From 466334149740335f437524c792ab6b6b99cf912d Mon Sep 17 00:00:00 2001
From: paku <paku@skyizwhite.dev>
Date: Sun, 21 Apr 2024 19:09:58 +0900
Subject: [PATCH] Improved page navigation and import of on-demand assets

---
 .../vendor/alpine-ext/async-alpine@1.2.2.js   |   1 +
 public/vendor/alpine-ext/morph@3.13.8.js      |   1 +
 public/vendor/htmx-ext/alpine-morph@1.9.12.js |  16 ++
 public/vendor/htmx-ext/head-support@1.9.12.js | 141 ++++++++++++++++++
 src/routes/about.lisp                         |  10 +-
 src/routes/index.lisp                         |  11 +-
 src/scripts/pages/about.js                    |   9 ++
 src/scripts/pages/index.js                    |   9 ++
 src/styles/pages/about.css                    |   2 +-
 src/styles/pages/index.css                    |   2 +-
 src/view/asset.lisp                           |  31 ++++
 src/view/components/document.lisp             |  67 +++------
 src/view/components/layout.lisp               |   1 +
 src/view/renderer.lisp                        |   2 +-
 14 files changed, 248 insertions(+), 55 deletions(-)
 create mode 100644 public/vendor/alpine-ext/async-alpine@1.2.2.js
 create mode 100644 public/vendor/alpine-ext/morph@3.13.8.js
 create mode 100644 public/vendor/htmx-ext/alpine-morph@1.9.12.js
 create mode 100644 public/vendor/htmx-ext/head-support@1.9.12.js
 create mode 100644 src/scripts/pages/about.js
 create mode 100644 src/scripts/pages/index.js
 create mode 100644 src/view/asset.lisp

diff --git a/public/vendor/alpine-ext/async-alpine@1.2.2.js b/public/vendor/alpine-ext/async-alpine@1.2.2.js
new file mode 100644
index 0000000..43a2b91
--- /dev/null
+++ b/public/vendor/alpine-ext/async-alpine@1.2.2.js
@@ -0,0 +1 @@
+(()=>{var d=Object.defineProperty;var w=e=>d(e,"__esModule",{value:!0});var A=(e,t)=>{w(e);for(var i in t)d(e,i,{get:t[i],enumerable:!0})};var o={};A(o,{eager:()=>h,event:()=>f,idle:()=>c,media:()=>_,visible:()=>m});var v=()=>!0,h=v;var b=({component:e,argument:t})=>new Promise(i=>{if(t)window.addEventListener(t,()=>i(),{once:!0});else{let s=n=>{n.detail.id===e.id&&(window.removeEventListener("async-alpine:load",s),i())};window.addEventListener("async-alpine:load",s)}}),f=b;var $=()=>new Promise(e=>{"requestIdleCallback"in window?window.requestIdleCallback(e):setTimeout(e,200)}),c=$;var E=({argument:e})=>new Promise(t=>{if(!e)return console.log("Async Alpine: media strategy requires a media query. Treating as 'eager'"),t();let i=window.matchMedia(`(${e})`);i.matches?t():i.addEventListener("change",t,{once:!0})}),_=E;var q=({component:e,argument:t})=>new Promise(i=>{let s=t||"0px 0px 0px 0px",n=new IntersectionObserver(a=>{a[0].isIntersecting&&(n.disconnect(),i())},{rootMargin:s});n.observe(e.el)}),m=q;function p(e){let t=P(e),i=x(t);return i.type==="method"?{type:"expression",operator:"&&",parameters:[i]}:i}function P(e){let t=/\s*([()])\s*|\s*(\|\||&&|\|)\s*|\s*((?:[^()&|]+\([^()]+\))|[^()&|]+)\s*/g,i=[],s;for(;(s=t.exec(e))!==null;){let[,n,a,r]=s;if(n!==void 0)i.push({type:"parenthesis",value:n});else if(a!==void 0)i.push({type:"operator",value:a==="|"?"&&":a});else{let u={type:"method",method:r.trim()};r.includes("(")&&(u.method=r.substring(0,r.indexOf("(")).trim(),u.argument=r.substring(r.indexOf("(")+1,r.indexOf(")"))),r.method==="immediate"&&(r.method="eager"),i.push(u)}}return i}function x(e){let t=g(e);for(;e.length>0&&(e[0].value==="&&"||e[0].value==="|"||e[0].value==="||");){let i=e.shift().value,s=g(e);t.type==="expression"&&t.operator===i?t.parameters.push(s):t={type:"expression",operator:i,parameters:[t,s]}}return t}function g(e){if(e[0].value==="("){e.shift();let t=x(e);return e[0].value===")"&&e.shift(),t}else return e.shift()}var y="__internal_",l={Alpine:null,_options:{prefix:"ax-",alpinePrefix:"x-",root:"load",inline:"load-src",defaultStrategy:"eager"},_alias:!1,_data:{},_realIndex:0,get _index(){return this._realIndex++},init(e,t={}){return this.Alpine=e,this._options={...this._options,...t},this},start(){return this._processInline(),this._setupComponents(),this._mutations(),this},data(e,t=!1){return this._data[e]={loaded:!1,download:t},this},url(e,t){!e||!t||(this._data[e]||this.data(e),this._data[e].download=()=>import(this._parseUrl(t)))},alias(e){this._alias=e},_processInline(){let e=document.querySelectorAll(`[${this._options.prefix}${this._options.inline}]`);for(let t of e)this._inlineElement(t)},_inlineElement(e){let t=e.getAttribute(`${this._options.alpinePrefix}data`),i=e.getAttribute(`${this._options.prefix}${this._options.inline}`);if(!t||!i)return;let s=this._parseName(t);this.url(s,i)},_setupComponents(){let e=document.querySelectorAll(`[${this._options.prefix}${this._options.root}]`);for(let t of e)this._setupComponent(t)},_setupComponent(e){let t=e.getAttribute(`${this._options.alpinePrefix}data`);e.setAttribute(`${this._options.alpinePrefix}ignore`,"");let i=this._parseName(t),s=e.getAttribute(`${this._options.prefix}${this._options.root}`)||this._options.defaultStrategy;this._componentStrategy({name:i,strategy:s,el:e,id:e.id||this._index})},async _componentStrategy(e){let t=p(e.strategy);await this._generateRequirements(e,t),await this._download(e.name),this._activate(e)},_generateRequirements(e,t){if(t.type==="expression"){if(t.operator==="&&")return Promise.all(t.parameters.map(i=>this._generateRequirements(e,i)));if(t.operator==="||")return Promise.any(t.parameters.map(i=>this._generateRequirements(e,i)))}return o[t.method]?o[t.method]({component:e,argument:t.argument}):!1},async _download(e){if(e.startsWith(y)||(this._handleAlias(e),!this._data[e]||this._data[e].loaded))return;let t=await this._getModule(e);this.Alpine.data(e,t),this._data[e].loaded=!0},async _getModule(e){if(!this._data[e])return;let t=await this._data[e].download(e);return typeof t=="function"?t:t[e]||t.default||Object.values(t)[0]||!1},_activate(e){this.Alpine.destroyTree(e.el);let t=`${this._options.alpinePrefix}ignore`;e.el.removeAttribute(t),e.el._x_ignore=!1,!this._anyParent(e.el,i=>i.hasAttribute(t))&&this.Alpine.initTree(e.el)},_mutations(){new MutationObserver(t=>{for(let i of t)if(!!i.addedNodes)for(let s of i.addedNodes){if(s.nodeType!==1)continue;s.hasAttribute(`${this._options.prefix}${this._options.root}`)&&this._mutationEl(s),s.querySelectorAll(`[${this._options.prefix}${this._options.root}]`).forEach(a=>this._mutationEl(a))}}).observe(document,{attributes:!0,childList:!0,subtree:!0})},_mutationEl(e){e.hasAttribute(`${this._options.prefix}${this._options.inline}`)&&this._inlineElement(e),this._setupComponent(e)},_handleAlias(e){if(!(!this._alias||this._data[e])){if(typeof this._alias=="function"){this.data(e,this._alias);return}this.url(e,this._alias.replaceAll("[name]",e))}},_parseName(e){return(e||"").split(/[({]/g)[0]||`${y}${this._index}`},_parseUrl(e){return new RegExp("^(?:[a-z+]+:)?//","i").test(e)?e:new URL(e,document.baseURI).href},_anyParent(e,t){return!e||e.nodeName==="HTML"?!1:t(e)?e:this._anyParent(e.parentElement,t)}};document.addEventListener("alpine:init",()=>{window.AsyncAlpine=l,l.init(Alpine,window.AsyncAlpineOptions||{}),document.dispatchEvent(new CustomEvent("async-alpine:init")),l.start()});})();
diff --git a/public/vendor/alpine-ext/morph@3.13.8.js b/public/vendor/alpine-ext/morph@3.13.8.js
new file mode 100644
index 0000000..d8502c1
--- /dev/null
+++ b/public/vendor/alpine-ext/morph@3.13.8.js
@@ -0,0 +1 @@
+(()=>{function k(u,l,o){Y();let g,h,y,B,O,E,v,T,_,A;function V(e={}){let n=a=>a.getAttribute("key"),d=()=>{};O=e.updating||d,E=e.updated||d,v=e.removing||d,T=e.removed||d,_=e.adding||d,A=e.added||d,y=e.key||n,B=e.lookahead||!1}function D(e,n){if(W(e,n))return q(e,n);let d=!1;if(!b(O,e,n,()=>d=!0)){if(e.nodeType===1&&window.Alpine&&window.Alpine.cloneNode(e,n),X(n)){$(e,n),E(e,n);return}d||G(e,n),E(e,n),L(e,n)}}function W(e,n){return e.nodeType!=n.nodeType||e.nodeName!=n.nodeName||x(e)!=x(n)}function q(e,n){if(b(v,e))return;let d=n.cloneNode(!0);b(_,d)||(e.replaceWith(d),T(e),A(d))}function $(e,n){let d=n.nodeValue;e.nodeValue!==d&&(e.nodeValue=d)}function G(e,n){if(e._x_transitioning||e._x_isShown&&!n._x_isShown||!e._x_isShown&&n._x_isShown)return;let d=Array.from(e.attributes),a=Array.from(n.attributes);for(let i=d.length-1;i>=0;i--){let t=d[i].name;n.hasAttribute(t)||e.removeAttribute(t)}for(let i=a.length-1;i>=0;i--){let t=a[i].name,m=a[i].value;e.getAttribute(t)!==m&&e.setAttribute(t,m)}}function L(e,n){e._x_teleport&&(e=e._x_teleport),n._x_teleport&&(n=n._x_teleport);let d=H(e.children),a={},i=I(n),t=I(e);for(;i;){Z(i,t);let s=x(i),p=x(t);if(!t)if(s&&a[s]){let r=a[s];e.appendChild(r),t=r}else{if(!b(_,i)){let r=i.cloneNode(!0);e.appendChild(r),A(r)}i=c(n,i);continue}let C=r=>r&&r.nodeType===8&&r.textContent==="[if BLOCK]><![endif]",S=r=>r&&r.nodeType===8&&r.textContent==="[if ENDBLOCK]><![endif]";if(C(i)&&C(t)){let r=0,N=t;for(;t;){let f=c(e,t);if(C(f))r++;else if(S(f)&&r>0)r--;else if(S(f)&&r===0){t=f;break}t=f}let R=t;r=0;let j=i;for(;i;){let f=c(n,i);if(C(f))r++;else if(S(f)&&r>0)r--;else if(S(f)&&r===0){i=f;break}i=f}let z=i,J=new w(N,R),Q=new w(j,z);L(J,Q);continue}if(t.nodeType===1&&B&&!t.isEqualNode(i)){let r=c(n,i),N=!1;for(;!N&&r;)r.nodeType===1&&t.isEqualNode(r)&&(N=!0,t=K(e,i,t),p=x(t)),r=c(n,r)}if(s!==p){if(!s&&p){a[p]=t,t=K(e,i,t),a[p].remove(),t=c(e,t),i=c(n,i);continue}if(s&&!p&&d[s]&&(t.replaceWith(d[s]),t=d[s]),s&&p){let r=d[s];if(r)a[p]=t,t.replaceWith(r),t=r;else{a[p]=t,t=K(e,i,t),a[p].remove(),t=c(e,t),i=c(n,i);continue}}}let P=t&&c(e,t);D(t,i),i=i&&c(n,i),t=P}let m=[];for(;t;)b(v,t)||m.push(t),t=c(e,t);for(;m.length;){let s=m.shift();s.remove(),T(s)}}function x(e){return e&&e.nodeType===1&&y(e)}function H(e){let n={};for(let d of e){let a=x(d);a&&(n[a]=d)}return n}function K(e,n,d){if(!b(_,n)){let a=n.cloneNode(!0);return e.insertBefore(a,d),A(a),a}return n}return V(o),g=u,h=typeof l=="string"?U(l):l,window.Alpine&&window.Alpine.closestDataStack&&!u._x_dataStack&&(h._x_dataStack=window.Alpine.closestDataStack(u),h._x_dataStack&&window.Alpine.cloneNode(u,h)),D(u,h),g=void 0,h=void 0,u}k.step=()=>{};k.log=()=>{};function b(u,...l){let o=!1;return u(...l,()=>o=!0),o}var F=!1;function U(u){let l=document.createElement("template");return l.innerHTML=u,l.content.firstElementChild}function X(u){return u.nodeType===3||u.nodeType===8}var w=class{constructor(l,o){this.startComment=l,this.endComment=o}get children(){let l=[],o=this.startComment.nextSibling;for(;o&&o!==this.endComment;)l.push(o),o=o.nextSibling;return l}appendChild(l){this.endComment.before(l)}get firstChild(){let l=this.startComment.nextSibling;if(l!==this.endComment)return l}nextNode(l){let o=l.nextSibling;if(o!==this.endComment)return o}insertBefore(l,o){return o.before(l),l}};function I(u){return u.firstChild}function c(u,l){let o;return u instanceof w?o=u.nextNode(l):o=l.nextSibling,o}function Y(){if(F)return;F=!0;let u=Element.prototype.setAttribute,l=document.createElement("div");Element.prototype.setAttribute=function(g,h){if(!g.includes("@"))return u.call(this,g,h);l.innerHTML=`<span ${g}="${h}"></span>`;let y=l.firstElementChild.getAttributeNode(g);l.firstElementChild.removeAttributeNode(y),this.setAttributeNode(y)}}function Z(u,l){let o=l&&l._x_bindings&&l._x_bindings.id;o&&(u.setAttribute("id",o),u.id=o)}function M(u){u.morph=k}document.addEventListener("alpine:init",()=>{window.Alpine.plugin(M)});})();
diff --git a/public/vendor/htmx-ext/alpine-morph@1.9.12.js b/public/vendor/htmx-ext/alpine-morph@1.9.12.js
new file mode 100644
index 0000000..1872dae
--- /dev/null
+++ b/public/vendor/htmx-ext/alpine-morph@1.9.12.js
@@ -0,0 +1,16 @@
+htmx.defineExtension('alpine-morph', {
+    isInlineSwap: function (swapStyle) {
+        return swapStyle === 'morph';
+    },
+    handleSwap: function (swapStyle, target, fragment) {
+        if (swapStyle === 'morph') {
+            if (fragment.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
+                Alpine.morph(target, fragment.firstElementChild);
+                return [target];
+            } else {
+                Alpine.morph(target, fragment.outerHTML);
+                return [target];
+            }
+        }
+    }
+});
\ No newline at end of file
diff --git a/public/vendor/htmx-ext/head-support@1.9.12.js b/public/vendor/htmx-ext/head-support@1.9.12.js
new file mode 100644
index 0000000..67cfc69
--- /dev/null
+++ b/public/vendor/htmx-ext/head-support@1.9.12.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;
+            })
+        }
+    });
+
+})()
\ No newline at end of file
diff --git a/src/routes/about.lisp b/src/routes/about.lisp
index 6edd7c9..0fbea6f 100644
--- a/src/routes/about.lisp
+++ b/src/routes/about.lisp
@@ -11,10 +11,14 @@
 
 (pi:define-element page ()
   (pi:h
-    (section :data-css "pages/about"
+    (section (view:asset-props :style "pages/about"
+                               :script "pages/about"
+                               :x-data "aboutPageState")
       (h1 "About")
-      (a :href "/"
-        "top"))))
+      (a :href "/" :hx-boost "true"
+        "top")
+      (button :@click "decrement()"
+        (span :x-text "count")))))
 
 (defun handle-get (params)
   (declare (ignore params))
diff --git a/src/routes/index.lisp b/src/routes/index.lisp
index 5311ce1..7592f8c 100644
--- a/src/routes/index.lisp
+++ b/src/routes/index.lisp
@@ -7,13 +7,18 @@
 
 (pi:define-element page ()
   (pi:h
-    (section :data-css "pages/index"
+    (section (view:asset-props :style "pages/index"
+                               :script "pages/index"
+                               :x-data "indexPageState")
       (h1
         "Hello, World!")
-      (a :href "/about"
+      (a :href "/about" :hx-boost "true"
         "About")
       (button :x-data t :@click "$store.darkMode.toggle()"
-        "Toggle theme"))))
+        "Toggle theme")
+      (button
+        :@click "increment()"
+        (span :x-text "count")))))
 
 (defun handle-get (params)
   (declare (ignore params))
diff --git a/src/scripts/pages/about.js b/src/scripts/pages/about.js
new file mode 100644
index 0000000..7426ded
--- /dev/null
+++ b/src/scripts/pages/about.js
@@ -0,0 +1,9 @@
+export function aboutPageState() {
+  return {
+    count: 0,
+    
+    decrement() {
+      this.count--
+    }
+  }
+}
diff --git a/src/scripts/pages/index.js b/src/scripts/pages/index.js
new file mode 100644
index 0000000..7ebac58
--- /dev/null
+++ b/src/scripts/pages/index.js
@@ -0,0 +1,9 @@
+export function indexPageState() {
+  return {
+    count: 0,
+
+    increment() {
+      this.count++
+    }
+  }
+}
diff --git a/src/styles/pages/about.css b/src/styles/pages/about.css
index 1d792a6..24c6147 100644
--- a/src/styles/pages/about.css
+++ b/src/styles/pages/about.css
@@ -1,4 +1,4 @@
-@scope ([data-css='pages/about']) {
+@scope ([data-style='pages/about']) {
   :scope {
     height: 100svh;
     display: grid;
diff --git a/src/styles/pages/index.css b/src/styles/pages/index.css
index 463170b..e743a0f 100644
--- a/src/styles/pages/index.css
+++ b/src/styles/pages/index.css
@@ -1,4 +1,4 @@
-@scope ([data-css='pages/index']) {
+@scope ([data-style='pages/index']) {
   :scope {
     height: 100svh;
     display: grid;
diff --git a/src/view/asset.lisp b/src/view/asset.lisp
new file mode 100644
index 0000000..810531e
--- /dev/null
+++ b/src/view/asset.lisp
@@ -0,0 +1,31 @@
+(defpackage #:hp/view/asset
+  (:use #:cl)
+  (:local-nicknames (#:re #:cl-ppcre))
+  (:export #:get-css-links
+           #:asset-props))
+(in-package #:hp/view/asset)
+
+(defun detect-data-props (html-str data-prop-name)
+  (remove-duplicates (re:all-matches-as-strings (format nil
+                                                        "(?<=~a=\")[^\"]*(?=\")"
+                                                        data-prop-name)
+                                                html-str)
+                     :test #'string=))
+
+(defun data-props->asset-links (parent-path extension data-props)
+  (mapcar (lambda (data-prop)
+            (concatenate 'string parent-path data-prop extension))
+          data-props))
+
+(defun get-css-links (html-str)
+  (data-props->asset-links "/styles/"
+                           ".css"
+                           (detect-data-props html-str "data-style")))
+
+(defun asset-props (&key style script x-data)
+  (append (and style `(:data-style ,style))
+          (and script x-data
+               `(:ax-load t
+                 :ax-load-src ,(format nil "/scripts/~a.js" script)
+                 :x-ignore t
+                 :x-data ,x-data))))
diff --git a/src/view/components/document.lisp b/src/view/components/document.lisp
index 0707ef0..e88e6fd 100644
--- a/src/view/components/document.lisp
+++ b/src/view/components/document.lisp
@@ -1,55 +1,19 @@
 (defpackage #:hp/view/components/document
   (:use #:cl)
-  (:local-nicknames (#:re #:cl-ppcre))
   (:local-nicknames (#:pi #:piccolo))
-  (:export #:document))
+  (:local-nicknames (#:asset #:hp/view/asset))
+  (:export #:document
+           #:partial-document))
 (in-package #:hp/view/components/document)
 
-(defun detect-data-props (html-str data-prop-name)
-  (remove-duplicates (re:all-matches-as-strings (format nil
-                                                        "(?<=~a=\")[^\"]*(?=\")"
-                                                        data-prop-name)
-                                                html-str)
-                     :test #'string=))
-
-(defun data-props->asset-links (parent-path extension data-props)
-  (mapcar (lambda (data-prop)
-            (concatenate 'string parent-path data-prop extension))
-          data-props))
-
-(defun get-css-links (html-str)
-  (data-props->asset-links "/styles/"
-                           ".css"
-                           (detect-data-props html-str "data-css")))
-
-(defun get-js-links (html-str)
-  (data-props->asset-links "/scripts/"
-                           ".js"
-                           (detect-data-props html-str "data-js")))
-
-(pi:define-element scripts (srcs)
-  (pi:h
-    (<>
-      (mapcar (lambda (src)
-                (script :src src :defer t))
-              srcs))))
-
-(pi:define-element stylesheets (hrefs)
-  (pi:h
-    (<>
-      (mapcar (lambda (href)
-                (link :rel "stylesheet" :type "text/css" :href href))
-              hrefs))))
-
-(pi:define-element on-demand-assets (component)
-  (let* ((pi:*escape-html* nil)
-         (html-str (pi:elem-str component))
-         (css-links (get-css-links html-str))
-         (js-links (get-js-links html-str)))
+(pi:define-element on-demand-stylesheets ()
+  (let* ((html-str (pi:elem-str pi:children))
+         (css-links (asset:get-css-links html-str)))
     (pi:h
       (<>
-        (stylesheets :hrefs css-links)
-        (scripts :srcs js-links)))))
+        (mapcar (lambda (href)
+                  (link :rel "stylesheet" :type "text/css" :href href))
+                css-links)))))
 
 (pi:define-element document (title description)
   (pi:h
@@ -58,18 +22,29 @@
         (meta :charset "UTF-8")
         (link :rel "stylesheet" :type "text/css" :href "/vendor/ress.css")
         (link :rel "stylesheet" :type "text/css" :href "/styles/global.css")
+        (on-demand-stylesheets pi:children)
         (link :rel "preconnect" :href "https://fonts.googleapis.com")
         (link :rel "preconnect" :href "https://fonts.gstatic.com" :crossorigin t)
         (link 
           :rel "stylesheet"
           :href "https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700&display=swap")
         (script :src "/vendor/htmx@1.9.12.js")
+        (script :src "/vendor/htmx-ext/alpine-morph@1.9.12.js")
+        (script :src "/vendor/htmx-ext/head-support@1.9.12.js")
+        (script :src "/vendor/alpine-ext/async-alpine@1.2.2.js" :defer t)
         (script :src "/vendor/alpine-ext/persist@3.13.8.js" :defer t)
+        (script :src "/vendor/alpine-ext/morph@3.13.8.js" :defer t)
         (script :src "/scripts/global.js" :defer t)
-        (on-demand-assets :component pi:children)
         (script :src "/vendor/alpine@3.13.8.js" :defer t)
         (title (format nil "~@[~a - ~]skyizwhite.dev" title))
         (meta
           :name "description"
           :content (or description "pakuの個人サイト")))
       pi:children)))
+
+(pi:define-element partial-document ()
+  (pi:h
+    (<>
+      (head :hx-head "append"
+        (on-demand-stylesheets pi:children))
+      pi:children)))
diff --git a/src/view/components/layout.lisp b/src/view/components/layout.lisp
index 3a0e3e3..86f1047 100644
--- a/src/view/components/layout.lisp
+++ b/src/view/components/layout.lisp
@@ -7,6 +7,7 @@
 (pi:define-element layout ()
   (pi:h
     (body
+      :hx-ext "head-support,alpine-morph"
       :x-data t
       :|:data-dark| "$store.darkMode.on"
       ; header
diff --git a/src/view/renderer.lisp b/src/view/renderer.lisp
index 033023d..3c6fc52 100644
--- a/src/view/renderer.lisp
+++ b/src/view/renderer.lisp
@@ -17,4 +17,4 @@
 (defun partial-render (component &key status)
   (jg:with-html-response
     (if status (jg:set-response-status status))
-    (pi:elem-str component)))
+    (pi:elem-str (cmp:partial-document component))))