From 04abf0ace82f351de6b39bb1c38ae9ed1fb6fe6e Mon Sep 17 00:00:00 2001 From: Thanos Apollo Date: Sat, 25 Apr 2026 11:54:10 +0300 Subject: 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). --- lisp/forgejo-api.el | 63 ------------------------ lisp/forgejo-buffer.el | 127 +++++++++++++++++++++++++------------------------ lisp/forgejo-issue.el | 8 +--- lisp/forgejo-pull.el | 10 ++-- lisp/forgejo-utils.el | 36 ++++++-------- lisp/forgejo-view.el | 50 ------------------- lisp/forgejo.el | 3 +- 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, -- cgit v1.0