;;; markdown-preview-mode.el --- markdown realtime preview minor mode. ;; Copyright (C) 2014 ;; Author: Igor Shymko ;; URL: https://github.com/ancane/markdown-preview-mode ;; Keywords: markdown, gfm, convenience ;; Version: 0.9.4 ;; Package-Requires: ((emacs "24.4") (websocket "1.6") (markdown-mode "2.0") (cl-lib "0.5") (web-server "0.1.1") ) ;; This file is not part of GNU Emacs. ;; 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 ;; the Free Software Foundation; either version 3, or (at your option) ;; any later version. ;; ;; This program is distributed in the hope that it will be useful, ;; but WITHOUT ANY WARRANTY; without even the implied warranty of ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ;; GNU General Public License for more details. ;; ;; You should have received a copy of the GNU General Public License ;; along with GNU Emacs. If not, see . ;;; Commentary: ;; ;; This package makes use of websockets to deliver rendered markdown to a web browser. ;; Updates happen upon buffer save or on idle. ;; ;;; Code: (eval-when-compile (require 'cl)) (require 'cl-lib) (require 'websocket) (require 'markdown-mode) (require 'web-server) (defgroup markdown-preview nil "Markdown preview mode." :group 'text :prefix "markdown-preview-" :link '(url-link "https://github.com/ancane/markdown-preview-mode")) (defcustom markdown-preview-host "localhost" "Markdown preview websocket server address." :group 'markdown-preview :type 'string) (defcustom markdown-preview-ws-port 7379 "Markdown preview websocket server port." :group 'markdown-preview :type 'integer) (defcustom markdown-preview-http-host "localhost" "Markdown preview http server address." :group 'markdown-preview :type 'string) (defcustom markdown-preview-http-port 9000 "Markdown preview http server port." :group 'markdown-preview :type 'integer) (defcustom markdown-preview-style nil "Deprecated. Use `markdown-preview-stylesheets'." :group 'markdown-preview :type 'string) (defcustom markdown-preview-file-name ".markdown-preview.html" "Markdown preview file name." :group 'markdown-preview :type 'string) (defcustom markdown-preview-auto-open 'http "Markdown preview websocket server address." :group 'markdown-preview :type '(choice (const :tag "As local file" file) (const :tag "Via http" http) (const :tag "Off" nil))) (defcustom markdown-preview-delay-time 2.0 "Refresh preview after this certain of time." :group 'markdown-preview :type 'float) (defcustom markdown-preview-script-oninit "" "Markdown preview javascript which runs on init." :group 'markdown-preview :type 'string) (defcustom markdown-preview-script-onupdate "" "Markdown preview javascript which runs on update preview." :group 'markdown-preview :type 'string) (defvar markdown-preview-javascript '() "List of client javascript libs for preview.") (defvar markdown-preview-stylesheets (list "https://thomasf.github.io/solarized-css/solarized-dark.min.css") "List of client stylesheets for preview.") (defvar markdown-preview--websocket-server nil "`markdown-preview' Websocket server.") (defvar markdown-preview--http-server nil "`markdown-preview' http server.") (defvar markdown-preview--local-client nil "`markdown-preview' local client.") (defvar markdown-preview--remote-clients (make-hash-table :test 'equal) "Remote clients hashtable. UUID -> WS.") (defvar markdown-preview--home-dir (file-name-directory load-file-name) "`markdown-preview-mode' home directory.") (defvar markdown-preview--preview-template (expand-file-name "preview.html" markdown-preview--home-dir) "`markdown-preview-mode' html preview template.") (defvar markdown-preview--idle-timer nil "Preview idle timer.") (defvar markdown-preview--uuid nil "Unique preview identifier.") (defvar markdown-preview--preview-buffers (make-hash-table :test 'equal) "Preview buffers hashtable. UUID -> buffer name.") (defun markdown-preview--stop-idle-timer () "Stop the `markdown-preview' idle timer." (when (timerp markdown-preview--idle-timer) (cancel-timer markdown-preview--idle-timer))) (defun markdown-preview--css () "Get list of styles for preview in backward compatible way." (let* ((custom-style (list markdown-preview-style)) (all-styles (mapc (lambda (x) (add-to-list 'custom-style x t)) markdown-preview-stylesheets))) (mapconcat (lambda (x) (if (string-match-p "^[\n\t ]*"))) all-styles "\n"))) (defun markdown-preview--scripts () "Get list of javascript script tags for preview." (mapconcat (lambda (x) (if (string-match-p "^[\n\t ]*"))) markdown-preview-javascript "\n")) (defun markdown-preview--read-preview-template (preview-uuid preview-file) "Read preview template and writes identified by PREVIEW-UUID rendered copy to PREVIEW-FILE, ready to be open in browser." (with-temp-file preview-file (insert-file-contents markdown-preview--preview-template) (when (search-forward "${MD_STYLE}" nil t) (replace-match (markdown-preview--css) t)) (when (search-forward "${MD_JS}" nil t) (replace-match (markdown-preview--scripts) t)) (when (search-forward "${MD_JS_ONINIT}" nil t) (replace-match markdown-preview-script-oninit t)) (when (search-forward "${WS_HOST}" nil t) (replace-match markdown-preview-host t)) (when (search-forward "${WS_PORT}" nil t) (replace-match (format "%s" markdown-preview-ws-port) t)) (when (search-forward "${MD_UUID}" nil t) (replace-match (format "%s" preview-uuid) t)) (when (search-forward "${MD_JS_ONUPDATE}" nil t) (replace-match markdown-preview-script-onupdate t)) (buffer-string))) ;; Emacs 26 async network workaround (defun markdown-preview--fix-network-process-wait (plist) "Ensure PLIST contain :nowait nil." (if (and (>= emacs-major-version 26) (equal (plist-get plist :name) "ws-server") (plist-get plist :server) (plist-get plist :nowait)) (plist-put plist :nowait nil) plist)) (defun markdown-preview--start-http-server (port) "Start http server at PORT to serve preview file via http." (unless markdown-preview--http-server (lexical-let ((docroot default-directory)) (advice-add 'make-network-process :filter-args #'markdown-preview--fix-network-process-wait) (setq markdown-preview--http-server (ws-start (lambda (request) (with-slots (process headers) request (let* ((path (substring (cdr (assoc :GET headers)) 1)) (filename (expand-file-name path docroot))) (if (string= path "") (progn (ws-send-file process (expand-file-name markdown-preview-file-name (with-current-buffer (gethash (markdown-preview--parse-uuid headers) markdown-preview--preview-buffers) default-directory )))) (if (string= path "favicon.ico") (ws-send-file process (expand-file-name path markdown-preview--home-dir)) (if (and (not (file-directory-p filename)) (file-exists-p filename)) (ws-send-file process filename) (ws-send-404 process) )))))) markdown-preview-http-port nil :host markdown-preview-http-host)) (advice-remove 'make-network-process #'markdown-preview--fix-network-process-wait)))) (defun markdown-preview--parse-uuid (headers) "Find uuid query param in HEADERS." (let ((found (cl-find-if (lambda (x) (when (stringp (car x)) (equal "uuid" (format "%s" (car x))))) headers))) (when found (cdr found)))) (defun markdown-preview--open-browser-preview () "Open the markdown preview in the browser." (when (eq markdown-preview-auto-open 'file) (browse-url (expand-file-name markdown-preview-file-name default-directory))) (when (eq markdown-preview-auto-open 'http) (browse-url (format "http://localhost:%d/?uuid=%s" markdown-preview-http-port markdown-preview--uuid))) (unless markdown-preview-auto-open (message (format "Preview address: http://0.0.0.0:%d/?uuid=%s" markdown-preview-http-port markdown-preview--uuid)))) (defun markdown-preview--stop-websocket-server () "Stop the `markdown-preview' websocket server." (clrhash markdown-preview--preview-buffers) (when markdown-preview--local-client (websocket-close markdown-preview--local-client)) (when markdown-preview--websocket-server (delete-process markdown-preview--websocket-server) (setq markdown-preview--websocket-server nil) (clrhash markdown-preview--remote-clients))) (defun markdown-preview--stop-http-server () "Stop the `markdown-preview' http server." (when markdown-preview--http-server (ws-stop markdown-preview--http-server) (setq markdown-preview--http-server nil))) (defun markdown-preview--drop-closed-clients () "Clean closed clients in `markdown-preview--remote-clients' list." (maphash (lambda (ws-uuid websocket) (unless (websocket-openp websocket)) (remhash ws-uuid markdown-preview--remote-clients)) markdown-preview--remote-clients)) (defun markdown-preview--start-websocket-server () "Start `markdown-preview' websocket server." (when (not markdown-preview--websocket-server) (setq markdown-preview--websocket-server (websocket-server markdown-preview-ws-port :host markdown-preview-host :on-message (lambda (websocket frame) (let ((ws-frame-text (websocket-frame-payload frame))) (if (and (stringp ws-frame-text) (string-prefix-p "MDPM-Register-UUID: " ws-frame-text)) (let ((ws-uuid (substring ws-frame-text 20))) (puthash ws-uuid websocket markdown-preview--remote-clients) (markdown-preview--send-preview-to websocket ws-uuid)) (progn (websocket-send (gethash markdown-preview--uuid markdown-preview--remote-clients) frame)) ))) :on-open (lambda (websocket) (message "Websocket opened")) :on-error (lambda (websocket type err) (message (format "====> Error: %s" err))) :on-close (lambda (websocket) (markdown-preview--drop-closed-clients)))) (add-hook 'kill-emacs-hook 'markdown-preview--stop-websocket-server)) (markdown-preview--open-browser-preview)) (defun markdown-preview--start-local-client () "Start the `markdown-preview' local client." (when (not markdown-preview--local-client) (setq markdown-preview--local-client (websocket-open (format "ws://%s:%d" markdown-preview-host markdown-preview-ws-port) :on-error (lambda (ws type err) (message "error connecting")) :on-close (lambda (websocket) (setq markdown-preview--local-client nil)))))) (defun markdown-preview--send-preview (preview-uuid) "Send the `markdown-preview' with PREVIEW-UUID preview to clients." (when (bound-and-true-p markdown-preview-mode) (markdown-preview--send-preview-to markdown-preview--local-client preview-uuid))) (defun markdown-preview--send-preview-to (websocket preview-uuid) "Send the `markdown-preview' with PREVIEW-UUID to a specific WEBSOCKET." (let ((mark-position-percent (number-to-string (truncate (* 100 (/ (float (- (line-number-at-pos) (/ (count-screen-lines (window-start) (point)) 2))) (count-lines (point-min) (point-max)))))))) (let ((md-buffer (gethash preview-uuid markdown-preview--preview-buffers))) (when md-buffer (with-current-buffer md-buffer (markdown markdown-output-buffer-name)))) (with-current-buffer markdown-output-buffer-name ;; get-buffer (websocket-send-text websocket (concat "
" "" mark-position-percent "" "
" (buffer-substring-no-properties (point-min) (point-max)) "
" "
") )))) (defun markdown-preview--start () "Start `markdown-preview' mode." (setq-local markdown-preview--uuid (markdown-preview--random-uuid)) (puthash markdown-preview--uuid (buffer-name) markdown-preview--preview-buffers) ;; (gethash markdown-preview--uuid markdown-preview--preview-buffers) (markdown-preview--read-preview-template markdown-preview--uuid (expand-file-name markdown-preview-file-name default-directory)) (markdown-preview--start-websocket-server) (markdown-preview--start-local-client) (markdown-preview--start-http-server markdown-preview-http-port) (setq markdown-preview--idle-timer (run-with-idle-timer markdown-preview-delay-time t (lambda () (when markdown-preview--uuid (markdown-preview--send-preview markdown-preview--uuid))))) (add-hook 'after-save-hook (lambda () (when markdown-preview--uuid (markdown-preview--send-preview markdown-preview--uuid))) nil t)) (defun markdown-preview--stop () "Stop `markdown-preview' mode." (remove-hook 'after-save-hook 'markdown-preview--send-preview t) (markdown-preview--stop-idle-timer) (remhash markdown-preview--uuid markdown-preview--preview-buffers) (let* ((preview-file-dir (if (buffer-file-name) (file-name-directory (buffer-file-name)) default-directory)) (preview-file (concat preview-file-dir markdown-preview-file-name))) (if (file-exists-p preview-file) (delete-file preview-file)))) (defun markdown-preview--random-uuid () "Insert a UUID using a simple hashing of variable data. Example of a UUID: 1df63142-a513-c850-31a3-535fc3520c3d Note: this code uses https://en.wikipedia.org/wiki/Md5, which is not cryptographically safe. I'm not sure what's the implication of its use here. Version 2015-01-30 URL `http://ergoemacs.org/emacs/elisp_generate_uuid.html'" ;; by Christopher Wellons, 2011-11-18. Editted by Xah Lee. ;; Edited by Hideki Saito further to generate all valid variants for "N" in xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx format. ;; (interactive) (let ((myStr (md5 (format "%s%s%s%s%s%s%s%s%s%s" (user-uid) (emacs-pid) (system-name) (user-full-name) (current-time) (emacs-uptime) (garbage-collect) (buffer-string) (random) (recent-keys))))) (format "%s-%s-4%s-%s%s-%s" (substring myStr 0 8) (substring myStr 8 12) (substring myStr 13 16) (format "%x" (+ 8 (random 4))) (substring myStr 17 20) (substring myStr 20 32)))) ;;;###autoload (defun markdown-preview-open-browser () "Open the `markdown-preview' in the browser." (interactive) (markdown-preview--open-browser-preview)) ;;;###autoload (defun markdown-preview-cleanup () "Cleanup `markdown-preview' mode." (interactive) (markdown-preview--stop-websocket-server) (markdown-preview--stop-http-server)) ;;;###autoload (define-minor-mode markdown-preview-mode "Markdown preview mode." :group 'markdown-preview :init-value nil (when (not (or (equal major-mode 'markdown-mode) (equal major-mode 'gfm-mode))) (markdown-mode)) (if markdown-preview-mode (markdown-preview--start) (markdown-preview--stop))) (provide 'markdown-preview-mode) ;;; markdown-preview-mode.el ends here