summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorThanos Apollo <public@thanosapollo.org>2026-04-25 11:54:10 +0300
committerThanos Apollo <public@thanosapollo.org>2026-04-25 11:54:10 +0300
commit04abf0ace82f351de6b39bb1c38ae9ed1fb6fe6e (patch)
tree2d5524cfc01ba68f5b103a14a156384486081b3f
parent246d83c548da6b113c92f5bcd3d6083456944a02 (diff)
buffer: Use gfm-view-mode for body rendering, drop shr
Replace shr HTML rendering with gfm-view-mode markdown fontification. Removes the server-side markdown-to-HTML round trip from the sync pipeline entirely. Unified link following via forgejo-buffer-follow-link. markdown-mode is now a hard dependency (Package-Requires).
-rw-r--r--lisp/forgejo-api.el63
-rw-r--r--lisp/forgejo-buffer.el127
-rw-r--r--lisp/forgejo-issue.el8
-rw-r--r--lisp/forgejo-pull.el10
-rw-r--r--lisp/forgejo-utils.el36
-rw-r--r--lisp/forgejo-view.el50
-rw-r--r--lisp/forgejo.el3
7 files changed, 88 insertions, 209 deletions
diff --git a/lisp/forgejo-api.el b/lisp/forgejo-api.el
index b0a6c51..69bba69 100644
--- a/lisp/forgejo-api.el
+++ b/lisp/forgejo-api.el
@@ -238,68 +238,5 @@ Calls CALLBACK with the settings alist when done."
"Return the cached default page limit, or 50 as fallback."
(or forgejo--api-default-limit 50))
-;;; Markdown rendering
-
-(defun forgejo-api-render-markdown (host text &optional context)
- "Render markdown TEXT to HTML via the Forgejo API on HOST.
-CONTEXT is an optional \"owner/repo\" string for resolving
-references. Returns the HTML string synchronously."
- (let ((url-request-method "POST")
- (url-request-extra-headers
- `(("Authorization" . ,(encode-coding-string
- (concat "token " (forgejo-token host))
- 'ascii))
- ("Content-Type" . "application/json")))
- (url-request-data
- (encode-coding-string
- (json-encode `((Context . ,(or context ""))
- (Mode . "gfm")
- (Text . ,text)))
- 'utf-8)))
- (with-current-buffer
- (url-retrieve-synchronously
- (format "%s/api/v1/markdown" host) t)
- (goto-char (point-min))
- (re-search-forward "\r?\n\r?\n" nil t)
- (let ((html (decode-coding-string
- (buffer-substring-no-properties (point) (point-max))
- 'utf-8)))
- (kill-buffer (current-buffer))
- html))))
-
-(defun forgejo-api-render-markdown-async (host text context callback)
- "Render markdown TEXT to HTML asynchronously on HOST.
-CONTEXT is \"owner/repo\" for resolving references.
-CALLBACK is called with (HTML) on success, nil on failure."
- (let ((url-request-method "POST")
- (url-request-extra-headers
- `(("Authorization" . ,(encode-coding-string
- (concat "token " (forgejo-token host))
- 'ascii))
- ("Content-Type" . "application/json")))
- (url-request-data
- (encode-coding-string
- (json-encode `((Context . ,(or context ""))
- (Mode . "gfm")
- (Text . ,text)))
- 'utf-8)))
- (url-retrieve
- (format "%s/api/v1/markdown" host)
- (lambda (status)
- (if (plist-get status :error)
- (progn (kill-buffer (current-buffer))
- (when callback (funcall callback nil)))
- (unwind-protect
- (progn
- (goto-char (point-min))
- (re-search-forward "\r?\n\r?\n" nil t)
- (let ((html (decode-coding-string
- (buffer-substring-no-properties
- (point) (point-max))
- 'utf-8)))
- (when callback (funcall callback html))))
- (kill-buffer (current-buffer)))))
- nil t)))
-
(provide 'forgejo-api)
;;; forgejo-api.el ends here
diff --git a/lisp/forgejo-buffer.el b/lisp/forgejo-buffer.el
index de00701..d9943d2 100644
--- a/lisp/forgejo-buffer.el
+++ b/lisp/forgejo-buffer.el
@@ -27,9 +27,8 @@
(require 'cl-lib)
(require 'ewoc)
-(require 'shr)
-(require 'dom)
(require 'diff-mode)
+(require 'markdown-mode)
(require 'forgejo)
(defvar forgejo-repo--host)
@@ -156,29 +155,23 @@ Returns nil if BODY is :null, nil, or empty."
(when (and body (stringp body) (not (string-empty-p body)))
(replace-regexp-in-string "\r" "" body)))
-(defun forgejo-buffer--shr-blockquote (dom)
- "Render a blockquote DOM node with `forgejo-blockquote-face'."
- (let ((start (point))
- (shr-indentation (+ shr-indentation 2)))
- (shr-ensure-newline)
- (shr-generic dom)
- (shr-ensure-newline)
- (put-text-property start (point) 'face 'forgejo-blockquote-face)))
-
(defun forgejo-buffer--linkify-refs (start end)
- "Add clickable properties to #N issue/PR references between START and END."
+ "Mark bare #N issue/PR references between START and END as clickable.
+Skips positions already handled by shr or markdown link rendering.
+Sets `forgejo-ref-number' for `forgejo-buffer-follow-link' to use."
(save-excursion
(goto-char start)
- (while (re-search-forward "#\\([0-9]+\\)" end t)
- (unless (get-text-property (match-beginning 0) 'keymap)
- (let ((number (string-to-number (match-string 1))))
- (add-text-properties
- (match-beginning 0) (match-end 0)
- (list 'forgejo-ref-number number
- 'keymap forgejo-buffer-ref-map
- 'face 'link
- 'mouse-face 'highlight
- 'help-echo "RET: view this issue/PR")))))))
+ (while (re-search-forward
+ "\\(?:\\([A-Za-z0-9._-]+/[A-Za-z0-9._-]+\\)\\)?#\\([0-9]+\\)" end t)
+ (unless (or (get-text-property (match-beginning 0) 'forgejo-ref-number)
+ (get-text-property (match-beginning 0) 'keymap))
+ (add-text-properties
+ (match-beginning 0) (match-end 0)
+ (list 'forgejo-ref-number (string-to-number (match-string 2))
+ 'forgejo-ref-repo (match-string 1)
+ 'face 'link
+ 'mouse-face 'highlight
+ 'help-echo "RET: view this issue/PR"))))))
(defun forgejo-buffer--parse-forgejo-url (url)
"Parse a Forgejo issue/PR URL into (OWNER REPO NUMBER), or nil."
@@ -195,30 +188,43 @@ Returns nil if BODY is :null, nil, or empty."
(forgejo-view-item (nth 0 parsed) (nth 1 parsed) (nth 2 parsed))
(browse-url-default-browser url)))
-(defun forgejo-buffer--insert-html (html)
- "Insert rendered HTML into the current buffer using shr.
-Applies `forgejo-blockquote-face' to quoted text and linkifies #N references.
-Forgejo issue/PR links open in forgejo.el instead of the browser.
-Falls back to plain text insertion if HTML parsing fails."
- (when (and html (stringp html) (not (string-empty-p html)))
- (condition-case nil
- (let ((dom (with-temp-buffer
- (insert html)
- (libxml-parse-html-region (point-min) (point-max))))
- (shr-use-fonts nil)
- (shr-width (min (- (window-width) 4) 80))
- (start (point)))
- (let ((shr-external-rendering-functions
- (cons '(blockquote . forgejo-buffer--shr-blockquote)
- shr-external-rendering-functions)))
- (shr-insert-document dom))
- (forgejo-buffer--linkify-refs start (point)))
- (error (insert html "\n")))))
-
-(defun forgejo-buffer--body-or-text (alist)
- "Return body_html from ALIST if available, else cleaned body text."
- (or (alist-get 'body_html alist)
- (forgejo-buffer--clean-body (alist-get 'body alist))))
+;;; URL following
+
+(defun forgejo-buffer--url-at-point ()
+ "Return the URL at point via `markdown-link-url'."
+ (markdown-link-url))
+
+(defun forgejo-buffer-follow-link ()
+ "Follow the link at point.
+Handles #N issue/PR refs and markdown URLs."
+ (interactive)
+ (cond
+ ((get-text-property (point) 'forgejo-ref-number)
+ (forgejo-buffer-follow-ref))
+ ((forgejo-buffer--url-at-point)
+ (forgejo-buffer--browse-url (forgejo-buffer--url-at-point)))
+ (t (user-error "No link at point"))))
+
+;;; Body rendering
+
+(defun forgejo-buffer--fontify-markdown (text)
+ "Return TEXT fontified with `gfm-view-mode' in a temp buffer.
+Uses the view mode so markdown link URLs are hidden.
+Prepends a blank line to prevent YAML front-matter misparsing."
+ (with-temp-buffer
+ (insert "\n" text)
+ (gfm-view-mode)
+ (font-lock-ensure)
+ (buffer-substring (+ (point-min) 1) (point-max))))
+
+(defun forgejo-buffer--insert-body (body &optional _body-html)
+ "Insert BODY text fontified with `gfm-mode'."
+ (when (and body (stringp body) (not (string-empty-p body)))
+ (let ((start (point)))
+ (insert (forgejo-buffer--fontify-markdown
+ (forgejo-buffer--clean-body body))
+ "\n")
+ (forgejo-buffer--linkify-refs start (point)))))
;;; Separator
@@ -270,7 +276,7 @@ NODE-DATA is a plist with :type and type-specific keys."
(state (plist-get data :state))
(author (plist-get data :author))
(number (plist-get data :number))
- (body-html (plist-get data :body-html))
+ (body (plist-get data :body))
(labels (plist-get data :labels))
(milestone (plist-get data :milestone))
(assignees (plist-get data :assignees))
@@ -297,17 +303,16 @@ NODE-DATA is a plist with :type and type-specific keys."
(mapconcat (lambda (a) (alist-get 'login a)) assignees ", ")
"\n"))
(forgejo-buffer--insert-separator)
- ;; Body displayed like a comment from the author
- (when body-html
+ (when body
(insert (propertize author 'face 'forgejo-comment-author-face)
"\n\n")
- (forgejo-buffer--insert-html body-html)
+ (forgejo-buffer--insert-body body)
(forgejo-buffer--insert-separator))))
(defun forgejo-buffer--pp-comment (data)
"Render a comment from DATA plist."
(let ((author (plist-get data :author))
- (body-html (plist-get data :body-html))
+ (body (plist-get data :body))
(created (plist-get data :created-at))
(updated (plist-get data :updated-at)))
(insert (propertize author 'face 'forgejo-comment-author-face)
@@ -316,7 +321,7 @@ NODE-DATA is a plist with :type and type-specific keys."
(when (and created updated (not (string= created updated)))
(forgejo-buffer--insert-edited-indicator))
(insert "\n\n")
- (forgejo-buffer--insert-html body-html)
+ (forgejo-buffer--insert-body body)
(forgejo-buffer--insert-separator)))
(defvar forgejo-buffer-ref-map
@@ -538,7 +543,7 @@ Prompts for review type: comment or request_changes."
:id (alist-get 'id event)
:author actor
:body (alist-get 'body event)
- :body-html (forgejo-buffer--body-or-text event)
+ :body-html (alist-get 'body event)
:created-at (alist-get 'created_at event)
:updated-at (alist-get 'updated_at event)))
@@ -659,13 +664,13 @@ Returns a list of nodes (may be multiple for review with threads)."
:created-at (alist-get 'created_at event)
:threads threads
:body-html (when (and body (not (string-empty-p body)))
- (forgejo-buffer--body-or-text event)))))
+ (alist-get 'body event)))))
((and body (not (string-empty-p body)))
(list (list :type 'comment
:id (alist-get 'id event)
:author actor
:body body
- :body-html (forgejo-buffer--body-or-text event)
+ :body-html (alist-get 'body event)
:created-at (alist-get 'created_at event))))
(t
(list (list :type 'event
@@ -777,7 +782,7 @@ Both should be alists with `body_html' pre-populated from the DB."
:state .state
:author (or (forgejo-buffer--login .user) "unknown")
:body .body
- :body-html (forgejo-buffer--body-or-text issue-data)
+ :body-html (alist-get 'body issue-data)
:labels (when (listp .labels) .labels)
:milestone (when (listp .milestone)
(alist-get 'title .milestone))
@@ -863,7 +868,7 @@ Both should be alists with `body_html' pre-populated from the DB."
(created (plist-get data :created-at))
(threads (plist-get data :threads))
(review-id (plist-get data :review-id))
- (body-html (plist-get data :body-html)))
+ (body (plist-get data :body)))
(insert (propertize actor 'face 'forgejo-comment-author-face)
(propertize (concat " reviewed " (forgejo-buffer--relative-time created))
'face 'shadow)
@@ -890,8 +895,8 @@ Both should be alists with `body_html' pre-populated from the DB."
(propertize "(resolved)" 'face 'success)
(propertize "(unresolved)" 'face 'warning))
"\n")))
- (when body-html
- (forgejo-buffer--insert-html body-html))
+ (when body
+ (forgejo-buffer--insert-body body))
(forgejo-buffer--insert-separator)))
;;; Review thread buffer
@@ -945,7 +950,7 @@ Both should be alists with `body_html' pre-populated from the DB."
;; Comments
(dolist (c comments)
(let ((author (or (alist-get 'login (alist-get 'user c)) "unknown"))
- (body (or (alist-get 'body_html c) (alist-get 'body c)))
+ (body (alist-get 'body c))
(created (alist-get 'created_at c)))
(insert "\n"
(propertize author 'face 'forgejo-comment-author-face)
@@ -953,7 +958,7 @@ Both should be alists with `body_html' pre-populated from the DB."
(forgejo-buffer--relative-time created))
'face 'shadow)
"\n")
- (forgejo-buffer--insert-html body)
+ (forgejo-buffer--insert-body body)
(forgejo-buffer--insert-separator))))
(use-local-map forgejo-review-thread-mode-map)
(setq buffer-read-only t)
diff --git a/lisp/forgejo-issue.el b/lisp/forgejo-issue.el
index 2337bcf..774c9a4 100644
--- a/lisp/forgejo-issue.el
+++ b/lisp/forgejo-issue.el
@@ -294,6 +294,7 @@ Empty input clears all filters."
(defvar forgejo-issue-view-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "q") #'quit-window)
+ (define-key map (kbd "RET") #'forgejo-buffer-follow-link)
(define-key map (kbd "g") #'forgejo-view-refresh)
(define-key map (kbd "b") #'forgejo-view-browse)
(define-key map (kbd "c") #'forgejo-view-comment)
@@ -344,14 +345,9 @@ When RESTORE-LINE is non-nil, go to that line after re-rendering."
(list (cons "limit" (number-to-string forgejo-timeline-page-size)))
(lambda (timeline _tl-headers)
(forgejo-db-save-timeline host owner repo number timeline)
- ;; First render with whatever we have
(forgejo-view--re-render
buf-name host-url host owner repo number
- #'forgejo-issue--render-detail restore-line)
- ;; Then render missing HTML and re-render
- (forgejo-view--render-missing-html
- host-url host owner repo number buf-name restore-line
- #'forgejo-issue--render-detail)))))))
+ #'forgejo-issue--render-detail restore-line)))))))
;;; Detail view entry
diff --git a/lisp/forgejo-pull.el b/lisp/forgejo-pull.el
index 4932242..a3207b3 100644
--- a/lisp/forgejo-pull.el
+++ b/lisp/forgejo-pull.el
@@ -257,6 +257,7 @@ Shows cached data immediately, then syncs from the API in the background."
(defvar forgejo-pull-view-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "q") #'quit-window)
+ (define-key map (kbd "RET") #'forgejo-buffer-follow-link)
(define-key map (kbd "g") #'forgejo-view-refresh)
(define-key map (kbd "b") #'forgejo-view-browse)
(define-key map (kbd "c") #'forgejo-view-comment)
@@ -309,11 +310,11 @@ When RESTORE-LINE is non-nil, go to that line after re-rendering."
(list (cons "limit" (number-to-string forgejo-timeline-page-size)))
(lambda (timeline _tl-headers)
(forgejo-db-save-timeline host owner repo number timeline)
- ;; First render with whatever we have
+ ;; Render with whatever we have
(forgejo-view--re-render
buf-name host-url host owner repo number
#'forgejo-pull--render-detail restore-line)
- ;; Fetch review comments, then render missing HTML
+ ;; Fetch review comments, then re-render
(let ((tl-alists (mapcar #'forgejo-db--row-to-timeline-alist
(forgejo-db-get-timeline host owner repo number))))
(forgejo-review-sync-comments
@@ -321,10 +322,7 @@ When RESTORE-LINE is non-nil, go to that line after re-rendering."
(lambda ()
(forgejo-view--re-render
buf-name host-url host owner repo number
- #'forgejo-pull--render-detail restore-line)
- (forgejo-view--render-missing-html
- host-url host owner repo number buf-name restore-line
- #'forgejo-pull--render-detail))))))))))
+ #'forgejo-pull--render-detail restore-line))))))))))
(defun forgejo-pull-view-at-point ()
"View the PR at point in the list."
diff --git a/lisp/forgejo-utils.el b/lisp/forgejo-utils.el
index 1752946..7f466c5 100644
--- a/lisp/forgejo-utils.el
+++ b/lisp/forgejo-utils.el
@@ -294,46 +294,38 @@ HOST is the hostname for DB cache update. CALLBACK is called on success."
"Edit the body of issue/PR NUMBER in OWNER/REPO on HOST-URL.
CURRENT-BODY is pre-filled in the editor. CALLBACK is called on success."
(let ((body (forgejo-utils-read-body "Edit body" current-body))
- (host (url-host (url-generic-parse-url host-url)))
- (context (format "%s/%s" owner repo)))
+ (host (url-host (url-generic-parse-url host-url))))
(when (and body (not (string= body (or current-body ""))))
(forgejo-api-patch
host-url
(format "repos/%s/%s/issues/%d" owner repo number)
`((body . ,body))
(lambda (_data _headers)
- (forgejo-api-render-markdown-async
- host-url body context
- (lambda (html)
- (forgejo-db--execute
- "UPDATE issues SET body = ?, body_html = ?
- WHERE host = ? AND owner = ? AND repo = ? AND number = ?"
- (list body (or html "") host owner repo number))
- (message "Updated body of %s/%s#%d" owner repo number)
- (when callback (funcall callback)))))))))
+ (forgejo-db--execute
+ "UPDATE issues SET body = ?
+ WHERE host = ? AND owner = ? AND repo = ? AND number = ?"
+ (list body host owner repo number))
+ (message "Updated body of %s/%s#%d" owner repo number)
+ (when callback (funcall callback)))))))
(defun forgejo-utils-edit-comment (host-url owner repo comment-id current-body
callback)
"Edit comment COMMENT-ID in OWNER/REPO on HOST-URL.
CURRENT-BODY is pre-filled in the editor. CALLBACK is called on success."
(let ((body (forgejo-utils-read-body "Edit comment" current-body))
- (host (url-host (url-generic-parse-url host-url)))
- (context (format "%s/%s" owner repo)))
+ (host (url-host (url-generic-parse-url host-url))))
(when (and body (not (string= body (or current-body ""))))
(forgejo-api-patch
host-url
(format "repos/%s/%s/issues/comments/%d" owner repo comment-id)
`((body . ,body))
(lambda (_data _headers)
- (forgejo-api-render-markdown-async
- host-url body context
- (lambda (html)
- (forgejo-db--execute
- "UPDATE timeline_events SET body = ?, body_html = ?
- WHERE host = ? AND owner = ? AND repo = ? AND id = ?"
- (list body (or html "") host owner repo comment-id))
- (message "Updated comment %d in %s/%s" comment-id owner repo)
- (when callback (funcall callback)))))))))
+ (forgejo-db--execute
+ "UPDATE timeline_events SET body = ?
+ WHERE host = ? AND owner = ? AND repo = ? AND id = ?"
+ (list body host owner repo comment-id))
+ (message "Updated comment %d in %s/%s" comment-id owner repo)
+ (when callback (funcall callback)))))))
;;; Label/assignee/milestone management
diff --git a/lisp/forgejo-view.el b/lisp/forgejo-view.el
index dc4f893..7744809 100644
--- a/lisp/forgejo-view.el
+++ b/lisp/forgejo-view.el
@@ -155,56 +155,6 @@ Restores point to RESTORE-LINE if given."
(goto-char (point-min))
(forward-line (1- restore-line)))))))
-;;; Markdown rendering for missing HTML
-
-(defun forgejo-view--render-missing-html (host-url host owner repo number
- buf-name restore-line
- render-detail-fn)
- "Render markdown to HTML for entries missing body_html.
-HOST-URL is the instance. HOST is the hostname.
-After rendering, re-render BUF-NAME via RENDER-DETAIL-FN and
-restore RESTORE-LINE."
- (let* ((context (format "%s/%s" owner repo))
- (item (forgejo-db-get-issue host owner repo number))
- (tl-rows (forgejo-db-get-timeline host owner repo number))
- (tl-alists (mapcar #'forgejo-db--row-to-timeline-alist tl-rows))
- (pending 0)
- (render-done
- (lambda ()
- (cl-decf pending)
- (when (<= pending 0)
- (forgejo-view--re-render
- buf-name host-url host owner repo number
- render-detail-fn restore-line)))))
- ;; Item body
- (when (and item
- (not (alist-get 'body_html item))
- (alist-get 'body item))
- (cl-incf pending)
- (forgejo-api-render-markdown-async
- host-url (alist-get 'body item) context
- (lambda (html)
- (when html
- (forgejo-db-update-issue-html host owner repo number html))
- (funcall render-done))))
- ;; Timeline comment bodies
- (dolist (evt tl-alists)
- (when (and (string= "comment" (or (alist-get 'type evt) ""))
- (not (alist-get 'body_html evt))
- (alist-get 'body evt))
- (let ((evt-id (alist-get 'id evt)))
- (cl-incf pending)
- (forgejo-api-render-markdown-async
- host-url (alist-get 'body evt) context
- (lambda (html)
- (when html
- (forgejo-db-update-timeline-html
- host owner repo number evt-id html))
- (funcall render-done))))))
- ;; Nothing to render
- (when (zerop pending)
- (funcall render-done))))
-
;;; Action commands
(defun forgejo-view-toggle-state ()
diff --git a/lisp/forgejo.el b/lisp/forgejo.el
index 32063fc..4f94cd0 100644
--- a/lisp/forgejo.el
+++ b/lisp/forgejo.el
@@ -6,7 +6,7 @@
;; Keywords: extensions
;; URL: https://codeberg.org/thanosapollo/emacs-forgejo
;; Version: 0.0.1
-;; Package-Requires: ((emacs "29.1"))
+;; Package-Requires: ((emacs "29.1") (markdown-mode "2.6"))
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
@@ -46,6 +46,7 @@ Use this to enable modes like `markdown-mode' or `flyspell-mode'."
:type 'hook
:group 'forgejo)
+
(defcustom forgejo-hosts '(("https://codeberg.org"))
"List of known Forgejo instances.
Each entry is (URL) or (URL TOKEN). When TOKEN is omitted,