;;; mhtml-ts-mode.el --- Major mode for HTML using tree-sitter -*- lexical-binding: t; -*-
;; Copyright (C) 2024-2025 Free Software Foundation, Inc.
;; Author: Vincenzo Pupillo
;; Maintainer: Vincenzo Pupillo
;; Created: Nov 2024
;; Keywords: HTML languages hypermedia tree-sitter
;; This file is part of GNU Emacs.
;; GNU Emacs 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 of the License, or
;; (at your option) any later version.
;; GNU Emacs 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 provides `mhtml-ts-mode' which is a major mode
;; for editing HTML files with embedded JavaScript and CSS.
;; Tree Sitter is used to parse each of these languages.
;;
;; Please note that this package requires `html-ts-mode', which
;; registers itself as the major mode for editing HTML.
;;
;; This package is compatible and has been tested with the following
;; tree-sitter grammars:
;; * https://github.com/tree-sitter/tree-sitter-html
;; * https://github.com/tree-sitter/tree-sitter-javascript
;; * https://github.com/tree-sitter/tree-sitter-jsdoc
;; * https://github.com/tree-sitter/tree-sitter-css
;;
;; Features
;;
;; * Indent
;; * Flymake
;; * IMenu
;; * Navigation
;; * Which-function
;; * Tree-sitter parser installation helper
;;; Code:
(require 'treesit)
(require 'html-ts-mode)
(require 'css-mode) ;; for embed css into html
(require 'js) ;; for embed javascript into html
(eval-when-compile
(require 'rx))
;; This tells the byte-compiler where the functions are defined.
;; Is only needed when a file needs to be able to byte-compile
;; in a Emacs not built with tree-sitter library.
(treesit-declare-unavailable-functions)
;; In a multi-language major mode can be useful to have an "installer" to
;; simplify the installation of the grammars supported by the major-mode.
(defvar mhtml-ts-mode--language-source-alist
'((html . ("https://github.com/tree-sitter/tree-sitter-html" "v0.23.2"))
(javascript . ("https://github.com/tree-sitter/tree-sitter-javascript" "v0.23.1"))
(jsdoc . ("https://github.com/tree-sitter/tree-sitter-jsdoc" "v0.23.2"))
(css . ("https://github.com/tree-sitter/tree-sitter-css" "v0.23.1")))
"Treesitter language parsers required by `mhtml-ts-mode'.
You can customize this variable if you want to stick to a specific
commit and/or use different parsers.")
(defun mhtml-ts-mode-install-parsers ()
"Install all the required treesitter parsers.
`mhtml-ts-mode--language-source-alist' defines which parsers to install."
(interactive)
(let ((treesit-language-source-alist mhtml-ts-mode--language-source-alist))
(dolist (item mhtml-ts-mode--language-source-alist)
(treesit-install-language-grammar (car item)))))
;;; Custom variables
(defgroup mhtml-ts-mode nil
"Major mode for editing HTML files, based on `html-ts-mode'.
Works with JS and CSS and for that use `js-ts-mode' and `css-ts-mode'."
:prefix "mhtml-ts-mode-"
;; :group 'languages
:group 'html)
(defcustom mhtml-ts-mode-js-css-indent-offset 2
"JavaScript and CSS indent spaces related to the
When nil, indentation of the tag body starts just below the
tag, like:
When `ignore', the tag body starts in the first column, like:
"
:type '(choice (const nil) (const t) (const ignore))
:safe 'symbolp
:set #'mhtml-ts-mode--tag-relative-indent-offset
:version "31.1")
(defcustom mhtml-ts-mode-css-fontify-colors t
"Whether CSS colors should be fontified using the color as the background.
If non-nil, text representing a CSS color will be fontified
such that its background is the color itself.
Works like `css--fontify-region'."
:tag "HTML colors the CSS properties values."
:version "31.1"
:type 'boolean
:safe 'booleanp)
(defvar mhtml-ts-mode-saved-pretty-print-command nil
"The command last used to pretty print in this buffer.")
(defun mhtml-ts-mode-pretty-print (command)
"Prettify the current buffer.
Argument COMMAND The command to use."
(interactive
(list (read-string
"Prettify command: "
(or mhtml-ts-mode-saved-pretty-print-command
(concat mhtml-ts-mode-pretty-print-command " ")))))
(setq mhtml-ts-mode-saved-pretty-print-command command)
(save-excursion
(shell-command-on-region
(point-min) (point-max)
command (buffer-name) t
"*mhtml-ts-mode-pretty-pretty-print-errors*" t)))
(defun mhtml-ts-mode--switch-fill-defun (&rest arguments)
"Switch between `fill-paragraph' and `prog-fill-reindent-defun'.
In an HTML region it calls `fill-paragraph' as does `html-ts-mode',
otherwise it calls `prog-fill-reindent-defun'.
Optional ARGUMENTS to to be passed to it."
(interactive)
(if (eq (treesit-language-at (point)) 'html)
(funcall-interactively #'fill-paragraph arguments)
(funcall-interactively #'prog-fill-reindent-defun arguments)))
(defvar-keymap mhtml-ts-mode-map
:doc "Keymap for `mhtml-ts-mode' buffers."
:parent html-mode-map
;; `mhtml-ts-mode' derive from `html-ts-mode' so the keymap is the
;; same, we need to add some mapping from others languages.
"C-c C-f" #'css-cycle-color-format
"M-q" #'mhtml-ts-mode--switch-fill-defun)
;; Place the CSS menu in the menu bar as well.
(easy-menu-define mhtml-ts-mode-menu mhtml-ts-mode-map
"Menu bar for `mhtml-ts-mode'."
css-mode--menu)
;; To enable some basic treesiter functionality, you should define
;; a function that recognizes which grammar is used at-point.
;; This function should be assigned to `treesit-language-at-point-function'
(defun mhtml-ts-mode--language-at-point (point)
"Return the language at POINT assuming the point is within a HTML buffer."
(let* ((node (treesit-node-at point 'html))
(parent (treesit-node-parent node))
(node-query (format "(%s (%s))"
(treesit-node-type parent)
(treesit-node-type node))))
(cond
((equal "(script_element (raw_text))" node-query) (js--treesit-language-at-point point))
((equal "(style_element (raw_text))" node-query) 'css)
(t 'html))))
;; Custom font-lock function that's used to apply color to css color
;; The signature of the function should be conforming to signature
;; QUERY-SPEC required by `treesit-font-lock-rules'.
(defun mhtml-ts-mode--colorize-css-value (node override start end &rest _)
"Colorize CSS property value like `css--fontify-region'.
For NODE, OVERRIDE, START, and END, see `treesit-font-lock-rules'."
(if (and mhtml-ts-mode-css-fontify-colors
(string-equal "plain_value" (treesit-node-type node)))
(let ((color (css--compute-color start (treesit-node-text node t))))
(when color
(with-silent-modifications
(add-text-properties
(treesit-node-start node) (treesit-node-end node)
(list 'face (list :background color
:foreground (readable-foreground-color
color)
:box '(:line-width -1)))))))
(treesit-fontify-with-override
(treesit-node-start node) (treesit-node-end node)
'font-lock-variable-name-face
override start end)))
;; Embedded languages should be indented according to the language
;; that embeds them.
;; This function signature complies with `treesit-simple-indent-rules'
;; ANCHOR.
(defun mhtml-ts-mode--js-css-tag-bol (_node _parent &rest _)
"Find the first non-space characters of html tags