fix: html-router modals and add insta snapshot tests.

Avoid nested forms in the scratchpad editor, centralize modal lifecycle in modal.js, return HTMX partials from archive, and add template compile plus layout snapshots.
This commit is contained in:
Per Stark
2026-06-03 20:20:43 +02:00
parent 2a28243213
commit 7b850769c9
29 changed files with 1426 additions and 217 deletions
+4
View File
@@ -25,3 +25,7 @@ devenv.local.nix
# html-router/assets/style.css
html-router/node_modules
.fastembed_cache/
# insta: pending (unreviewed) snapshots; accepted *.snap files are committed
*.snap.new
.insta.bak
Generated
+32 -1
View File
@@ -1468,6 +1468,17 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "console"
version = "0.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87"
dependencies = [
"encode_unicode",
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "const-random"
version = "0.1.18"
@@ -2967,6 +2978,7 @@ dependencies = [
"common",
"futures",
"include_dir",
"insta",
"json-stream-parser",
"minijinja",
"minijinja-autoreload",
@@ -3342,7 +3354,7 @@ version = "0.17.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235"
dependencies = [
"console",
"console 0.15.11",
"number_prefix",
"portable-atomic",
"unicode-width 0.2.2",
@@ -3412,6 +3424,19 @@ dependencies = [
"generic-array 0.14.7",
]
[[package]]
name = "insta"
version = "1.47.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e"
dependencies = [
"console 0.16.3",
"once_cell",
"regex",
"similar",
"tempfile",
]
[[package]]
name = "instant"
version = "0.1.13"
@@ -6164,6 +6189,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
name = "simple_asn1"
version = "0.6.4"
+1
View File
@@ -43,6 +43,7 @@ json-stream-parser = { path = "../json-stream-parser" }
[dev-dependencies]
common = { path = "../common", features = ["test-utils"] }
insta = { version = "1.47.2", features = ["filters"] }
tower = "0.5"
[build-dependencies]
+19
View File
@@ -0,0 +1,19 @@
/**
* Shared "Reset to Default" handler for the admin prompt-edit modals
* (templates/admin/edit_*_prompt_modal.html).
*
* Each reset button carries data-reset-target with a selector for the prompt
* textarea to repopulate from the modal's hidden #default_prompt_content.
*/
(function () {
'use strict';
document.body.addEventListener('click', function (e) {
const btn = e.target.closest('[data-reset-target]');
if (!btn) return;
const scope = btn.closest('dialog') || document;
const source = scope.querySelector('#default_prompt_content');
const target = scope.querySelector(btn.dataset.resetTarget);
if (source && target) target.value = source.value;
});
})();
+33
View File
@@ -0,0 +1,33 @@
/**
* Modal lifecycle for markup injected into #modal (see templates/modal_base.html).
*
* Uses delegated listeners so we do not rely on inline <script> re-execution on
* each hx-swap="innerHTML". Successful submit close is per-form via hx-on in the template.
*/
(function () {
'use strict';
function getDialog() {
return document.getElementById('body_modal');
}
// Auto-open the dialog whenever new modal markup is swapped into #modal.
document.body.addEventListener('htmx:afterSwap', function (e) {
if (!e.detail.target || e.detail.target.id !== 'modal') return;
const dialog = getDialog();
if (dialog && typeof dialog.showModal === 'function' && !dialog.open) {
dialog.showModal();
}
});
// Submit success → close: hx-on::after-request on #modal_form (modal_base.html)
// and on scratchpad ingest-form; not handled here.
// Clear modal content on close so browser back/forward can't reopen it.
// The dialog 'close' event does not bubble, so listen in the capture phase.
document.body.addEventListener('close', function (e) {
if (e.target && e.target.id === 'body_modal') {
e.target.innerHTML = '';
}
}, true);
})();
@@ -385,8 +385,10 @@ pub async fn archive_scratchpad(
.map(ScratchpadArchiveItem::from)
.collect();
Ok(TemplateResponse::new_template(
// HTMX archive targets #main_section; return only the list partial, not full layout.
Ok(TemplateResponse::new_partial(
"scratchpad/base.html",
"main",
ScratchpadPageData {
scratchpads: scratchpad_list,
archived_scratchpads: archived_list,
@@ -19,17 +19,12 @@ hx-swap="outerHTML"
{% endblock %}
{% block primary_actions %}
<button type="button" class="nb-btn w-full sm:w-auto sm:mr-2" id="reset_prompt_button">
<button type="button" class="nb-btn w-full sm:w-auto sm:mr-2"
data-reset-target="textarea[name=image_processing_prompt]">
Reset to Default
</button>
<textarea id="default_prompt_content" style="display:none;">{{ default_image_prompt }}</textarea>
<script>
document.getElementById('reset_prompt_button').addEventListener('click', function () {
const defaultContent = document.getElementById('default_prompt_content').value;
document.querySelector('textarea[name=image_processing_prompt]').value = defaultContent;
});
</script>
<button type="submit" class="nb-btn nb-cta w-full sm:w-auto">
<span class="htmx-indicator hidden">
@@ -19,17 +19,12 @@ hx-swap="outerHTML"
{% endblock %}
{% block primary_actions %}
<button type="button" class="nb-btn w-full sm:w-auto sm:mr-2" id="reset_prompt_button">
<button type="button" class="nb-btn w-full sm:w-auto sm:mr-2"
data-reset-target="textarea[name=ingestion_system_prompt]">
Reset to Default
</button>
<textarea id="default_prompt_content" style="display:none;">{{ default_ingestion_prompt }}</textarea>
<script>
document.getElementById('reset_prompt_button').addEventListener('click', function () {
const defaultContent = document.getElementById('default_prompt_content').value;
document.querySelector('textarea[name=ingestion_system_prompt]').value = defaultContent;
});
</script>
<button type="submit" class="nb-btn nb-cta w-full sm:w-auto">
<span class="htmx-indicator hidden">
@@ -19,17 +19,12 @@ hx-swap="outerHTML"
{% endblock %}
{% block primary_actions %}
<button type="button" class="nb-btn w-full sm:w-auto sm:mr-2" id="reset_prompt_button">
<button type="button" class="nb-btn w-full sm:w-auto sm:mr-2"
data-reset-target="textarea[name=query_system_prompt]">
Reset to Default
</button>
<textarea id="default_prompt_content" style="display:none;">{{ default_query_prompt }}</textarea>
<script>
document.getElementById('reset_prompt_button').addEventListener('click', function () {
const defaultContent = document.getElementById('default_prompt_content').value;
document.querySelector('textarea[name=query_system_prompt]').value = defaultContent;
});
</script>
<button type="submit" class="nb-btn nb-cta w-full sm:w-auto">
<span class="htmx-indicator hidden">
+31
View File
@@ -23,5 +23,36 @@
</div> <!-- End Drawer -->
<div id="modal"></div>
<div id="toast-container" class="fixed bottom-4 right-4 z-50 space-y-2"></div>
<script defer>
document.addEventListener('DOMContentLoaded', function () {
if (window.marked && !window.markedGlobalOptionsSet) {
marked.setOptions({
breaks: true,
gfm: true,
headerIds: false,
mangle: false,
smartLists: true,
smartypants: true,
xhtml: false
});
window.markedGlobalOptionsSet = true;
}
renderAllMarkdown();
});
document.body.addEventListener('htmx:afterSettle', renderAllMarkdown);
function renderAllMarkdown() {
if (!window.marked) return;
document.querySelectorAll('.markdown-content[data-content]').forEach(el => {
const raw = el.getAttribute('data-content') || '';
if (el.dataset.renderedContent !== raw) {
el.innerHTML = marked.parse(raw);
el.dataset.renderedContent = raw;
}
});
}
window.renderAllMarkdown = renderAllMarkdown;
</script>
</body>
{% endblock %}
@@ -1,9 +1,11 @@
{% include "chat/streaming_response.html" %}
<!-- OOB swap targeting the form element directly -->
<form id="chat-form" hx-post="/chat/{{conversation.id}}" hx-target="#chat_container" hx-swap="beforeend"
class="relative flex gap-2" hx-swap-oob="true">
class="relative flex gap-2" hx-swap-oob="true"
hx-on::after-request="if(event.detail.successful) document.getElementById('chat-input').value=''">
<textarea autofocus required name="content" placeholder="Type your message..." rows="2"
class="nb-input h-24 pr-8 pl-2 pt-2 pb-2 flex-grow resize-none" id="chat-input"></textarea>
class="nb-input h-24 pr-8 pl-2 pt-2 pb-2 flex-grow resize-none" id="chat-input"
hx-on:keydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();htmx.trigger('#chat-form','submit')}"></textarea>
<button type="submit" class="absolute p-2 cursor-pointer right-0.5 btn-ghost btn-sm top-1">
{% include "icons/send_icon.html" %}
</button>
@@ -13,24 +15,8 @@
</form>
<script>
(function () {
const newChatStreamId = 'ai-stream-{{ user_message.id }}';
document.getElementById('chat-input').addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
htmx.trigger('#chat-form', 'submit');
}
});
// Clear textarea after successful submission
document.getElementById('chat-form').addEventListener('htmx:afterRequest', function (e) {
if (e.detail.successful) { // Check if the request was successful
document.getElementById('chat-input').value = ''; // Clear the textarea
}
});
const refreshSidebarAfterFirstResponse = function (e) {
const streamEl = document.getElementById(newChatStreamId);
if (!streamEl || e.target !== streamEl) return;
if (!e.target.closest('[data-message-id]')) return;
htmx.ajax('GET', '/chat/sidebar', {
target: '.drawer-side',
@@ -2,26 +2,13 @@
<div class="mx-auto max-w-3xl px-4 pb-3">
<div class="nb-panel p-2 no-animation">
<form hx-post="{% if conversation %} /chat/{{conversation.id}} {% else %} /chat {% endif %}"
hx-target="#chat_container" hx-swap="beforeend" class="relative flex gap-2 items-end" id="chat-form">
hx-target="#chat_container" hx-swap="beforeend" class="relative flex gap-2 items-end" id="chat-form"
hx-on::after-request="if(event.detail.successful) document.getElementById('chat-input').value=''">
<textarea autofocus required name="content" placeholder="Type your message…" rows="3"
class="nb-input flex-grow min-h-24 pr-10 pl-2 pt-2 pb-2 resize-none" id="chat-input"></textarea>
class="nb-input flex-grow min-h-24 pr-10 pl-2 pt-2 pb-2 resize-none" id="chat-input"
hx-on:keydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();htmx.trigger('#chat-form','submit')}"></textarea>
<button type="submit" class="nb-btn nb-cta h-10 px-3">{% include "icons/send_icon.html" %}</button>
</form>
</div>
</div>
</div>
<script>
document.getElementById('chat-input').addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
htmx.trigger('#chat-form', 'submit');
}
});
// Clear textarea after successful submission
document.getElementById('chat-form').addEventListener('htmx:afterRequest', function (e) {
if (e.detail.successful) { // Check if the request was successful
document.getElementById('chat-input').value = ''; // Clear the textarea
}
});
</script>
+18 -12
View File
@@ -1,7 +1,6 @@
<div class="relative my-2">
<button id="references-toggle-{{message.id}}"
class="nb-btn btn-xs bg-base-100 hover:bg-base-200 flex items-center"
onclick="toggleReferences('{{message.id}}')">
<button id="references-toggle-{{message.id}}" data-message-id="{{message.id}}"
class="references-toggle nb-btn btn-xs bg-base-100 hover:bg-base-200 flex items-center">
REFERENCES
{% include "icons/chevron_icon.html" %}
</button>
@@ -20,6 +19,19 @@
</div>
<script>
// Register global handlers once; this fragment is re-injected per SSE response.
if (!window.__referencesInit) {
window.__referencesInit = true;
document.body.addEventListener('click', function (e) {
const btn = e.target.closest('.references-toggle');
if (btn) toggleReferences(btn.dataset.messageId);
});
document.addEventListener('DOMContentLoaded', initializeReferenceTooltips);
document.body.addEventListener('htmx:afterSwap', initializeReferenceTooltips);
}
function toggleReferences(messageId) {
const refsContent = document.getElementById('references-content-' + messageId);
const refsList = document.getElementById('references-list-' + messageId);
@@ -56,15 +68,6 @@
}
}
// Initialize portal tooltips
document.addEventListener('DOMContentLoaded', function () {
initializeReferenceTooltips();
});
document.body.addEventListener('htmx:afterSwap', function () {
initializeReferenceTooltips();
});
function initializeReferenceTooltips() {
document.querySelectorAll('.reference-badge-container').forEach(container => {
if (container.dataset.initialized === 'true') return;
@@ -145,4 +148,7 @@
container.dataset.initialized = 'true';
});
}
// Initialize any badges present in this freshly injected fragment (idempotent).
initializeReferenceTooltips();
</script>
@@ -4,11 +4,11 @@
</div>
</div>
<div class="chat chat-start">
<div id="ai-stream-{{user_message.id}}" hx-ext="sse"
<div id="ai-stream-{{user_message.id}}" data-message-id="{{user_message.id}}" hx-ext="sse"
sse-connect="/chat/response-stream?message_id={{user_message.id}}" sse-close="close_stream"
hx-swap="beforeend">
<div class="chat-bubble">
<span class="loading loading-dots loading-sm loading-id-{{user_message.id}}"></span>
<span class="loading loading-dots loading-sm" data-stream-spinner></span>
<div class="markdown-content" id="ai-message-content-{{user_message.id}}" sse-swap="chat_message"></div>
</div>
<div sse-swap="references"></div>
@@ -16,34 +16,39 @@
</div>
<script>
window.markdownBuffer = window.markdownBuffer || {};
document.body.addEventListener('htmx:sseBeforeMessage', function (e) {
const msgId = '{{ user_message.id }}';
const spinner = document.querySelector('.loading-id-' + msgId);
if (spinner) spinner.style.display = 'none';
const el = document.getElementById('ai-message-content-' + msgId);
if (e.detail.elt !== el) return;
e.preventDefault();
window.markdownBuffer[msgId] = (window.markdownBuffer[msgId] || '') + (e.detail.data || '');
el.innerHTML = marked.parse(window.markdownBuffer[msgId].replace(/\\n/g, '\n'));
if (typeof window.scrollChatToBottom === "function") window.scrollChatToBottom();
});
document.body.addEventListener('htmx:sseClose', function (e) {
const msgId = '{{ user_message.id }}';
const streamEl = document.getElementById('ai-stream-' + msgId);
if (streamEl && e.target !== streamEl) return;
// Single delegated listener set; message identity comes from data-message-id.
(function () {
if (window.__streamHandlersInit) return;
window.__streamHandlersInit = true;
window.markdownBuffer = window.markdownBuffer || {};
const el = document.getElementById('ai-message-content-' + msgId);
if (el && window.markdownBuffer[msgId]) {
document.body.addEventListener('htmx:sseBeforeMessage', function (e) {
const root = e.detail.elt.closest('[data-message-id]');
if (!root) return;
const msgId = root.dataset.messageId;
const spinner = root.querySelector('[data-stream-spinner]');
if (spinner) spinner.style.display = 'none';
const el = document.getElementById('ai-message-content-' + msgId);
if (e.detail.elt !== el) return;
e.preventDefault();
window.markdownBuffer[msgId] = (window.markdownBuffer[msgId] || '') + (e.detail.data || '');
el.innerHTML = marked.parse(window.markdownBuffer[msgId].replace(/\\n/g, '\n'));
delete window.markdownBuffer[msgId];
if (typeof window.scrollChatToBottom === "function") window.scrollChatToBottom();
}
});
if (streamEl) {
document.body.addEventListener('htmx:sseClose', function (e) {
const streamEl = e.target.closest('[data-message-id]');
if (!streamEl) return;
const msgId = streamEl.dataset.messageId;
const el = document.getElementById('ai-message-content-' + msgId);
if (el && window.markdownBuffer[msgId]) {
el.innerHTML = marked.parse(window.markdownBuffer[msgId].replace(/\\n/g, '\n'));
delete window.markdownBuffer[msgId];
if (typeof window.scrollChatToBottom === "function") window.scrollChatToBottom();
}
streamEl.removeAttribute('sse-connect');
streamEl.removeAttribute('sse-close');
streamEl.removeAttribute('hx-ext');
}
});
});
})();
</script>
@@ -1,60 +1,62 @@
<div class="drawer-side z-20">
<label for="my-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
<ul class="menu p-0 w-72 h-full nb-canvas text-base-content flex flex-col border-r-2 border-neutral">
<nav class="flex flex-col h-full w-72 p-0 nb-canvas text-base-content border-r-2 border-neutral">
<!-- === TOP FIXED SECTION === -->
<div class="px-2 mt-4 space-y-3">
<ul class="menu w-full px-2 mt-4 space-y-3">
{% block sidebar_nav_items %}
{% endblock %}
</div>
</ul>
<!-- === MIDDLE SCROLLABLE SECTION === -->
<span class="px-4 py-2 nb-label">Recent Chats</span>
<div class="flex-1 overflow-y-auto space-y-1 custom-scrollbar">
{% if conversation_archive is defined and conversation_archive %}
{% for conversation in conversation_archive %}
<li id="conversation-{{ conversation.id }}">
{% if edit_conversation_id == conversation.id %}
<!-- Edit mode -->
<form hx-patch="/chat/{{ conversation.id }}/title" hx-target=".drawer-side" hx-swap="outerHTML"
class="flex items-center gap-1 px-2 py-2 max-w-72 relative">
<input type="text" name="title" value="{{ conversation.title }}" class="nb-input nb-input-sm max-w-52" />
<div class="flex gap-0.5 absolute right-2">
<button type="submit" class="btn btn-ghost btn-xs !p-0">{% include "icons/check_icon.html" %}</button>
<button type="button" hx-get="/chat/sidebar" hx-target=".drawer-side" hx-swap="outerHTML"
class="btn btn-ghost btn-xs !p-0">
{% include "icons/x_icon.html" %}
</button>
<div class="flex-1 overflow-y-auto min-h-0 custom-scrollbar">
<ul class="menu w-full space-y-1">
{% if conversation_archive is defined and conversation_archive %}
{% for conversation in conversation_archive %}
<li id="conversation-{{ conversation.id }}">
{% if edit_conversation_id == conversation.id %}
<!-- Edit mode -->
<form hx-patch="/chat/{{ conversation.id }}/title" hx-target=".drawer-side" hx-swap="outerHTML"
class="flex items-center gap-1 px-2 py-2 max-w-72 relative">
<input type="text" name="title" value="{{ conversation.title }}" class="nb-input nb-input-sm max-w-52" />
<div class="flex gap-0.5 absolute right-2">
<button type="submit" class="btn btn-ghost btn-xs !p-0">{% include "icons/check_icon.html" %}</button>
<button type="button" hx-get="/chat/sidebar" hx-target=".drawer-side" hx-swap="outerHTML"
class="btn btn-ghost btn-xs !p-0">
{% include "icons/x_icon.html" %}
</button>
</div>
</form>
{% else %}
<!-- View mode -->
<div class="flex w-full pl-4 pr-2 py-2">
<a hx-boost="true" href="/chat/{{ conversation.id }}" class="flex-grow text-sm truncate">
<span>{{ conversation.title }}</span>
</a>
<div class="flex items-center gap-0.5 ml-2">
<button hx-get="/chat/{{ conversation.id }}/title" hx-target=".drawer-side" hx-swap="outerHTML"
class="btn btn-ghost btn-xs">
{% include "icons/edit_icon.html" %}
</button>
<button hx-delete="/chat/{{ conversation.id }}" hx-target=".drawer-side" hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this chat?" class="btn btn-ghost btn-xs">
{% include "icons/delete_icon.html" %}
</button>
</div>
</div>
</form>
{% endif %}
</li>
{% endfor %}
{% else %}
<!-- View mode -->
<div class="flex w-full pl-4 pr-2 py-2">
<a hx-boost="true" href="/chat/{{ conversation.id }}" class="flex-grow text-sm truncate">
<span>{{ conversation.title }}</span>
</a>
<div class="flex items-center gap-0.5 ml-2">
<button hx-get="/chat/{{ conversation.id }}/title" hx-target=".drawer-side" hx-swap="outerHTML"
class="btn btn-ghost btn-xs">
{% include "icons/edit_icon.html" %}
</button>
<button hx-delete="/chat/{{ conversation.id }}" hx-target=".drawer-side" hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this chat?" class="btn btn-ghost btn-xs">
{% include "icons/delete_icon.html" %}
</button>
</div>
</div>
{% endif %}
</li>
{% endfor %}
{% else %}
{% endif %}
</ul>
</div>
<!-- === BOTTOM FIXED SECTION === -->
<div class="px-2 pb-4 space-y-3">
<ul class="menu w-full px-2 pb-4 space-y-3">
{% block sidebar_bottom_actions %}
{% endblock %}
</div>
</ul>
</ul>
</nav>
</div>
+8 -42
View File
@@ -16,12 +16,20 @@
<!-- Scripts -->
<script src="/assets/htmx.min.js" defer></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
htmx.config.globalViewTransitions = true;
htmx.config.selfRequestsOnly = false;
});
</script>
<script src="/assets/htmx-ext-sse.js" defer></script>
<script src="/assets/theme-toggle.js" defer></script>
<script src="/assets/toast.js" defer></script>
<script src="/assets/marked.min.js" defer></script>
<script src="/assets/knowledge-graph.js" defer></script>
<script src="/assets/design-polish.js" defer></script>
<script src="/assets/modal.js" defer></script>
<script src="/assets/admin-prompt-reset.js" defer></script>
<!-- Global View Transition -->
<meta name="view-transition" content="same-origin" />
@@ -38,49 +46,7 @@
{% block head %}{% endblock %}
</head>
<script>
(function wait_for_htmx() {
if (window.htmx) {
htmx.config.globalViewTransitions = true;
htmx.config.selfRequestsOnly = false;
} else {
setTimeout(wait_for_htmx, 50);
}
})();
</script>
{% block body %}{% endblock %}
<script defer>
document.addEventListener('DOMContentLoaded', function () {
if (window.marked && !window.markedGlobalOptionsSet) {
marked.setOptions({
breaks: true,
gfm: true,
headerIds: false,
mangle: false,
smartLists: true,
smartypants: true,
xhtml: false
});
window.markedGlobalOptionsSet = true;
}
renderAllMarkdown();
});
document.body.addEventListener('htmx:afterSettle', renderAllMarkdown);
function renderAllMarkdown() {
if (!window.marked) return;
document.querySelectorAll('.markdown-content[data-content]').forEach(el => {
const raw = el.getAttribute('data-content') || '';
if (el.dataset.renderedContent !== raw) {
el.innerHTML = marked.parse(raw);
el.dataset.renderedContent = raw;
}
});
}
window.renderAllMarkdown = renderAllMarkdown;
</script>
</html>
@@ -62,7 +62,8 @@
</td>
<td>
<input id="relationship_type_input" name="relationship_type" type="text" placeholder="RelatedTo"
class="nb-input w-full new_relationship_input" value="{{ default_relationship_type }}" />
class="nb-input w-full new_relationship_input" value="{{ default_relationship_type }}"
hx-on:keydown="if(event.key==='Enter'){event.preventDefault();document.getElementById('save_relationship_button').click()}" />
</td>
<td>
<button id="save_relationship_button" type="button" class="nb-btn btn-sm" hx-post="/knowledge-relationship"
@@ -74,11 +75,3 @@
</tbody>
</table>
</div>
<script>
document.getElementById('relationship_type_input').addEventListener('keydown', function (event) {
if (event.key === 'Enter') {
event.preventDefault(); // Prevent form submission if within a form
document.getElementById('save_relationship_button').click();
}
});
</script>
+8 -22
View File
@@ -2,46 +2,32 @@
<div
class="modal-box relative rounded-none border-2 border-neutral bg-base-100 shadow-[8px_8px_0_0_#000] p-6 {% block modal_class %}max-w-lg{% endblock %}">
<!-- God Level UX: Explicit Escape Hatch -->
<!-- Close control (always visible; does not depend on form submit/cancel) -->
<button type="button"
class="btn btn-sm btn-square btn-ghost absolute right-2 top-2 z-10"
onclick="document.getElementById('body_modal').close()"
hx-on:click="document.getElementById('body_modal').close()"
aria-label="Close modal">
{% include "icons/x_icon.html" %}
</button>
<form id="modal_form" {% block form_attributes %}{% endblock %}>
{# Default: one outer #modal_form. Modals with multiple forms (scratchpad editor)
override modal_form_open / modal_form_close — nested <form> is invalid HTML. #}
{% block modal_form_open %}<form id="modal_form" hx-on::after-request="if(event.detail.successful) document.getElementById('body_modal').close()" {% block form_attributes %}{% endblock %}>{% endblock %}
<div class="flex flex-col flex-1 gap-5">
{% block modal_content %}{% endblock %}
</div>
<div class="mt-8 pt-2 flex flex-col gap-2 sm:flex-row sm:justify-end sm:items-center">
<!-- Secondary Action: Ghost style to reduce noise -->
<button type="button" class="btn btn-ghost rounded-none w-full sm:w-auto hover:bg-neutral/10" onclick="document.getElementById('body_modal').close()">
<!-- Dismiss without submitting -->
<button type="button" class="btn btn-ghost rounded-none w-full sm:w-auto hover:bg-neutral/10" hx-on:click="document.getElementById('body_modal').close()">
Cancel
</button>
<!-- Primary actions block -->
{% block primary_actions %}{% endblock %}
</div>
</form>
{% block modal_form_close %}</form>{% endblock %}
</div>
<script>
// Auto-open modal when injected
document.getElementById('body_modal').showModal();
// Close modal on successful form submission
document.getElementById('modal_form')
.addEventListener('htmx:afterRequest', (evt) => {
if (evt.detail.elt !== evt.currentTarget) return; // ignore inner htmx requests
if (evt.detail.successful) document.getElementById('body_modal').close();
});
// Clear modal content on close to prevent browser back from reopening it
document.getElementById('body_modal').addEventListener('close', (evt) => {
evt.target.innerHTML = '';
});
</script>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
@@ -2,7 +2,10 @@
{% block modal_class %}w-11/12 max-w-[90ch] max-h-[95%] overflow-y-auto{% endblock %}
{% block form_attributes %}{% endblock %}
{# title-form, auto-save-form, ingest-form, archive-form — override modal_base
wrapper blocks so these are not nested inside #modal_form. #}
{% block modal_form_open %}<div class="contents">{% endblock %}
{% block modal_form_close %}</div>{% endblock %}
{% block modal_content %}
<h3 class="text-xl font-extrabold tracking-tight">
@@ -34,7 +37,7 @@
<form id="auto-save-form"
hx-patch="/scratchpad/{{ scratchpad.id }}/auto-save"
hx-trigger="keyup changed delay:2s, focusout"
hx-trigger="keyup changed delay:2s, focusout delay:150ms"
hx-indicator="#save-indicator"
hx-swap="none"
class="flex flex-col gap-2">
@@ -64,8 +67,8 @@
hx-on::after-request="if(event.detail.successful) document.getElementById('body_modal').close()"
class="inline flex flex-col gap-3"
id="ingest-form">
<button type="button" class="nb-btn nb-cta" onclick="toggleIngestConfirmation(true)"
data-role="ingest-trigger">
<button type="button" class="nb-btn nb-cta" data-role="ingest-trigger"
hx-on:mousedown="event.preventDefault(); toggleIngestConfirmation(true)">
{% include "icons/send_icon.html" %} Ingest as Content
</button>
<div id="ingest-warning"
@@ -81,7 +84,8 @@
<button type="submit" class="nb-btn nb-btn-sm nb-cta">
Confirm ingest
</button>
<button type="button" class="nb-btn nb-btn-sm btn-ghost" onclick="toggleIngestConfirmation(false)">
<button type="button" class="nb-btn nb-btn-sm btn-ghost"
hx-on:click="toggleIngestConfirmation(false)">
Cancel
</button>
</div>
+1 -1
View File
@@ -22,7 +22,7 @@
hx-target="#modal" hx-swap="innerHTML">{% include "icons/send_icon.html" %} Add
Content</button>
</li>
<div class="u-hairline mt-4"></div>
<li role="separator" class="u-hairline mt-4 list-none"></li>
{% endblock %}
{% block sidebar_bottom_actions %}
+233
View File
@@ -107,6 +107,239 @@ async fn response_body(response: Response) -> String {
String::from_utf8(body.to_vec()).expect("html body")
}
/// Shared insta settings for HTML snapshots in this module.
///
/// The in-memory DB is recreated per test, so ids in markup would otherwise churn.
/// Filters normalize those values; see `snapshot_*` tests below.
fn snapshot_settings() -> insta::Settings {
let mut settings = insta::Settings::clone_current();
settings.set_prepend_module_to_snapshot(false);
settings.add_filter(
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
"[uuid]",
);
settings.add_filter(r"[a-z_]+:[0-9a-z]{12,}", "[record-id]");
settings
}
const AUTHENTICATED_MAIN_OPEN: &str = r#"<main class="flex flex-col flex-1 overflow-y-auto">"#;
/// Inner HTML of the scrollable page column from `body_base.html` (`{% block main %}`).
///
/// Omits head, navbar shell, sidebar, and modal mount points so per-route snapshots
/// do not duplicate layout chrome (see `snapshot_authenticated_shell`).
fn extract_authenticated_main(html: &str) -> &str {
let start = html
.find(AUTHENTICATED_MAIN_OPEN)
.expect("authenticated page main column")
+ AUTHENTICATED_MAIN_OPEN.len();
let rest = &html[start..];
let end = rest
.find("</main>")
.expect("authenticated page main column close");
&rest[..end]
}
async fn get_html(app: &Router, uri: &str, cookie: Option<&str>) -> String {
let mut builder = Request::builder().uri(uri);
if let Some(cookie) = cookie {
builder = builder.header(header::COOKIE, cookie);
}
let response = app
.clone()
.oneshot(builder.body(Body::empty()).expect("request"))
.await
.expect("response");
response_body(response).await
}
/// Fixed credentials for authenticated snapshot routes (dashboard, search, etc.).
async fn seeded_cookie(app: &Router, db: &SurrealDbClient) -> String {
User::create_new(
"snapshot_user@example.com".to_string(),
"snapshot_password".to_string(),
db,
"UTC".to_string(),
"system".to_string(),
)
.await
.expect("snapshot user");
sign_in(app, "snapshot_user@example.com", "snapshot_password").await
}
/// Parses a scratchpad id from the list page HTML after `POST /scratchpad`.
async fn create_scratchpad_and_get_id(app: &Router, cookie: &str, title: &str) -> String {
app.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/scratchpad")
.header(header::COOKIE, cookie)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(format!("title={title}")))
.expect("create request"),
)
.await
.expect("create response");
let list = get_html(app, "/scratchpad", Some(cookie)).await;
let marker = "/scratchpad/";
let start = list.find(marker).expect("scratchpad link present") + marker.len();
list[start..start + list[start..].find('/').expect("id terminator")].to_string()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn scratchpad_editor_modal_does_not_nest_forms() {
let (app, db) = build_test_app().await;
let cookie = seeded_cookie(&app, &db).await;
let id = create_scratchpad_and_get_id(&app, &cookie, "IngestPad").await;
let modal = get_html(&app, &format!("/scratchpad/{id}/modal"), Some(&cookie)).await;
// Scratchpad editor opts out of #modal_form (see editor_modal.html); nested
// <form> elements are invalid HTML and browsers drop the inner forms.
assert!(
!modal.contains(r#"id="modal_form""#),
"editor modal should not wrap content in #modal_form"
);
assert!(
modal.contains(&format!("/scratchpad/{id}/ingest")),
"ingest form action should be present"
);
assert!(
modal.contains(r#"id="ingest-form""#),
"ingest form should be a real, addressable form"
);
// Ingest targets #main_section, so the response must be a partial, not a full page.
app.clone()
.oneshot(
Request::builder()
.method("PATCH")
.uri(format!("/scratchpad/{id}/auto-save"))
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from("content=Some+content+to+ingest"))
.expect("save request"),
)
.await
.expect("save response");
let ingest = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/scratchpad/{id}/ingest"))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.expect("ingest request"),
)
.await
.expect("ingest response");
assert_eq!(ingest.status(), StatusCode::OK);
let body = response_body(ingest).await;
assert!(
!body.trim_start().starts_with("<!DOCTYPE") && body.contains(r#"id="main_section""#),
"ingest should return only the main section partial"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn scratchpad_archive_returns_main_partial_only() {
let (app, db) = build_test_app().await;
let cookie = seeded_cookie(&app, &db).await;
let id = create_scratchpad_and_get_id(&app, &cookie, "RegressionPad").await;
let archive = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/scratchpad/{id}/archive"))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.expect("archive request"),
)
.await
.expect("archive response");
assert_eq!(archive.status(), StatusCode::OK);
let body = response_body(archive).await;
// Archive uses hx-target="#main_section" — same partial contract as ingest.
assert!(
!body.trim_start().starts_with("<!DOCTYPE"),
"archive should return a partial, not a full document"
);
assert!(
!body.contains("drawer-side"),
"archive partial should not include the sidebar"
);
assert!(
body.contains(r#"id="main_section""#),
"archive partial should be the main section"
);
}
// HTML regression snapshots (insta). Authenticated layout: one full-document shell
// plus per-route main-column slices via `extract_authenticated_main`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn snapshot_signin_page() {
let (app, _db) = build_test_app().await;
let body = get_html(&app, "/signin", None).await;
snapshot_settings().bind(|| insta::assert_snapshot!("signin_page", body));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn snapshot_signup_page() {
let (app, _db) = build_test_app().await;
let body = get_html(&app, "/signup", None).await;
snapshot_settings().bind(|| insta::assert_snapshot!("signup_page", body));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn snapshot_authenticated_shell() {
let (app, db) = build_test_app().await;
let cookie = seeded_cookie(&app, &db).await;
let body = get_html(&app, "/", Some(&cookie)).await;
snapshot_settings().bind(|| insta::assert_snapshot!("authenticated_shell", body));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn snapshot_dashboard_main() {
let (app, db) = build_test_app().await;
let cookie = seeded_cookie(&app, &db).await;
let body = get_html(&app, "/", Some(&cookie)).await;
let main = extract_authenticated_main(&body);
snapshot_settings().bind(|| insta::assert_snapshot!("dashboard_main", main));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn snapshot_search_main() {
let (app, db) = build_test_app().await;
let cookie = seeded_cookie(&app, &db).await;
let body = get_html(&app, "/search", Some(&cookie)).await;
let main = extract_authenticated_main(&body);
snapshot_settings().bind(|| insta::assert_snapshot!("search_main", main));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn snapshot_not_found_main() {
let (app, db) = build_test_app().await;
let cookie = seeded_cookie(&app, &db).await;
let body = get_html(&app, "/file/does-not-exist", Some(&cookie)).await;
let main = extract_authenticated_main(&body);
snapshot_settings().bind(|| insta::assert_snapshot!("not_found_main", main));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn snapshot_new_entity_modal() {
let (app, db) = build_test_app().await;
let cookie = seeded_cookie(&app, &db).await;
let body = get_html(&app, "/knowledge-entity/new", Some(&cookie)).await;
snapshot_settings().bind(|| insta::assert_snapshot!("new_entity_modal", body));
}
async fn sign_in(app: &Router, email: &str, password: &str) -> String {
let response = app
.clone()
@@ -0,0 +1,391 @@
---
source: html-router/tests/router_integration.rs
expression: body
---
<!DOCTYPE html>
<html lang="en" data-theme="light" data-theme-preference="system">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>Minne - Dashboard</title>
<!-- Preload critical assets -->
<link rel="preload" href="/assets/htmx.min.js" as="script">
<link rel="preload" href="/assets/style.css" as="style">
<!-- Core styles -->
<link rel="stylesheet" href="/assets/style.css">
<!-- Scripts -->
<script src="/assets/htmx.min.js" defer></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
htmx.config.globalViewTransitions = true;
htmx.config.selfRequestsOnly = false;
});
</script>
<script src="/assets/htmx-ext-sse.js" defer></script>
<script src="/assets/theme-toggle.js" defer></script>
<script src="/assets/toast.js" defer></script>
<script src="/assets/marked.min.js" defer></script>
<script src="/assets/knowledge-graph.js" defer></script>
<script src="/assets/design-polish.js" defer></script>
<script src="/assets/modal.js" defer></script>
<script src="/assets/admin-prompt-reset.js" defer></script>
<!-- Global View Transition -->
<meta name="view-transition" content="same-origin" />
<!-- Icons -->
<link rel="icon" href="/assets/icon/favicon.ico">
<link rel="apple-touch-icon" href="/assets/icon/apple-touch-icon.png" media="(device-width: 320px)">
<!-- PWA -->
<link rel="manifest" href="/assets/manifest.json">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
</head>
<body class="relative">
<div id="main-content-wrapper" class="drawer lg:drawer-open">
<input id="my-drawer" type="checkbox" class="drawer-toggle" />
<!-- Page Content -->
<div class="drawer-content flex flex-col h-screen">
<!-- Navbar -->
<nav class="sticky top-0 z-10 nb-panel nb-panel-canvas border-t-0" style="border-left: 0">
<div class="container mx-auto navbar">
<div class="mr-2 flex-1">
<div class="flex items-center gap-2 min-w-[90px] w-full">
<form class="w-full relative" hx-boost="true" method="get" action="/search"
hx-trigger="keyup changed delay:500ms from:#search-input, search from:#search-input" hx-push-url="true">
<input id="search-input" type="search" aria-label="Search" class=" nb-input w-full pl-9 ml-2" name="query"
autocomplete="off" value="" />
<button type="submit"
class="absolute right-1 top-1/2 -translate-y-1/2 nb-btn btn-xs px-3 h-7 bg-base-100 hover:bg-base-200">
Search
</button>
<span class="hidden md:inline absolute right-24 top-1/2 -translate-y-1/2 text-xs opacity-60">
press <kbd class="kbd kbd-xs">Enter</kbd>
</span>
</form>
</div>
</div>
<div class="flex-none">
<ul class="menu menu-horizontal px-2 gap-2 items-center">
<label for="my-drawer" aria-label="open sidebar" class="hover:cursor-pointer lg:hidden">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" width="20" height="20" viewBox="0 0 24 24" stroke-width="1.5"
width="20" height="20" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</label>
</ul>
</div>
</div>
</nav>
<!-- Main Content Area -->
<main class="flex flex-col flex-1 overflow-y-auto">
<div class="flex justify-center grow mt-2 sm:mt-4 pb-4 w-full">
<div class="container">
<section class="mb-4">
<div class="nb-panel p-3 flex items-center justify-between">
<h1 class="text-xl font-extrabold tracking-tight">Dashboard</h1>
<button class="nb-btn nb-cta" hx-get="/ingest-form" hx-target="#modal" hx-swap="innerHTML">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
width="20" height="20" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
</svg>
<span class="ml-2">Add Content</span>
</button>
</div>
</section>
<section class="mb-4 sm:mt-4">
<h2 class="text-2xl font-extrabold tracking-tight mb-3">Overview</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
<div class="nb-stat">
<div class="text-xs opacity-70">Total Documents</div>
<div class="text-3xl font-extrabold">0</div>
<div class="text-xs opacity-60">+0 this week</div>
</div>
<div class="nb-stat">
<div class="text-xs opacity-70">Text Chunks</div>
<div class="text-3xl font-extrabold">0</div>
<div class="text-xs opacity-60">+0 this week</div>
</div>
<div class="nb-stat">
<div class="text-xs opacity-70">Knowledge Entities</div>
<div class="text-3xl font-extrabold">0</div>
<div class="text-xs opacity-60">+0 this week</div>
</div>
<div class="nb-stat">
<div class="text-xs opacity-70">Conversations</div>
<div class="text-3xl font-extrabold">0</div>
<div class="text-xs opacity-60">+0 this week</div>
</div>
</div>
</section>
<div id="latest_content_section" class="list">
<h2 class="text-2xl mb-2 font-extrabold">Recent content</h2>
<div id="latest_text_content_cards" class="space-y-6">
<div class="nb-card p-8 text-center text-sm opacity-70">
No content found.
</div>
</div>
</div>
<section id="active_jobs_section" class="nb-panel p-4 space-y-4 mt-6 sm:mt-8">
<header class="flex flex-wrap items-center justify-between gap-3">
<h2 class="text-xl font-extrabold tracking-tight">Active Tasks</h2>
<div class="flex gap-2">
<button class="nb-btn btn-square btn-sm" hx-get="/active-jobs" hx-target="#active_jobs_section" hx-swap="outerHTML"
aria-label="Refresh active tasks">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
width="20" height="20" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
</button>
<button class="nb-btn btn-sm" hx-get="/jobs/archive" hx-target="#modal" hx-swap="innerHTML"
aria-label="View task archive">
View Archive
</button>
</div>
</header>
</section>
</div>
</div>
<div class="p32 min-h-[10px]"></div>
</main>
</div>
<!-- Sidebar -->
<div class="drawer-side z-20">
<label for="my-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
<nav class="flex flex-col h-full w-72 p-0 nb-canvas text-base-content border-r-2 border-neutral">
<!-- === TOP FIXED SECTION === -->
<ul class="menu w-full px-2 mt-4 space-y-3">
<li>
<a hx-boost="true" href="&#x2f;" class="nb-btn w-full justify-start gap-3 bg-base-100 hover:bg-base-200">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
width="20" height="20" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg>
<span class="uppercase tracking-wide">Dashboard</span>
</a>
</li>
<li>
<a hx-boost="true" href="&#x2f;knowledge" class="nb-btn w-full justify-start gap-3 bg-base-100 hover:bg-base-200">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
width="20" height="20" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
</svg>
<span class="uppercase tracking-wide">Knowledge</span>
</a>
</li>
<li>
<a hx-boost="true" href="&#x2f;content" class="nb-btn w-full justify-start gap-3 bg-base-100 hover:bg-base-200">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
width="20" height="20" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg>
<span class="uppercase tracking-wide">Content</span>
</a>
</li>
<li>
<a hx-boost="true" href="&#x2f;chat" class="nb-btn w-full justify-start gap-3 bg-base-100 hover:bg-base-200">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
width="20" height="20" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
</svg>
<span class="uppercase tracking-wide">Chat</span>
</a>
</li>
<li>
<a hx-boost="true" href="&#x2f;search" class="nb-btn w-full justify-start gap-3 bg-base-100 hover:bg-base-200">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
width="20" height="20" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
<span class="uppercase tracking-wide">Search</span>
</a>
</li>
<li>
<a hx-boost="true" href="&#x2f;scratchpad" class="nb-btn w-full justify-start gap-3 bg-base-100 hover:bg-base-200">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
width="20" height="20" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
</svg>
<span class="uppercase tracking-wide">Scratchpad</span>
</a>
</li>
<li>
<button class="nb-btn nb-cta w-full flex items-center gap-3 justify-start mt-2" hx-get="/ingest-form"
hx-target="#modal" hx-swap="innerHTML"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
width="20" height="20" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
</svg> Add
Content</button>
</li>
<li role="separator" class="u-hairline mt-4 list-none"></li>
</ul>
<!-- === MIDDLE SCROLLABLE SECTION === -->
<span class="px-4 py-2 nb-label">Recent Chats</span>
<div class="flex-1 overflow-y-auto min-h-0 custom-scrollbar">
<ul class="menu w-full space-y-1">
</ul>
</div>
<!-- === BOTTOM FIXED SECTION === -->
<ul class="menu w-full px-2 pb-4 space-y-3">
<li>
<a hx-boost="true" href="/account"
class="nb-btn w-full justify-start items-center gap-3 bg-base-100 hover:bg-base-200">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
width="20" height="20" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
<span class="uppercase tracking-wide">Account</span>
</a>
</li>
<li>
<a hx-boost="true" href="/admin"
class="nb-btn w-full justify-start items-center gap-3 bg-base-100 hover:bg-base-200">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
width="20" height="20" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M11.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 0 0 4.486-6.336l-3.276 3.277a3.004 3.004 0 0 1-2.25-2.25l3.276-3.276a4.5 4.5 0 0 0-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437 1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008Z" />
</svg>
<span class="uppercase tracking-wide">Admin</span>
</a>
</li>
<li>
<a hx-boost="true" href="/signout"
class="nb-btn w-full justify-start items-center gap-3 bg-base-100 hover:bg-base-200 border-error text-error">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
width="20" height="20" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M8.25 9V5.25A2.25 2.25 0 0 1 10.5 3h6a2.25 2.25 0 0 1 2.25 2.25v13.5A2.25 2.25 0 0 1 16.5 21h-6a2.25 2.25 0 0 1-2.25-2.25V15m-3 0-3-3m0 0 3-3m-3 3H15" />
</svg>
<span class="uppercase tracking-wide">Logout</span>
</a>
</li>
</ul>
</nav>
</div>
</div> <!-- End Drawer -->
<div id="modal"></div>
<div id="toast-container" class="fixed bottom-4 right-4 z-50 space-y-2"></div>
<script defer>
document.addEventListener('DOMContentLoaded', function () {
if (window.marked && !window.markedGlobalOptionsSet) {
marked.setOptions({
breaks: true,
gfm: true,
headerIds: false,
mangle: false,
smartLists: true,
smartypants: true,
xhtml: false
});
window.markedGlobalOptionsSet = true;
}
renderAllMarkdown();
});
document.body.addEventListener('htmx:afterSettle', renderAllMarkdown);
function renderAllMarkdown() {
if (!window.marked) return;
document.querySelectorAll('.markdown-content[data-content]').forEach(el => {
const raw = el.getAttribute('data-content') || '';
if (el.dataset.renderedContent !== raw) {
el.innerHTML = marked.parse(raw);
el.dataset.renderedContent = raw;
}
});
}
window.renderAllMarkdown = renderAllMarkdown;
</script>
</body>
</html>
@@ -0,0 +1,91 @@
---
source: html-router/tests/router_integration.rs
expression: main
---
<div class="flex justify-center grow mt-2 sm:mt-4 pb-4 w-full">
<div class="container">
<section class="mb-4">
<div class="nb-panel p-3 flex items-center justify-between">
<h1 class="text-xl font-extrabold tracking-tight">Dashboard</h1>
<button class="nb-btn nb-cta" hx-get="/ingest-form" hx-target="#modal" hx-swap="innerHTML">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
width="20" height="20" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
</svg>
<span class="ml-2">Add Content</span>
</button>
</div>
</section>
<section class="mb-4 sm:mt-4">
<h2 class="text-2xl font-extrabold tracking-tight mb-3">Overview</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
<div class="nb-stat">
<div class="text-xs opacity-70">Total Documents</div>
<div class="text-3xl font-extrabold">0</div>
<div class="text-xs opacity-60">+0 this week</div>
</div>
<div class="nb-stat">
<div class="text-xs opacity-70">Text Chunks</div>
<div class="text-3xl font-extrabold">0</div>
<div class="text-xs opacity-60">+0 this week</div>
</div>
<div class="nb-stat">
<div class="text-xs opacity-70">Knowledge Entities</div>
<div class="text-3xl font-extrabold">0</div>
<div class="text-xs opacity-60">+0 this week</div>
</div>
<div class="nb-stat">
<div class="text-xs opacity-70">Conversations</div>
<div class="text-3xl font-extrabold">0</div>
<div class="text-xs opacity-60">+0 this week</div>
</div>
</div>
</section>
<div id="latest_content_section" class="list">
<h2 class="text-2xl mb-2 font-extrabold">Recent content</h2>
<div id="latest_text_content_cards" class="space-y-6">
<div class="nb-card p-8 text-center text-sm opacity-70">
No content found.
</div>
</div>
</div>
<section id="active_jobs_section" class="nb-panel p-4 space-y-4 mt-6 sm:mt-8">
<header class="flex flex-wrap items-center justify-between gap-3">
<h2 class="text-xl font-extrabold tracking-tight">Active Tasks</h2>
<div class="flex gap-2">
<button class="nb-btn btn-square btn-sm" hx-get="/active-jobs" hx-target="#active_jobs_section" hx-swap="outerHTML"
aria-label="Refresh active tasks">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
width="20" height="20" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
</button>
<button class="nb-btn btn-sm" hx-get="/jobs/archive" hx-target="#modal" hx-swap="innerHTML"
aria-label="View task archive">
View Archive
</button>
</div>
</header>
</section>
</div>
</div>
<div class="p32 min-h-[10px]"></div>
@@ -0,0 +1,119 @@
---
source: html-router/tests/router_integration.rs
expression: body
---
<dialog id="body_modal" class="modal">
<div
class="modal-box relative rounded-none border-2 border-neutral bg-base-100 shadow-[8px_8px_0_0_#000] p-6 max-w-4xl w-full">
<!-- Close control (always visible; does not depend on form submit/cancel) -->
<button type="button"
class="btn btn-sm btn-square btn-ghost absolute right-2 top-2 z-10"
hx-on:click="document.getElementById('body_modal').close()"
aria-label="Close modal">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
width="20" height="20" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
<form id="modal_form" hx-on::after-request="if(event.detail.successful) document.getElementById('body_modal').close()"
hx-post="/knowledge-entity"
hx-target="#knowledge_pane"
hx-swap="outerHTML"
>
<div class="flex flex-col flex-1 gap-5">
<h3 class="text-xl font-extrabold tracking-tight">Create Knowledge Entity</h3>
<div class="flex flex-col gap-3">
<label class="w-full">
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Name</div>
<input type="text" name="name" class="nb-input w-full" placeholder="Entity title" required>
</label>
<label class="w-full">
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Type</div>
<select name="entity_type" class="nb-select w-full">
<option value="Idea">Idea</option>
<option value="Project">Project</option>
<option value="Document">Document</option>
<option value="Page">Page</option>
<option value="TextSnippet">TextSnippet</option>
</select>
</label>
<label class="w-full">
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Description</div>
<textarea name="description" class="nb-input w-full h-32"
placeholder="Describe this entity so it can be found later"></textarea>
</label>
</div>
<div class="u-hairline pt-3 mt-4 space-y-3">
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<div class="text-xs uppercase tracking-wide opacity-70">Relationships</div>
<p class="text-xs opacity-70 max-w-md">
Select existing entities to link. Suggestions will pre-select likely matches.
</p>
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<label class="flex items-center gap-2">
<span class="text-xs uppercase tracking-wide opacity-70">Type</span>
<input type="text" name="relationship_type" value="RelatedTo"
class="nb-input w-32" placeholder="RelatedTo" list="relationship-type-options">
</label>
<datalist id="relationship-type-options">
<option value="References"></option>
<option value="RelatedTo"></option>
<option value="RelevantTo"></option>
<option value="SimilarTo"></option>
</datalist>
<button type="button" class="nb-btn btn-sm nb-cta sm:ml-2" hx-post="/knowledge-entity/suggestions"
hx-target="#relationship-list" hx-swap="outerHTML" hx-include="#modal_form">
Suggest Relationships
</button>
</div>
</div>
<div id="relationship-list" class="nb-card p-4 text-sm opacity-70">
You need at least one existing entity before creating relationships.
</div>
</div>
</div>
<div class="mt-8 pt-2 flex flex-col gap-2 sm:flex-row sm:justify-end sm:items-center">
<!-- Dismiss without submitting -->
<button type="button" class="btn btn-ghost rounded-none w-full sm:w-auto hover:bg-neutral/10" hx-on:click="document.getElementById('body_modal').close()">
Cancel
</button>
<!-- Primary actions block -->
<button type="submit" class="nb-btn nb-cta">
Create Entity
</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
@@ -0,0 +1,17 @@
---
source: html-router/tests/router_integration.rs
expression: main
---
<main class="container justify-center flex-grow flex mx-auto mt-4">
<div class="flex flex-col space-y-4 text-center justify-center">
<h1 class="text-2xl font-bold text-error">
404
</h1>
<p class="text-2xl my-4">Page Not Found</p>
<p class="text-base-content/60">The page you&#x27;re looking for doesn&#x27;t exist or was removed.</p>
<a href="/" class="btn btn-primary mt-8">Go Home</a>
</div>
@@ -0,0 +1,46 @@
---
source: html-router/tests/router_integration.rs
expression: main
---
<div id="search_pane" class="flex justify-center grow mt-2 sm:mt-4">
<div class="container">
<section class="mb-4">
<div class="nb-panel p-3 flex items-center justify-between">
<div>
<h1 class="text-xl font-extrabold tracking-tight">Search</h1>
<div class="text-xs opacity-70">Find document snippets and knowledge entities</div>
</div>
<form hx-get="/search" hx-target="#search_pane" hx-swap="outerHTML" hx-push-url="true"
class="flex items-center gap-1">
<button type="submit" name="view" value="all"
class="nb-btn btn-sm nb-cta">
All
</button>
<button type="submit" name="view" value="chunks"
class="nb-btn btn-sm btn-ghost">
Chunks
</button>
<button type="submit" name="view" value="entities"
class="nb-btn btn-sm btn-ghost">
Entities
</button>
</form>
</div>
</section>
<div class="nb-panel p-5 text-center">
<p class="text-lg font-semibold">Enter a term above to search your knowledge base.</p>
<p class="text-sm opacity-70">Results will appear here.</p>
</div>
</div>
</div>
<div class="p32 min-h-[10px]"></div>
@@ -0,0 +1,103 @@
---
source: html-router/tests/router_integration.rs
expression: body
---
<!DOCTYPE html>
<html lang="en" data-theme="light" data-theme-preference="system">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>Minne - Sign in</title>
<!-- Preload critical assets -->
<link rel="preload" href="/assets/htmx.min.js" as="script">
<link rel="preload" href="/assets/style.css" as="style">
<!-- Core styles -->
<link rel="stylesheet" href="/assets/style.css">
<!-- Scripts -->
<script src="/assets/htmx.min.js" defer></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
htmx.config.globalViewTransitions = true;
htmx.config.selfRequestsOnly = false;
});
</script>
<script src="/assets/htmx-ext-sse.js" defer></script>
<script src="/assets/theme-toggle.js" defer></script>
<script src="/assets/toast.js" defer></script>
<script src="/assets/marked.min.js" defer></script>
<script src="/assets/knowledge-graph.js" defer></script>
<script src="/assets/design-polish.js" defer></script>
<script src="/assets/modal.js" defer></script>
<script src="/assets/admin-prompt-reset.js" defer></script>
<!-- Global View Transition -->
<meta name="view-transition" content="same-origin" />
<!-- Icons -->
<link rel="icon" href="/assets/icon/favicon.ico">
<link rel="apple-touch-icon" href="/assets/icon/apple-touch-icon.png" media="(device-width: 320px)">
<!-- PWA -->
<link rel="manifest" href="/assets/manifest.json">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
</head>
<div class="min-h-[100dvh] flex flex-col items-center justify-center">
<div class="container mx-auto px-4 sm:max-w-md flex-1 flex items-center justify-center">
<div class="w-full nb-card p-5">
<div class="flex items-center justify-between mb-3">
<div class="brand-mark text-3xl font-extrabold tracking-tight">MINNE</div>
<span class="nb-badge">Sign In</span>
</div>
<div class="u-hairline mb-3"></div>
<form hx-post="/signin" hx-swap="none" class="flex flex-col gap-2">
<label class="w-full">
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Email</div>
<input name="email" type="email" placeholder="Email" class="nb-input w-full validator" required />
<div class="validator-hint hidden text-xs opacity-70 mt-1">Enter valid email address</div>
</label>
<label class="w-full">
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Password</div>
<input name="password" type="password" class="nb-input w-full validator" required placeholder="Password"
minlength="8" />
</label>
<div class="form-control mt-1">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" name="remember_me" class="nb-checkbox" />
<span class="label-text">Remember me</span>
</label>
</div>
<div class="form-control mt-1">
<button id="submit-btn" class="nb-btn nb-cta w-full">Login</button>
</div>
</form>
<div class="u-hairline my-3"></div>
<div class="text-center text-sm">
Dont have an account?
<a href="/signup" hx-boost="true" class="nb-link">Sign up</a>
</div>
</div>
</div>
</div>
<div id="toast-container" class="fixed bottom-4 right-4 z-50 space-y-2"></div>
</html>
@@ -0,0 +1,108 @@
---
source: html-router/tests/router_integration.rs
expression: body
---
<!DOCTYPE html>
<html lang="en" data-theme="light" data-theme-preference="system">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>Minne - Sign up</title>
<!-- Preload critical assets -->
<link rel="preload" href="/assets/htmx.min.js" as="script">
<link rel="preload" href="/assets/style.css" as="style">
<!-- Core styles -->
<link rel="stylesheet" href="/assets/style.css">
<!-- Scripts -->
<script src="/assets/htmx.min.js" defer></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
htmx.config.globalViewTransitions = true;
htmx.config.selfRequestsOnly = false;
});
</script>
<script src="/assets/htmx-ext-sse.js" defer></script>
<script src="/assets/theme-toggle.js" defer></script>
<script src="/assets/toast.js" defer></script>
<script src="/assets/marked.min.js" defer></script>
<script src="/assets/knowledge-graph.js" defer></script>
<script src="/assets/design-polish.js" defer></script>
<script src="/assets/modal.js" defer></script>
<script src="/assets/admin-prompt-reset.js" defer></script>
<!-- Global View Transition -->
<meta name="view-transition" content="same-origin" />
<!-- Icons -->
<link rel="icon" href="/assets/icon/favicon.ico">
<link rel="apple-touch-icon" href="/assets/icon/apple-touch-icon.png" media="(device-width: 320px)">
<!-- PWA -->
<link rel="manifest" href="/assets/manifest.json">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
</head>
<div class="min-h-[100dvh] flex flex-col items-center justify-center">
<div class="container mx-auto px-4 sm:max-w-md">
<div class="nb-card p-5">
<div class="flex items-center justify-between mb-3">
<div class="text-3xl font-extrabold tracking-tight">MINNE</div>
<span class="nb-badge">Sign Up</span>
</div>
<div class="u-hairline mb-3"></div>
<form hx-post="/signup" hx-swap="none" class="flex flex-col gap-4">
<label class="w-full">
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Email</div>
<input type="email" placeholder="Email" name="email" required class="nb-input w-full validator" />
<div class="validator-hint hidden text-xs opacity-70 mt-1">Enter valid email address</div>
</label>
<label class="w-full">
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Password</div>
<input type="password" name="password" class="nb-input w-full validator" required placeholder="Password"
minlength="8" pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}"
title="Must be more than 8 characters, including number, lowercase letter, uppercase letter" />
<p class="validator-hint hidden text-xs opacity-70 mt-1">
Must be more than 8 characters, including
<br />At least one number
<br />At least one lowercase letter
<br />At least one uppercase letter
</p>
</label>
<div class="form-control mt-1">
<button id="submit-btn" class="nb-btn nb-cta w-full">Create Account</button>
</div>
<input type="hidden" name="timezone" id="timezone" />
</form>
<div class="u-hairline my-3"></div>
<div class="text-center text-sm">
Already have an account?
<a href="/signin" hx-boost="true" class="nb-link">Sign in</a>
</div>
</div>
</div>
<script>
// Detect timezone and set hidden input
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
document.getElementById("timezone").value = timezone;
</script>
</div>
<div id="toast-container" class="fixed bottom-4 right-4 z-50 space-y-2"></div>
</html>
+60
View File
@@ -0,0 +1,60 @@
//! Compile-time smoke test for every file under `templates/`.
//!
//! Loads each `.html` through minijinja with the same `path_loader` setup as the
//! app. Catches syntax and extends/include errors without rendering or hitting routes.
//! Complements insta snapshots in `router_integration.rs`, which test rendered HTML.
#![allow(clippy::expect_used)]
use std::fs;
use std::path::{Path, PathBuf};
use minijinja::{path_loader, Environment};
fn templates_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("templates")
}
fn collect_html_templates(dir: &Path, root: &Path, out: &mut Vec<String>) {
for entry in fs::read_dir(dir).expect("read template directory") {
let path = entry.expect("directory entry").path();
if path.is_dir() {
collect_html_templates(&path, root, out);
} else if path.extension().and_then(|ext| ext.to_str()) == Some("html") {
let rel = path.strip_prefix(root).expect("strip templates root");
// minijinja template names use forward slashes regardless of OS.
out.push(rel.to_string_lossy().replace('\\', "/"));
}
}
}
#[test]
fn all_templates_compile() {
let root = templates_dir();
let mut env = Environment::new();
env.set_loader(path_loader(&root));
minijinja_contrib::add_to_environment(&mut env);
let mut names = Vec::new();
collect_html_templates(&root, &root, &mut names);
assert!(
!names.is_empty(),
"expected to discover template files under {}",
root.display()
);
let mut failures = Vec::new();
for name in &names {
if let Err(error) = env.get_template(name) {
failures.push(format!("{name}: {error:#}"));
}
}
assert!(
failures.is_empty(),
"{} template(s) failed to compile:\n{}",
failures.len(),
failures.join("\n")
);
}