drshapeless


Writing Go templ with Emacs

Tags: htmx | templ | emacs | go

Create: 2024-02-27, Update: 2024-02-28

After switching back to Go for web development, it has been simplified into a single Go backend spitting out htmx fragment. It is great and better than anything I have used before, including svelte.

However, this blog post is not about my experience of using go templ. Instead, I am going to talk about how I struggled to use templ in Emacs.

This is a very long blog post.

Go templ

The templ package is a library that allow us to write html template with go code. Similar experience can be achived by maud in Rust. However, the Rust solution is not that usable because of the long compile time. You definitely don't want to wait a few seconds when testing different tailwind classes.

Using templ with Emacs

Templ is great. As a die-hard Emacs fan, I would like to use Emacs. Sadly, when I first used templ last year, there wasn't even a major mode for it. The templ official site only supported vscode and neovim. I guess that is because not that many Emacs users are web developers.

I had to derive my own mode from web-mode. That was somehow usable, but the indentation and syntax highlight sucked.

Later in early 2024, the templ-ts-mode comes out, thanks for its author. Now, we finally have a dedicated major mode for templ, what even better is it comes with treesit support. (For those who doesn't understand the "ts" in the mode name, that "ts" is not about TypeScript, which is a stupid solution for overly complicated self-created problem, it stands for treesit.)

My only experience on tree-sitter is a very neat helper function for me to type the pointer arrow in C with two dots. .. => -> See this. I will talk about tree-sitter later.

Templ also comes with an lsp, which is fairly simple to setup with eglot in Emacs. The configuration is already included in the templ-ts-mode, no need for extra setup.

Limited functions

If you have ever used web-mode in Emacs, you will notice that web-mode provides a lot of extra functionalities without an lsp, but within Emacs itself. Like tag-name auto-completion, auto-closing tag when typing "</", wrapping of element.

If I derived my own templ-mode in the past, this would be inherited.

However, since templ-ts-mode derives from prog-mode, which is just a skeleton mode for all programming languages, all the good features of web-mode are gone.

Part of the fault is by the limited functionality of the templ lsp, if the templ lsp can provide auto-completion for html attributes and the corresponding values, that would be dream. Luckily, there seems to be an discussion on this issue.

Multiple LSP

Now, to achieve similar functions of editing HTML files in web-mode, we have two routes. First is to use multiple lsp on the same file, second is to find a way to customize the mechanism of auto-completion in Emacs.

Using the multiple lsp approach is more ideal, since an lsp not provides auto-completion, it also give diagnosis and fixes, which is good to have. (Well, I don't care about the fixes from lsp that much. The only use case of using an lsp fix is to import some header library.)

The lsp client I use in Emacs is eglot. It is lightweight, fast, minimalistic configuration. I even have written a blog post to compare different lsp clients in Emacs a couple years ago, emacs lsp clients comparison. I hold my point, eglot is still the best one to use.

However, when it comes to web development. Often, multiple lsp is needed to be used. Here is where eglot comes short. Eglot currently does not support multiple lsp. Although there were some discussion on supporting multiple lsp, there seemed to be no progress at all in the previous few years. The worst part is that, eglot is now merged into the emacs core, contributing to it would be a tedious process. I expect the support of multiple lsp servers in eglot is not going to happen in the foreseeable future.

If I have time to read all the specification of an lsp server, I may contribute to it. But now, I have no idea how an lsp server is implemented.

Now we comes to the alternatives, lsp-mode and lsp-bridge.

I first tried the lsp-mode first. But the previous bad experience of configuring it recalls a lot of headache. Another couple of years pass, lsp-mode is still a nightmare to configure. I just want the html-ls server and templ lsp to co-exist. And it requires me to write and register a whole lsp-client. Also, lsp-mode works best with company, which I have given up for a long time. I use corfu right now. An I recommend every one to use corfu. After a few attempts, I successfully launched the templ lsp. Next, I would want some tailwindcss auto-completion. Luckily, there is a package call lsp-tailwindcss, which is supposed to be plug and play. The way that lsp-mode supports multiple server is by an "add-on?" switch.

It does NOT work. Fuck that. No response from the tailwindcss server, at all. I even struggle to launch tailwindcss server on a ".templ" file every time. Sometimes it started the tailwind lsp and gave me an internal server error, sometimes it didn't even start.

I spent a whole afternoon to fix this issue, did not solve it. Gave up.

Let's see lsp-bridge. The reason I don't want to use lsp-bridge is because it depends on Python, even though all of my machines have Python installed. Using Python is still awkward from a Emacs user perspective. Not because I don't know how to write Python, but because I can no longer use elisp to configure the details of it. It may sound weird, I do prefer lisp over Python in configuration.

Another bad part is that, because it out-sources the heavy lifting of communicating lsp server to Python, the auto-completion is a whole new world, because it has to stay sync with the Python process. Guess what? The author wrote yet another completion framework called acm, other than company and corfu.

The configuration of lsp-bridge is done with editing some json files. Although they are not elisp, I would say that it is 100x easier to configuring lsp-mode. The biggest issue of lsp-bridge is the documentation and discussion. The documentation is long, filled with examples, but when something doesn't work, I can hardly find any discussion. Since lsp-bridge is developed by the Chinese hacker, a lot of discussion actually happened in the Emacs China Forum. Well, I can read Chinese. I am not a programmer. I have repeatedly said I am a professional Traditional Chinese Medicine practitioner. I did gain a lot of knowledge from the forum, but doesn't help.

The sad ending is that, lsp-bridge does not work either. I have yet to understand why the tailwindcss lsp server always shows error and no response at all. To clarify whether it is my configuration issue of tailwindcss lsp issue, I even downloaded vscode. (Forgive my sin, the almighty Emacs god.)

Vscode is supposed to be the most braindead solution to all of this, I even considered to write templ file exclusively in vscode if it works. For some unknown reasons, it doesn't work either. Normally, I should also download neovim for testing. But I was already so pissed and went to bed. (Downloading neovim may be too big for a sin that I would never be forgiven.)

Before bed, I was thinking about the wasted time in my precious weekend. What do I want from setting this lsp thing? Auto-completion. All I want is auto-completion, why bother with lsp if emacs can provide auto-completion?

Tailwindcss auto-completion

All it comes down to is auto-completion, I forget about all the lsp mess and went back to basic.

The same author of corfu has another package called cape. It provides a lot of completion-at-point function. The one catches my sight is cape-dict.

It reads a txt file containing words line by line, which is called a dict file, if I have a file which contains all the tailwindcss classes, with the help of cape-dict, I can implement auto-completion myself.

First, I have to get a file contains all the tailwindcss classes. I thought it would be simple. It is not. The official site does not have one. The closest you can get is this discussion, a guy who dedicated half a day of work to manually make a list. Yet, it is a js object, not even a json. I was about to manually write some code to generate a dict file. Something from the lsp-bridge catch my sight. In this issue, it said lsp-bridge supports tailwindcss-intellisense. And I dig deeper into lsp-bridge, I discovered tailwind. What? It is a list of all tailwindcss keywords, what a surprise.

I happily steal the file and put it into my Emacs config path.

Let's see some code. Do not copy this code into your config file now, a lot more thing needs to be modified. I will provide a complete configuration at the end.

(setq cape-dict-file
      (expand-file-name "dict/tailwind_css_keyword.txt"
                        user-emacs-directory))

(add-to-list 'completion-at-point-functions #'cape-dict)

Open a templ file. No completion at all. If we check the symbol completion-at-point-functions, it only shows (eglot-completion-at-point t).

It is because eglot will override all the original completion-at-point-functions every time when it successfully managed a buffer. What we can do is to add more functions to completion-at-point-functions after eglot has managed the buffer.

According to the corfu wiki, we can add more functions via cape-capf-super, like this.

(defun my/eglot-capf ()
  (setq-local completion-at-point-functions
              (list (cape-capf-super
                     #'eglot-completion-at-point
                     #'tempel-expand
                     #'cape-file))))

(add-hook 'eglot-managed-mode-hook #'my/eglot-capf)

Do NOT do this. For whatever reason, the cape-capf-super isn't even defined in the corfu package, the wiki may be outdated.

This is a working way to do.

(defun drsl/eglot-capf ()
  (add-to-list 'completion-at-point-functions #'cape-dict))

(add-hook 'eglot-managed-mode-hook #'my/eglot-capf)

But this has a problem, now we enabled tailwindcss completion is every programming language that eglot managed. I definitely do not want my C code to have anything to deal with tailwindcss. I decided to a some major mode checking before add-to-list.

(defun drsl/eglot-capf ()
  (when (eq major-mode 'templ-ts-mode)
   (add-to-list 'completion-at-point-functions #'cape-dict)))

(add-hook 'eglot-managed-mode-hook #'my/eglot-capf)

This doesn't seem to be a good idea either. Why would I have to check the major mode after eglot has managed the buffer? It is so counter logical. I should be able to do it with templ-ts-mode-hook. The final solution for me is to define a custom variable. Such a way that we can pre-define some universal completion functions.

(defcustom drsl/eglot-extra-completion-functions '(cape-file)
  "extra completion functions for eglot"
  :type '(repeat string))

(defun drsl/eglot-capf ()
  (mapc
   (lambda (FUNCTION)
     (add-to-list 'completion-at-point-functions
                  FUNCTION))
   drsl/eglot-extra-completion-functions))

(add-hook 'eglot-managed-mode-hook #'drsl/eglot-capf)

(add-hook 'templ-ts-mode-hook
          (lambda ()
            (add-to-list 'drsl/eglot-extra-completion-functions
                         #'cape-dict)))

It is working. But it has another issue, it now completes tailwindcss classes everywhere, fuck.

Let's limit the completion within the class attribute. In the old days, we would have to write some regex to determine where we are at. Now, thanks to the power of tree-sitter, we can do it much cleaner. (I originally confused the author of the built-in treesit and the author of lsp-bridge and thought they are the same person. That was embarassing.) Here is a link of he explaining the tree-sitter API in Emacs, well all in Chinese. Whatever, the code is something like this.

;; This is a the preferred version
(defun drsl/is-class-attr ()
  (let ((mynode (treesit-node-parent
                 (treesit-node-parent
                  (treesit-node-at (point))))))
    (and (string= (treesit-node-type mynode) "attribute")
         (string= (treesit-node-text (treesit-node-child mynode 0))
                  "class"))))

;; Here is a simplified version which does only one string comparison.
;; Use this if you really care about performance, but not ideal.
(defun drsl/is-class-attr ()
  (let ((mynode (treesit-node-parent
                 (treesit-node-parent
                  (treesit-node-at (point))))))
    (string= (treesit-node-text (treesit-node-child mynode 0))
                 "class")))

We don't want to modify the original cape-dict-file. Define a new custom variable and my own cape dict function.

(defcustom drsl/tailwind-css-keyword-file
  (expand-file-name "dict/tailwind_css_keyword.txt" user-emacs-directory)
  "tailwindcss keyword file path."
  :type 'string)

(defun drsl/tailwind-css-dict-list (input)
  "Return all words from `drsl/tailwind-css-keyword-file' matching INPUT."
  (unless (equal input "")
    (let* ((inhibit-message t)
           (message-log-max nil)
           (default-directory
            (if (and (not (file-remote-p default-directory))
                     (file-directory-p default-directory))
                default-directory
              user-emacs-directory))
           (files (mapcar #'expand-file-name
                          (ensure-list
                           drsl/tailwind-css-keyword-file)))
           (words
            (apply #'process-lines-ignore-status
                   "grep"
                   (concat "-Fh"
                           (and (cape--case-fold-p cape-dict-case-fold) "i")
                           (and cape-dict-limit (format "m%d" cape-dict-limit)))
                   input files)))
      (cons
       (apply-partially
        (if (and cape-dict-limit (length= words cape-dict-limit))
            #'equal #'string-search)
        input)
       (cape--case-replace-list cape-dict-case-replace input words)))))

(defun drsl/templ-tailwind-cape-dict ()
  (when (drsl/is-class-attr)
    (pcase-let ((`(,beg . ,end) (cape--bounds 'word)))
      `(,beg ,end
             ,(cape--properties-table
               (completion-table-case-fold
                (cape--dynamic-table beg end #'drsl/tailwind-css-dict-list)
                (not (cape--case-fold-p cape-dict-case-fold)))
               :sort nil ;; Presorted word list (by frequency)
               :category 'cape-dict)
             ,@cape--dict-properties))))

The completion is there, and it would only complete tailwindcss classes within the class attribute. The dream comes true. Sort of, it still has an issue, the completion is weird, because tailwindcss consists of a lot of hyphenated class names, e.g. text-red-500. The limitation of cape-dict thinks they are separate words, let's fix it. The function determinating the boundary of word is cape--bounds, which under the hood is just bounds-of-thing-at-point. A quick scratch would be like this.

;; Ugly, stupid, but sort of works.
(defun drsl/bounds-of-keyword ()
  (cons (car (bounds-of-thing-at-point 'symbol))
        (cdr (bounds-of-thing-at-point 'word))))

The reason this works is because a boundary of symbol stops at space, but a boundary of word will also stop at hyphen. But the lower boundary of symbol would be somewhere in the eternal infinity as long as we have characters immediately after the point.

After understanding the boundary calculation, I decided to make my own boundary function.

(defun drsl/bounds-of-keyword ()
  (if (or (char-equal (char-before) ?\s)
          (char-equal (char-before) ?\"))
      nil
    (let ((START (save-excursion
                   (re-search-backward "[\s\"\t\n]"
                                       (line-beginning-position)
                                       t)))
          (END (save-excursion
                 (re-search-forward "[\s\"\t\n]"
                                    (line-end-position)
                                    t))))
      (cons (if START
                (1+ START)
              (line-beginning-position))
            (if END
                (1- END)
              (point))))))

(defun drsl/templ-tailwind-cape-dict ()
  (when (drsl/is-class-attr)
    (pcase-let ((`(,beg . ,end) (drsl/bounds-of-keyword)))
      `(,beg ,end
             ,(cape--properties-table
               (completion-table-case-fold
                (cape--dynamic-table beg end #'drsl/tailwind-css-dict-list)
                (not (cape--case-fold-p cape-dict-case-fold)))
               :sort nil ;; Presorted word list (by frequency)
               :category 'cape-dict)
             ,@cape--dict-properties))))

That's all for the tailwindcss completion. Perfect.

HTML tags and attributes

After successfully making tailwindcss works, I have gained a massive amount of confidence. I decided to tackle the html keywords. Ridiculously, templ-ts-mode doesn't have auto-completion for html attribute and tag name.

Let's look at the existing example. The source code of web-mode.

OK. That was a bad idea. I have no idea how web-mode does auto-completion even after reading the whole thing over and over. One thing I can confirm is that, it does not add anything to the completion-at-point-functions.

Let's look at the built-in html-mode. Emm... It adds a hook to completion-at-point-functions, what the fuck?

;; from sgml-mode
(add-hook 'completion-at-point-functions 'html-mode--complete-at-point nil t)

I guess it doesn't matter? Copied.

Fuck, it breaks my tailwindcss completion.

This is how it is implemented.

(defun html-mode--complete-at-point ()
  ;; Complete a tag like <colg etc.
  (or
   (when-let ((tag (save-excursion
                     (and (looking-back "<\\([^ \t\n]*\\)"
                                        (line-beginning-position))
                          (match-string 1)))))
     (list (match-beginning 1) (point)
           (mapcar #'car html-tag-alist)))
   ;; Complete params like <colgroup ali etc.
   (when-let ((tag (save-excursion (sgml-beginning-of-tag)))
              (params (seq-filter #'consp (cdr (assoc tag html-tag-alist))))
              (param (save-excursion
                       (and (looking-back "[ \t\n]\\([^= \t\n]*\\)"
                                          (line-beginning-position))
                            (match-string 1)))))
     (list (match-beginning 1) (point)
           (mapcar #'car params)))
   ;; Complete param values like <colgroup align=mi etc.
   (when-let ((tag (save-excursion (sgml-beginning-of-tag)))
              (params (seq-filter #'consp (cdr (assoc tag html-tag-alist))))
              (param (save-excursion
                       (and (looking-back
                             "[ \t\n]\\([^= \t\n]+\\)=\\([^= \t\n]*\\)"
                             (line-beginning-position))
                            (match-string 1))))
              (values (cdr (assoc param params))))
     (list (match-beginning 2) (point)
           (mapcar #'car values)))))

What is this? I guess when it matches one of the three regexp, it overwrites the list of completion candidates returned from my original completion functions. This is very bad.

After a lot of attempts to fix the above code, I gave up and decided to write my own completion function with tree-sitter. I came up with an idea of checking if there is an html-ts-mode. html-ts-mode is only available in the Emacs 30 development branch. No problem, I can compile my own Emacs. Shockingly, the html-ts-mode is derived from html-mode, the tree-sitter part is only for syntax highlighting. Damn it.

I am going to do it all on my own. After a few researches, I realize that to be one of the completion-at-point-functions, we only need a list consists of three thing, the upper bound, the lower bound, and a list of words. It blows my mind how simple it is, and how overly complicated the web-mode and html-mode implementations are. (Both mode might do something that I don't understand, but from a purely auto-completion point of view, they are very unnecessary.) Here is my code.

(defvar drsl/templ-ts-mode-tag-list
  '("a" "abbr" "address" "area" "article" "aside" "audio" "b"
    "base" "bdi" "bdo" "blockquote" "body" "br" "button" "canvas"
    "caption" "cite" "code" "col" "colgroup" "data" "datalist"
    "dd" "del" "details" "dfn" "dialog" "div" "dl" "dt" "em"
    "embed" "fieldset" "figcaption" "figure" "footer" "form" "h1"
    "h2" "h3" "h4" "h5" "h6" "head" "header" "hgroup" "hr" "html"
    "i" "iframe" "img" "input" "ins" "kbd" "label" "legend" "li"
    "link" "main" "map" "mark" "math" "menu" "meta" "meter" "nav"
    "noscript" "object" "ol" "optgroup" "option" "output" "p"
    "picture" "pre" "progress" "q" "rp" "rt" "ruby" "s" "samp"
    "script" "search" "section" "select" "slot" "small" "source"
    "span" "strong" "style" "sub" "summary" "sup" "svg" "table"
    "tbody" "td" "template" "textarea" "tfoot" "th" "thead" "time"
    "title" "tr" "track" "u" "ul" "var" "video" "wbr")
  "HTML tags used for completion.

Steal from `web-mode'.")

(defvar drsl/templ-ts-mode-attribute-list
  '("accept" "accesskey" "action" "alt" "async" "autocomplete" "autofocus"
    "autoplay" "charset" "checked" "cite" "class" "cols" "colspan" "content"
    "contenteditable" "controls" "coords" "data" "datetime" "default" "defer"
    "dir" "dirname" "disabled" "download" "draggable" "enctype" "for" "form"
    "formaction" "headers" "height" "hidden" "high" "href" "hreflang" "http"
    "id" "ismap" "kind" "label" "lang" "list" "loop" "low" "max" "maxlength"
    "media" "method" "min" "multiple" "muted" "name" "novalidate" "onabort"
    "onafterprint" "onbeforeprint" "onbeforeunload" "onblur" "oncanplay"
    "oncanplaythrough" "onchange" "onclick" "oncontextmenu" "oncopy"
    "oncuechange" "oncut" "ondblclick" "ondrag" "ondragend" "ondragenter"
    "ondragleave" "ondragover" "ondragstart" "ondrop" "ondurationchange"
    "onemptied" "onended" "onerror" "onfocus" "onhashchange" "oninput"
    "oninvalid" "onkeydown" "onkeypress" "onkeyup" "onload" "onloadeddata"
    "onloadedmetadata" "onloadstart" "onmousedown" "onmousemove" "onmouseout"
    "onmouseover" "onmouseup" "onmousewheel" "onoffline" "ononline"
    "onpagehide" "onpageshow" "onpaste" "onpause" "onplay" "onplaying"
    "onpopstate" "onprogress" "onratechange" "onreset" "onresize" "onscroll"
    "onsearch" "onseeked" "onseeking" "onselect" "onstalled" "onstorage"
    "onsubmit" "onsuspend" "ontimeupdate" "ontoggle" "onunload"
    "onvolumechange" "onwaiting" "onwheel" "open" "optimum" "pattern"
    "placeholder" "poster" "preload" "readonly" "rel" "required" "reversed"
    "rows" "rowspan" "sandbox" "scope" "selected" "shape" "size" "sizes"
    "span" "spellcheck" "src" "srcdoc" "srclang" "srcset" "start" "step"
    "style" "tabindex" "target" "title" "translate" "type" "usemap" "value"
    "width" "wrap")
  "HTML attributes used for completion.

Steal from `web-mode'.")

(defun drsl/templ-ts-mode-completion ()
  "templ-ts-mode completion function.

The built-in treesit is required."
  (cond (;; completing tag name, e.g. <d
         (let ((bounds (or (bounds-of-thing-at-point 'word)
                           (cons (point) (point)))))
           (when (char-equal (char-before (car bounds)) ?\<)
             (list (car bounds)
                   (cdr bounds)
                   drsl/templ-ts-mode-tag-list
                   :annotation-function (lambda (_) " HTML Tag")
                   :company-kind (lambda (_) 'text)
                   :exclude 'no))))

        (;; completing attribute name, e.g. <div c
         (or (string= (treesit-node-type (treesit-node-at (point)))
                      "attribute_name")
             (string= (treesit-node-type (treesit-node-at (point)))
                      ">"))
         (let ((bounds (bounds-of-thing-at-point 'word)))
           (when bounds
             (list (car bounds)
                   (cdr bounds)
                   drsl/templ-ts-mode-attribute-list
                   :annotation-function (lambda (_) " HTML Attr")
                   :company-kind (lambda (_) 'text)
                   :exclusive 'no))))
        ))

There are some minor things. The exclusive should always be no, otherwise other completion functions will not run if your function returns candidates. The annotation-function determine the tag on the right side of your completion child frame. The company-kind is something I don't understand but copied from the implementation of some cape functions. It is necessary.

There are some possible known value candidates of some attributes, e.g. the method attribute may have the value "post", but I am too lazy to do it now.

htmx

If I can do it with HTML tags, I think I can do it with the htmx attributes.

It is fairly simple.

(defvar drsl/htmx-attribute-list
  '("hx-get" "hx-post" "hx-on" "hx-push-url" "hx-select" "hx-select-oob"
    "hx-swap" "hx-swap-oob" "hx-target" "hx-trigger" "hx-vals" "hx-boost"
    "hx-confirm" "hx-delete" "hx-disable" "hx-disabled-elt" "hx-disinherit"
    "hx-encoding" "hx-ext" "hx-headers" "hx-history" "hx-history-elt"
    "hx-include" "hx-indicator" "hx-params" "hx-patch" "hx-preserve"
    "hx-prompt" "hx-put" "hx-replace-url" "hx-request" "hx-sync" "hx-validate")

  "Htmx attributes used for completion.")

(defvar drsl/htmx-swap-keyword-list
  '("innerHTML" "outerHTML" "beforebegin" "afterbegin" "beforeend"
    "afterend" "delete" "none")
  "Keywords for hx-swap.")

(defvar drsl/htmx-target-keyword-list
  '("this" "closest" "find" "next" "previous")
  "Keywords for hx-target.")

(defun drsl/get-htmx-value-list (ATTR)
  "Return a list of htmx value.

ATTR is the attribute name.
Only support hx-swap, hx-swap-oob, hx-target."
  (cond ((string-prefix-p "hx-swap" ATTR)
         drsl/htmx-swap-keyword-list)
        ((string= ATTR "hx-target")
         drsl/htmx-target-keyword-list)))

(defun drsl/templ-ts-mode-htmx-completion ()
  "templ-ts-mode completion for htmx.

Built-in treesit is required."
  (cond (;; completion of htmx attr name, e.g. <div hx-swap
         (or (string= (treesit-node-type (treesit-node-at (point)))
                      "attribute_name")
             (string= (treesit-node-type (treesit-node-at (point)))
                      ">"))
         ;; This mess if for the case when a - is typed.
         ;;
         ;; In the case of hx-swap*, where * is the pointer.
         ;;
         ;; Since word only includes swap, but symbol includes from
         ;; hx-swap... to the infinity, so just select the first of
         ;; symbol and last of word. But when a - is type, the
         ;; bounds of word returns nil, so just set it to the
         ;; `point'.
         ;;
         ;; TODO: This is an issue in the syntax table of
         ;; `templ-ts-mode'.
         ;;
         (let ((bounds (drsl/bounds-of-keyword)))
           (when bounds
             (list (car bounds)
                   (cdr bounds)
                   drsl/htmx-attribute-list
                   :annotation-function (lambda (_) " htmx attr")
                   :company-kind (lambda (_) 'text)
                   :exclusive 'no)))
         )
        (;; completion of some htmx value, e.g. <div hx-swap="innerHTML"
         (string= (treesit-node-type (treesit-node-parent
                                      (treesit-node-at (point))))
                  "quoted_attribute_value")
         (let ((words (drsl/get-htmx-value-list
                       (treesit-node-text
                        (treesit-node-prev-sibling
                         (treesit-node-parent (treesit-node-at (point)))
                         t)
                        t)))
               (bounds (or (bounds-of-thing-at-point 'word)
                           (cons (point) (point)))))
           (when words
             (list (car bounds)
                   (cdr bounds)
                   words
                   :annotation-function (lambda (_) " htmx value")
                   :company-kind (lambda (_) 'text)
                   :exclusive 'no)
             ))
         )))

Auto closing tag

Another feature I really like about web-mode is the auto closing tag. And again, reading the source code web-mode is a pain in the ass. A lot of regexp are used to find the last opening tag.

But with tree-sitter, things are so much easier.

(defun drsl/templ-ts-mode-insert-slash ()
  "Auto closing tag when inserting slash in `templ-ts-mode'"
  (interactive)
  (if (char-equal (char-before) ?\<)
      (let ((TAG (or (treesit-node-text
                      (treesit-node-child
                       (drsl/treesit-prev-sibling-until
                        (treesit-node-at (point))
                        (lambda (NODE)
                          (string= (treesit-node-type NODE)
                                   "tag_start")))
                       1)
                      t)

                     (when (drsl/treesit-next-sibling-until
                            (treesit-node-parent (treesit-node-at (point)))
                            (lambda (NODE)
                              (string= (treesit-node-type NODE)
                                       "tag_end")))
                       (treesit-node-text
                        (treesit-node-child
                         (drsl/treesit-prev-sibling-until
                          (treesit-node-parent (treesit-node-at (point)))
                          (lambda (NODE)
                            (string= (treesit-node-type NODE)
                                     "tag_start")))
                         1)
                        t))
                     )))
        (if TAG
            (progn (insert ?\/
                           TAG
                           ?\>)
                   (treesit-indent))
          (insert ?\/)))
    (insert ?\/)))

(keymap-set templ-ts-mode-map "/" #'drsl/templ-ts-mode-insert-slash)

If you read carefully, you may find a mess in the second case, that is because of the tree-sitter parsing issue, when we are in between an already closed tag, some weird things would happen.

A kindly suggestion for anyone who intended to use tree-sitter to do editing in Emacs is to utilize the treesit-explore-mode, compare the source and the treesit parsing result side by side.

TLDR

Here is the complete code for copying.

(defun drsl/is-class-attr ()
  (let ((mynode (treesit-node-parent
                 (treesit-node-parent
                  (treesit-node-at (point))))))
    (and (string= (treesit-node-type mynode) "attribute")
         (string= (treesit-node-text (treesit-node-child mynode 0))
                  "class"))))

(defun drsl/bounds-of-keyword ()
  (if (or (char-equal (char-before) ?\s)
          (char-equal (char-before) ?\"))
      nil
    (let ((START (save-excursion
                   (re-search-backward "[\s\"\t\n]"
                                       (line-beginning-position)
                                       t)))
          (END (save-excursion
                 (re-search-forward "[\s\"\t\n]"
                                    (line-end-position)
                                    t))))
      (cons (if START
                (1+ START)
              (line-beginning-position))
            (if END
                (1- END)
              (point))))))

(defcustom drsl/tailwind-css-keyword-file
  (expand-file-name "dict/tailwind_css_keyword.txt" user-emacs-directory)
  "tailwindcss keyword file path."
  :type 'string)

(defun drsl/tailwind-css-dict-list (input)
  "Return all words from `drsl/tailwind-css-keyword-file' matching INPUT."
  (unless (equal input "")
    (let* ((inhibit-message t)
           (message-log-max nil)
           (default-directory
            (if (and (not (file-remote-p default-directory))
                     (file-directory-p default-directory))
                default-directory
              user-emacs-directory))
           (files (mapcar #'expand-file-name
                          (ensure-list
                           drsl/tailwind-css-keyword-file)))
           (words
            (apply #'process-lines-ignore-status
                   "grep"
                   (concat "-Fh"
                           (and (cape--case-fold-p cape-dict-case-fold) "i")
                           (and cape-dict-limit (format "m%d" cape-dict-limit)))
                   input files)))
      (cons
       (apply-partially
        (if (and cape-dict-limit (length= words cape-dict-limit))
            #'equal #'string-search)
        input)
       (cape--case-replace-list cape-dict-case-replace input words)))))

(defun drsl/templ-tailwind-cape-dict ()
  (when (drsl/is-class-attr)
    (pcase-let ((`(,beg . ,end) (drsl/bounds-of-keyword)))
      `(,beg ,end
             ,(cape--properties-table
               (completion-table-case-fold
                (cape--dynamic-table beg end #'drsl/tailwind-css-dict-list)
                (not (cape--case-fold-p cape-dict-case-fold)))
               :sort nil ;; Presorted word list (by frequency)
               :category 'cape-dict)
             ,@cape--dict-properties))))

(defvar drsl/templ-ts-mode-tag-list
  '("a" "abbr" "address" "area" "article" "aside" "audio" "b"
    "base" "bdi" "bdo" "blockquote" "body" "br" "button" "canvas"
    "caption" "cite" "code" "col" "colgroup" "data" "datalist"
    "dd" "del" "details" "dfn" "dialog" "div" "dl" "dt" "em"
    "embed" "fieldset" "figcaption" "figure" "footer" "form" "h1"
    "h2" "h3" "h4" "h5" "h6" "head" "header" "hgroup" "hr" "html"
    "i" "iframe" "img" "input" "ins" "kbd" "label" "legend" "li"
    "link" "main" "map" "mark" "math" "menu" "meta" "meter" "nav"
    "noscript" "object" "ol" "optgroup" "option" "output" "p"
    "picture" "pre" "progress" "q" "rp" "rt" "ruby" "s" "samp"
    "script" "search" "section" "select" "slot" "small" "source"
    "span" "strong" "style" "sub" "summary" "sup" "svg" "table"
    "tbody" "td" "template" "textarea" "tfoot" "th" "thead" "time"
    "title" "tr" "track" "u" "ul" "var" "video" "wbr")
  "HTML tags used for completion.

Steal from `web-mode'.")

(defvar drsl/templ-ts-mode-attribute-list
  '("accept" "accesskey" "action" "alt" "async" "autocomplete" "autofocus"
    "autoplay" "charset" "checked" "cite" "class" "cols" "colspan" "content"
    "contenteditable" "controls" "coords" "data" "datetime" "default" "defer"
    "dir" "dirname" "disabled" "download" "draggable" "enctype" "for" "form"
    "formaction" "headers" "height" "hidden" "high" "href" "hreflang" "http"
    "id" "ismap" "kind" "label" "lang" "list" "loop" "low" "max" "maxlength"
    "media" "method" "min" "multiple" "muted" "name" "novalidate" "onabort"
    "onafterprint" "onbeforeprint" "onbeforeunload" "onblur" "oncanplay"
    "oncanplaythrough" "onchange" "onclick" "oncontextmenu" "oncopy"
    "oncuechange" "oncut" "ondblclick" "ondrag" "ondragend" "ondragenter"
    "ondragleave" "ondragover" "ondragstart" "ondrop" "ondurationchange"
    "onemptied" "onended" "onerror" "onfocus" "onhashchange" "oninput"
    "oninvalid" "onkeydown" "onkeypress" "onkeyup" "onload" "onloadeddata"
    "onloadedmetadata" "onloadstart" "onmousedown" "onmousemove" "onmouseout"
    "onmouseover" "onmouseup" "onmousewheel" "onoffline" "ononline"
    "onpagehide" "onpageshow" "onpaste" "onpause" "onplay" "onplaying"
    "onpopstate" "onprogress" "onratechange" "onreset" "onresize" "onscroll"
    "onsearch" "onseeked" "onseeking" "onselect" "onstalled" "onstorage"
    "onsubmit" "onsuspend" "ontimeupdate" "ontoggle" "onunload"
    "onvolumechange" "onwaiting" "onwheel" "open" "optimum" "pattern"
    "placeholder" "poster" "preload" "readonly" "rel" "required" "reversed"
    "rows" "rowspan" "sandbox" "scope" "selected" "shape" "size" "sizes"
    "span" "spellcheck" "src" "srcdoc" "srclang" "srcset" "start" "step"
    "style" "tabindex" "target" "title" "translate" "type" "usemap" "value"
    "width" "wrap")
  "HTML attributes used for completion.

Steal from `web-mode'.")

(defun drsl/templ-ts-mode-completion ()
  "templ-ts-mode completion function.

The built-in treesit is required."
  (cond (;; completing tag name, e.g. <d
         (let ((bounds (or (bounds-of-thing-at-point 'word)
                           (cons (point) (point)))))
           (when (char-equal (char-before (car bounds)) ?\<)
             (list (car bounds)
                   (cdr bounds)
                   drsl/templ-ts-mode-tag-list
                   :annotation-function (lambda (_) " HTML Tag")
                   :company-kind (lambda (_) 'text)
                   :exclude 'no))))

        (;; completing attribute name, e.g. <div c
         (or (string= (treesit-node-type (treesit-node-at (point)))
                      "attribute_name")
             (string= (treesit-node-type (treesit-node-at (point)))
                      ">"))
         (let ((bounds (bounds-of-thing-at-point 'word)))
           (when bounds
             (list (car bounds)
                   (cdr bounds)
                   drsl/templ-ts-mode-attribute-list
                   :annotation-function (lambda (_) " HTML Attr")
                   :company-kind (lambda (_) 'text)
                   :exclusive 'no))))
        ))

(defvar drsl/htmx-attribute-list
  '("hx-get" "hx-post" "hx-on" "hx-push-url" "hx-select" "hx-select-oob"
    "hx-swap" "hx-swap-oob" "hx-target" "hx-trigger" "hx-vals" "hx-boost"
    "hx-confirm" "hx-delete" "hx-disable" "hx-disabled-elt" "hx-disinherit"
    "hx-encoding" "hx-ext" "hx-headers" "hx-history" "hx-history-elt"
    "hx-include" "hx-indicator" "hx-params" "hx-patch" "hx-preserve"
    "hx-prompt" "hx-put" "hx-replace-url" "hx-request" "hx-sync" "hx-validate")

  "Htmx attributes used for completion.")

(defvar drsl/htmx-swap-keyword-list
  '("innerHTML" "outerHTML" "beforebegin" "afterbegin" "beforeend"
    "afterend" "delete" "none")
  "Keywords for hx-swap.")

(defvar drsl/htmx-target-keyword-list
  '("this" "closest" "find" "next" "previous")
  "Keywords for hx-target.")

(defun drsl/get-htmx-value-list (ATTR)
  "Return a list of htmx value.

ATTR is the attribute name.
Only support hx-swap, hx-swap-oob, hx-target."
  (cond ((string-prefix-p "hx-swap" ATTR)
         drsl/htmx-swap-keyword-list)
        ((string= ATTR "hx-target")
         drsl/htmx-target-keyword-list)))

(defun drsl/templ-ts-mode-htmx-completion ()
  "templ-ts-mode completion for htmx.

Built-in treesit is required."
  (cond (;; completion of htmx attr name, e.g. <div hx-swap
         (or (string= (treesit-node-type (treesit-node-at (point)))
                      "attribute_name")
             (string= (treesit-node-type (treesit-node-at (point)))
                      ">"))
         ;; This mess if for the case when a - is typed.
         ;;
         ;; In the case of hx-swap*, where * is the pointer.
         ;;
         ;; Since word only includes swap, but symbol includes from
         ;; hx-swap... to the infinity, so just select the first of
         ;; symbol and last of word. But when a - is type, the
         ;; bounds of word returns nil, so just set it to the
         ;; `point'.
         ;;
         ;; TODO: This is an issue in the syntax table of
         ;; `templ-ts-mode'.
         ;;
         (let ((bounds (drsl/bounds-of-keyword)))
           (when bounds
             (list (car bounds)
                   (cdr bounds)
                   drsl/htmx-attribute-list
                   :annotation-function (lambda (_) " htmx attr")
                   :company-kind (lambda (_) 'text)
                   :exclusive 'no)))
         )
        (;; completion of some htmx value, e.g. <div hx-swap="innerHTML"
         (string= (treesit-node-type (treesit-node-parent
                                      (treesit-node-at (point))))
                  "quoted_attribute_value")
         (let ((words (drsl/get-htmx-value-list
                       (treesit-node-text
                        (treesit-node-prev-sibling
                         (treesit-node-parent (treesit-node-at (point)))
                         t)
                        t)))
               (bounds (or (bounds-of-thing-at-point 'word)
                           (cons (point) (point)))))
           (when words
             (list (car bounds)
                   (cdr bounds)
                   words
                   :annotation-function (lambda (_) " htmx value")
                   :company-kind (lambda (_) 'text)
                   :exclusive 'no)
             ))
         )))

(defun drsl/templ-ts-mode-insert-slash ()
  "Auto closing tag when inserting slash in `templ-ts-mode'"
  (interactive)
  (if (char-equal (char-before) ?\<)
      (let ((TAG (or (treesit-node-text
                      (treesit-node-child
                       (drsl/treesit-prev-sibling-until
                        (treesit-node-at (point))
                        (lambda (NODE)
                          (string= (treesit-node-type NODE)
                                   "tag_start")))
                       1)
                      t)

                     (when (drsl/treesit-next-sibling-until
                            (treesit-node-parent (treesit-node-at (point)))
                            (lambda (NODE)
                              (string= (treesit-node-type NODE)
                                       "tag_end")))
                       (treesit-node-text
                        (treesit-node-child
                         (drsl/treesit-prev-sibling-until
                          (treesit-node-parent (treesit-node-at (point)))
                          (lambda (NODE)
                            (string= (treesit-node-type NODE)
                                     "tag_start")))
                         1)
                        t))
                     )))
        (if TAG
            (progn (insert ?\/
                           TAG
                           ?\>)
                   (treesit-indent))
          (insert ?\/)))
    (insert ?\/)))

(keymap-set templ-ts-mode-map "/" #'drsl/templ-ts-mode-insert-slash)

(add-hook 'templ-ts-mode-hook
          (lambda ()
            (progn
              (add-to-list 'drsl/eglot-extra-completion-functions
                           #'drsl/templ-tailwind-cape-dict)
              (add-to-list 'drsl/eglot-extra-completion-functions
                           #'drsl/templ-ts-mode-completion)
              (add-to-list 'drsl/eglot-extra-completion-functions
                           #'drsl/templ-ts-mode-htmx-completion))))

(defcustom drsl/eglot-extra-completion-functions '(cape-file)
  "extra completion functions for eglot"
  :type '(repeat string))

(defun drsl/eglot-capf ()
  (mapc
   (lambda (FUNCTION)
     (add-to-list 'completion-at-point-functions
                  FUNCTION))
   drsl/eglot-extra-completion-functions))

(add-hook 'eglot-managed-mode-hook #'drsl/eglot-capf)