瀏覽代碼

Chapter 4: extending HTML as Hypermedia.

Frédéric G. MARAND 6 月之前
當前提交
6ce526de32

+ 8 - 0
.idea/.gitignore

@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml

+ 5 - 0
.idea/codeStyles/codeStyleConfig.xml

@@ -0,0 +1,5 @@
+<component name="ProjectCodeStyleConfiguration">
+  <state>
+    <option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
+  </state>
+</component>

+ 10 - 0
.idea/demo-htmx.iml

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<module type="WEB_MODULE" version="4">
+  <component name="Go" enabled="true" />
+  <component name="NewModuleRootManager">
+    <content url="file://$MODULE_DIR$" />
+    <orderEntry type="inheritedJdk" />
+    <orderEntry type="sourceFolder" forTests="false" />
+    <orderEntry type="library" name="htmx.org" level="application" />
+  </component>
+</module>

+ 6 - 0
.idea/jsLibraryMappings.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="JavaScriptLibraryMappings">
+    <file url="PROJECT" libraries="{htmx.org}" />
+  </component>
+</project>

+ 8 - 0
.idea/modules.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="ProjectModuleManager">
+    <modules>
+      <module fileurl="file://$PROJECT_DIR$/.idea/demo-htmx.iml" filepath="$PROJECT_DIR$/.idea/demo-htmx.iml" />
+    </modules>
+  </component>
+</project>

+ 6 - 0
.idea/vcs.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="VcsDirectoryMappings">
+    <mapping directory="" vcs="Git" />
+  </component>
+</project>

+ 44 - 0
doc/htmx-ch04.md

@@ -0,0 +1,44 @@
+# HTMX
+## Attributes
+
+- `hx-[delete|get|patch|post|put]` - AJAX method to use
+- `hx-swap`
+  - `innerHTML` - Default, replace hx-target inner html.
+  - `outerHTML` - Replace the hx-target itself
+  - `beforebegin` - Insert before hx-target
+  - `afterbegin` - Insert before hx-target first child
+  - `beforeend` - Insert after hx-target last child
+  - `afterend` - Insert response after hx-target
+  - `delete` - Deletes hx-target regardless of the response.
+  - `none` - No swap.
+- `hx-target`: CSS selector for `hx-swap` action
+- `hx-trigger`: comma-separated list of events triggering a request, often the default:
+  - On `input`, `textarea`, `select`: `change`
+  - On `form`: `submit`
+  - On all other elements: `click`
+  - Filters: 
+    - in `[]` after the event name 
+    - Example: `keyup[ctrlKey && key == 'l']` to catch CTRL-L
+  - Specify event source with `from:<extended CSS selector>`, 
+    - defaults to current element
+    - Example: `from:body` to receive events from the whole body
+  - Complete spec: https://htmx.org/attributes/hx-trigger/
+- `hx-include`: 
+  - when `hx-post`-ing outside a form, specify which elements to include, as if they had been in a form along with the posting button
+  - supports matching on multiple comma-separated selectors 
+- `hx-vals`: value as a JSON string or JS dynamic value
+  - example: `hx-vals='{"state":"MT"}'`
+  - example: `hx-vals='js:{"state":getCurrentState()}'`
+
+
+## Extended CSS selectors
+
+- support multiple comma-separated selectors, but...
+  - `hx-target`: return first matching element from first selector (?)
+  - `hx-trigger from:` and `hx-include`: return first matching child
+- relative selectors:
+  - `closest:` closest parent matching selector
+  - `(previous|next):`: previous or next element (not necessarily sibling) matching selector
+  - `find:` next child matching selector
+  - `this:` the current element
+  - 

+ 3 - 0
go.mod

@@ -0,0 +1,3 @@
+module code.osinet.fr/fgm/demo-htmx
+
+go 1.22.2

+ 20 - 0
main.go

@@ -0,0 +1,20 @@
+package main
+
+import (
+	"log"
+	"os"
+
+	"code.osinet.fr/fgm/demo-htmx/web"
+)
+
+func realMain() int {
+	if err := web.UI(":8080"); err != nil {
+		log.Println(err)
+		return 1
+	}
+	return 0
+}
+
+func main() {
+	os.Exit(realMain())
+}

+ 19 - 0
web/public/00.html

@@ -0,0 +1,19 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <title id="page-title">HTMX Demos</title>
+    <script src="htmx-1-9-12.min.js"></script>
+    <link rel="stylesheet" href="styles.css" />
+</head>
+<body>
+<h1 id="title">Demo</h1>
+<h2>Default: swap current element</h2>
+
+<nav hx-get="/autonum/nav" hx-trigger="load"></nav>
+
+<button hx-post="/contacts" hx-swap="innerHTML">
+    Contacts
+</button>
+</body>
+</html>

+ 22 - 0
web/public/01.html

@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <title id="page-title">HTMX Demos</title>
+    <script src="htmx-1-9-12.min.js"></script>
+    <link rel="stylesheet" href="styles.css" />
+</head>
+<body>
+<h1 id="title">Demo</h1>
+<h2>hx-target : target another element</h2>
+
+<nav hx-get="/autonum/nav" hx-trigger="load"></nav>
+
+<div id="main">
+    <div class="list"></div>
+    <button hx-post="/contacts" hx-target="#main .list" hx-swap="innerHTML">
+        Contacts
+    </button>
+</div>
+</body>
+</html>

+ 22 - 0
web/public/02.html

@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <title id="page-title">HTMX Demos</title>
+    <script src="htmx-1-9-12.min.js"></script>
+    <link rel="stylesheet" href="styles.css" />
+</head>
+<body>
+<h1 id="title">Demo</h1>
+<h2>hx-trigger: trigger on mouseenter</h2>
+
+<nav hx-get="/autonum/nav" hx-trigger="load"></nav>
+
+<div id="main">
+    <div class="list"></div>
+    <button hx-post="/contacts" hx-target="#main .list" hx-swap="innerHTML" hx-trigger="mouseenter">
+        Contacts
+    </button>
+</div>
+</body>
+</html>

+ 26 - 0
web/public/03.html

@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <title id="page-title">HTMX Demos</title>
+    <script src="htmx-1-9-12.min.js"></script>
+    <link rel="stylesheet" href="styles.css"/>
+</head>
+<body>
+<h1 id="title">Demo</h1>
+<h2>hx-trigger: <code>^L</code> keyboard shortcut</h2>
+
+<nav hx-get="/autonum/nav" hx-trigger="load"></nav>
+
+<div id="main">
+    <div class="list"></div>
+    <button
+            hx-post="/contacts"
+            hx-target="#main .list"
+            hx-swap="innerHTML"
+            hx-trigger="click,keyup[ctrlKey && key == 'l'] from:body">
+        Contacts
+    </button>
+</div>
+</body>
+</html>

+ 26 - 0
web/public/04.html

@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <title id="page-title">HTMX Demos</title>
+    <script src="htmx-1-9-12.min.js"></script>
+    <link rel="stylesheet" href="styles.css"/>
+</head>
+<body>
+<h1 id="title">Demo</h1>
+<h2>Forms</h2>
+
+<nav hx-get="/autonum/nav" hx-trigger="load"></nav>
+
+<div id="main">
+    <form>
+        <label for="search">Search Contacts:</label>
+        <input id="search" name="q" type="search" placeholder="Search Contacts">
+        <button hx-post="/search" hx-target="#main .results">
+            Search The Contacts
+        </button>
+    </form>
+    <div class="results"></div>
+</div>
+</body>
+</html>

+ 26 - 0
web/public/05.html

@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <title id="page-title">HTMX Demos</title>
+    <script src="htmx-1-9-12.min.js"></script>
+    <link rel="stylesheet" href="styles.css"/>
+</head>
+<body>
+<h1 id="title">Demo</h1>
+<h2>Including inputs without forms</h2>
+
+<nav hx-get="/autonum/nav" hx-trigger="load"></nav>
+
+<div id="main">
+    <label for="search">Search Contacts:</label>
+    <input id="search" name="q" type="search" placeholder="Search Contacts">
+    <button hx-get="/search"
+            hx-target="#main .results"
+            hx-include="#search">
+        Search The Contacts
+    </button>
+    <div class="results"></div>
+</div>
+</body>
+</html>

+ 27 - 0
web/public/06.html

@@ -0,0 +1,27 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <title id="page-title">HTMX Demos</title>
+    <script src="htmx-1-9-12.min.js"></script>
+    <link rel="stylesheet" href="styles.css"/>
+</head>
+<body>
+<h1 id="title">Demo</h1>
+<h2>Including static values with <code>hx-vals</code></h2>
+
+<nav hx-get="/autonum/nav" hx-trigger="load"></nav>
+
+<div id="main">
+    <label for="search">Search Contacts:</label>
+    <input id="search" name="q" type="search" placeholder="Search Contacts">
+    <button hx-get="/search"
+            hx-target="#main .results"
+            hx-include="#search"
+            hx-vals='{"state":"Montana"}'>
+        Search The Contacts
+    </button>
+    <div class="results"></div>
+</div>
+</body>
+</html>

+ 32 - 0
web/public/07.html

@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <title id="page-title">HTMX Demos</title>
+  <script src="htmx-1-9-12.min.js"></script>
+  <link rel="stylesheet" href="styles.css"/>
+  <script>
+    function getCurrentState() {
+      return "California";
+    }
+  </script>
+</head>
+<body>
+<h1 id="title">Demo</h1>
+<h2>Including JavaScript values with <code>hx-vals</code></h2>
+
+<nav hx-get="/autonum/nav" hx-trigger="load"></nav>
+
+<div id="main">
+  <label for="search">Search Contacts:</label>
+  <input id="search" name="q" type="search" placeholder="Search Contacts">
+  <button hx-get="/search"
+          hx-target="#main .results"
+          hx-include="#search"
+          hx-vals='js:{"state":getCurrentState()}'>
+    Search The Contacts
+  </button>
+  <div class="results"></div>
+</div>
+</body>
+</html>

+ 34 - 0
web/public/08.html

@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <title id="page-title">HTMX Demos</title>
+  <script src="htmx-1-9-12.min.js"></script>
+  <link rel="stylesheet" href="styles.css"/>
+</head>
+<body>
+<h1 id="title">Demo</h1>
+<h2>Using history with <code>hx-push-url</code></h2>
+
+<nav hx-get="/autonum/nav" hx-trigger="load"></nav>
+
+<div id="main">
+  <label for="search">Search Contacts:</label>
+  <input id="search" name="q" type="search" placeholder="Search Contacts">
+  <button hx-get="/search"
+          hx-target="#main .results"
+          hx-include="#search"
+          hx-push-url="true"
+  >
+    Search The Contacts
+  </button>
+  <div class="results"></div>
+  <p>Usage:</p>
+  <ul>
+    <li>search on some criteria a couple of times,</li>
+    <li>then use the browser &larr;/&rarr; buttons button. Yay ! 😊</li>
+    <li>Now refresh the page. Boo 😕 : we get only the partial response, because the pushed URL is the one for the <code>hx-post</code></li>
+  </ul>
+</div>
+</body>
+</html>

+ 10 - 0
web/public/contacts.gohtml

@@ -0,0 +1,10 @@
+{{ define "contacts" }}
+  {{ if .state }}<p>Results in {{ .state }}</p>{{ end }}
+  <ul>{{ range $name, $mail := .contacts }}
+      <li><a href="mailto:{{$mail}}">{{ $name }}</a></li>
+      {{- else -}}
+        <li><em>No results found</em></li>
+      {{ end }}
+  </ul>
+  {{ if .counter }}<p>Counter: {{ .counter }}</p>{{ end }}
+{{ end }}

+ 141 - 0
web/public/head-support.js

@@ -0,0 +1,141 @@
+//==========================================================
+// head-support.js
+//
+// An extension to htmx 1.0 to add head tag merging.
+//==========================================================
+(function(){
+
+    var api = null;
+
+    function log() {
+        //console.log(arguments);
+    }
+
+    function mergeHead(newContent, defaultMergeStrategy) {
+
+        if (newContent && newContent.indexOf('<head') > -1) {
+            const htmlDoc = document.createElement("html");
+            // remove svgs to avoid conflicts
+            var contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
+            // extract head tag
+            var headTag = contentWithSvgsRemoved.match(/(<head(\s[^>]*>|>)([\s\S]*?)<\/head>)/im);
+
+            // if the  head tag exists...
+            if (headTag) {
+
+                var added = []
+                var removed = []
+                var preserved = []
+                var nodesToAppend = []
+
+                htmlDoc.innerHTML = headTag;
+                var newHeadTag = htmlDoc.querySelector("head");
+                var currentHead = document.head;
+
+                if (newHeadTag == null) {
+                    return;
+                } else {
+                    // put all new head elements into a Map, by their outerHTML
+                    var srcToNewHeadNodes = new Map();
+                    for (const newHeadChild of newHeadTag.children) {
+                        srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
+                    }
+                }
+
+
+
+                // determine merge strategy
+                var mergeStrategy = api.getAttributeValue(newHeadTag, "hx-head") || defaultMergeStrategy;
+
+                // get the current head
+                for (const currentHeadElt of currentHead.children) {
+
+                    // If the current head element is in the map
+                    var inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
+                    var isReAppended = currentHeadElt.getAttribute("hx-head") === "re-eval";
+                    var isPreserved = api.getAttributeValue(currentHeadElt, "hx-preserve") === "true";
+                    if (inNewContent || isPreserved) {
+                        if (isReAppended) {
+                            // remove the current version and let the new version replace it and re-execute
+                            removed.push(currentHeadElt);
+                        } else {
+                            // this element already exists and should not be re-appended, so remove it from
+                            // the new content map, preserving it in the DOM
+                            srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
+                            preserved.push(currentHeadElt);
+                        }
+                    } else {
+                        if (mergeStrategy === "append") {
+                            // we are appending and this existing element is not new content
+                            // so if and only if it is marked for re-append do we do anything
+                            if (isReAppended) {
+                                removed.push(currentHeadElt);
+                                nodesToAppend.push(currentHeadElt);
+                            }
+                        } else {
+                            // if this is a merge, we remove this content since it is not in the new head
+                            if (api.triggerEvent(document.body, "htmx:removingHeadElement", {headElement: currentHeadElt}) !== false) {
+                                removed.push(currentHeadElt);
+                            }
+                        }
+                    }
+                }
+
+                // Push the tremaining new head elements in the Map into the
+                // nodes to append to the head tag
+                nodesToAppend.push(...srcToNewHeadNodes.values());
+                log("to append: ", nodesToAppend);
+
+                for (const newNode of nodesToAppend) {
+                    log("adding: ", newNode);
+                    var newElt = document.createRange().createContextualFragment(newNode.outerHTML);
+                    log(newElt);
+                    if (api.triggerEvent(document.body, "htmx:addingHeadElement", {headElement: newElt}) !== false) {
+                        currentHead.appendChild(newElt);
+                        added.push(newElt);
+                    }
+                }
+
+                // remove all removed elements, after we have appended the new elements to avoid
+                // additional network requests for things like style sheets
+                for (const removedElement of removed) {
+                    if (api.triggerEvent(document.body, "htmx:removingHeadElement", {headElement: removedElement}) !== false) {
+                        currentHead.removeChild(removedElement);
+                    }
+                }
+
+                api.triggerEvent(document.body, "htmx:afterHeadMerge", {added: added, kept: preserved, removed: removed});
+            }
+        }
+    }
+
+    htmx.defineExtension("head-support", {
+        init: function(apiRef) {
+            // store a reference to the internal API.
+            api = apiRef;
+
+            htmx.on('htmx:afterSwap', function(evt){
+                var serverResponse = evt.detail.xhr.response;
+                if (api.triggerEvent(document.body, "htmx:beforeHeadMerge", evt.detail)) {
+                    mergeHead(serverResponse, evt.detail.boosted ? "merge" : "append");
+                }
+            })
+
+            htmx.on('htmx:historyRestore', function(evt){
+                if (api.triggerEvent(document.body, "htmx:beforeHeadMerge", evt.detail)) {
+                    if (evt.detail.cacheMiss) {
+                        mergeHead(evt.detail.serverResponse, "merge");
+                    } else {
+                        mergeHead(evt.detail.item.head, "merge");
+                    }
+                }
+            })
+
+            htmx.on('htmx:historyItemCreated', function(evt){
+                var historyItem = evt.detail.item;
+                historyItem.head = document.head.outerHTML;
+            })
+        }
+    });
+
+})()

File diff suppressed because it is too large
+ 0 - 0
web/public/htmx-1-9-12.min.js


+ 11 - 0
web/public/nav.gohtml

@@ -0,0 +1,11 @@
+{{ define "nav" }}
+    <ul>
+        {{ if gt .num 0 }}
+            <li><a href="/{{.prev}}.html">Previous</a></li>
+        {{ end }}
+        <li><a href="/{{.next}}.html">Next</a></li>
+    </ul>
+    {{/* Piggy-back title updates on the NAV swap to avoid extra requests */}}
+    <h1 id="title" hx-swap-oob="true">Demo {{ .curr }}</h1>
+    <title id="page title" hx-swap-oob="true">Demo {{ .curr }}</title>
+{{ end }}

+ 11 - 0
web/public/styles.css

@@ -0,0 +1,11 @@
+nav ul, nav li {
+    padding: 0;
+}
+
+nav li {
+    display: inline;
+}
+
+button > ul {
+    text-align: left;
+}

+ 102 - 0
web/web.go

@@ -0,0 +1,102 @@
+package web
+
+import (
+	"errors"
+	"fmt"
+	"html/template"
+	"net/http"
+	"net/url"
+	"path/filepath"
+	"strconv"
+	"strings"
+)
+
+var (
+	counter int = 1
+)
+
+func getContacts() map[string]any {
+	return map[string]any{
+		"Joe":   "joe@example.com",
+		"Sarah": "sarah@example.com",
+		"Fred":  "fred@example.com",
+	}
+}
+
+func makeContacts1(templates *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		templates.ExecuteTemplate(w, "contacts", map[string]any{
+			"contacts": getContacts(),
+			"counter":  counter,
+		})
+		counter++
+	}
+}
+
+func makeNav(templates *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		path := strings.SplitAfterN(r.URL.Path, "/", 3)[2]
+		htmlPath := r.Header.Get("Hx-Current-Url")
+		u, err := url.Parse(htmlPath)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+		htmlPath = strings.SplitAfterN(u.Path, "/", 2)[1]
+		htmlPath = htmlPath[0 : len(htmlPath)-len(filepath.Ext(htmlPath))]
+		num, err := strconv.Atoi(htmlPath)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+		switch path {
+		case "nav":
+			templates.ExecuteTemplate(w, "nav", map[string]any{
+				"num":  num,
+				"prev": fmt.Sprintf("%02d", num-1),
+				"curr": fmt.Sprintf("%2d", num),
+				"next": fmt.Sprintf("%02d", num+1),
+			})
+		default:
+			http.Error(w, path, http.StatusNotFound)
+			return
+		}
+	}
+}
+
+func makeSearch4(templates *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		q := r.FormValue("q")
+		state := r.FormValue("state")
+		raw := getContacts()
+		filtered := map[string]any{}
+		for k, v := range raw {
+			if strings.Contains(strings.ToUpper(k), strings.ToUpper(q)) {
+				filtered[k] = v
+			}
+		}
+		templates.ExecuteTemplate(w, "contacts", map[string]any{
+			"contacts": filtered,
+			"state":    state,
+		})
+	}
+}
+
+func SetupRoutes(mux *http.ServeMux, templates *template.Template) {
+	mux.Handle("/", http.FileServer(http.Dir("./web/public/")))
+	mux.HandleFunc("/autonum/", makeNav(templates))
+	mux.HandleFunc("/contacts", makeContacts1(templates))
+	mux.HandleFunc("/search", makeSearch4(templates))
+}
+
+func UI(addr string) error {
+	templates := template.Must(template.ParseGlob("./web/public/*.gohtml"))
+	mux := http.NewServeMux()
+	SetupRoutes(mux, templates)
+	if err := http.ListenAndServe(addr, mux); err != nil {
+		if !errors.Is(err, http.ErrServerClosed) {
+			return fmt.Errorf("http server error %w", err)
+		}
+	}
+	return nil
+}

Some files were not shown because too many files changed in this diff