621 lines
26 KiB
Common Lisp
621 lines
26 KiB
Common Lisp
(text "{% extends \"root.html\" %} {% block head %}")
|
|
(text "{% if journal -%}") (title (text "{{ journal.title }}")) (text "{% else %}") (title (text "Journals - {{ config.name }}")) (text "{%- endif %}")
|
|
(link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/css/chats.css?v=tetratto-{{ random_cache_breaker }}"))
|
|
|
|
(text "{% if view_mode and journal and is_editor -%} {% if note -%}")
|
|
; redirect to note
|
|
(meta ("http-equiv" "refresh") ("content" "0; url=/@{{ user.username }}/{{ journal.title }}/{{ note.title }}"))
|
|
(text "{% else %}")
|
|
; redirect to journal homepage
|
|
(meta ("http-equiv" "refresh") ("content" "0; url=/@{{ user.username }}/{{ journal.title }}"))
|
|
(text "{%- endif %} {%- endif %}")
|
|
|
|
(text "{% if view_mode and journal -%}")
|
|
; add journal css
|
|
(link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/api/v1/journals/{{ journal.id }}/journal.css?v=tetratto-{{ random_cache_breaker }}"))
|
|
(text "{%- endif %}")
|
|
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"journals\") }}")
|
|
(text "{% if not view_mode -%}")
|
|
(nav
|
|
("class" "chats_nav")
|
|
(button
|
|
("class" "flex gap-2 items-center active")
|
|
("onclick" "toggle_sidebars(event)")
|
|
(text "{{ icon \"panel-left\" }} {% if community -%}")
|
|
(b
|
|
("class" "name shorter")
|
|
(text "{% if community.context.display_name -%} {{ community.context.display_name }} {% else %} {{ community.title }} {%- endif %}"))
|
|
(text "{% else %}")
|
|
(b
|
|
(text "{{ text \"journals:label.my_journals\" }}"))
|
|
(text "{%- endif %}")))
|
|
(text "{%- endif %}")
|
|
(div
|
|
("class" "flex")
|
|
; journals/notes listing
|
|
(text "{% if not view_mode -%}")
|
|
; this isn't shown if we're in view mode
|
|
(div
|
|
("class" "sidebar flex flex-col gap-2 justify-between")
|
|
("id" "notes_list")
|
|
(div
|
|
("class" "flex flex-col gap-2 w-full")
|
|
(button
|
|
("class" "lowered justify-start w-full")
|
|
("onclick" "create_journal()")
|
|
(icon (text "plus"))
|
|
(str (text "journals:action.create_journal")))
|
|
|
|
(text "{% for journal in journals %}")
|
|
(text "{{ components::journal_listing(journal=journal, notes=notes, selected_note=selected_note, selected_journal=selected_journal, view_mode=view_mode) }}")
|
|
(text "{% endfor %}")))
|
|
(text "{%- endif %}")
|
|
; editor
|
|
(div
|
|
("class" "w-full padded_section")
|
|
("id" "editor")
|
|
("style" "padding: var(--pad-4)")
|
|
(main
|
|
("class" "flex flex-col gap-2")
|
|
; the journal/note header is always shown
|
|
(text "{% if journal -%}")
|
|
(div
|
|
("class" "mobile_nav w-full flex items-center justify-between gap-2")
|
|
(div
|
|
("class" "flex gap-2 items-center")
|
|
(a
|
|
("class" "flex items-center")
|
|
("href" "/api/v1/auth/user/find/{{ journal.owner }}")
|
|
(text "{{ components::avatar(username=journal.owner, selector_type=\"id\", size=\"18px\") }}"))
|
|
|
|
(a
|
|
("class" "flush")
|
|
("href" "{% if view_mode -%} /@{{ owner.username }}/{{ journal.title }} {%- else -%} /journals/{{ journal.id }}/0 {%- endif %}")
|
|
(b (text "{{ journal.title }}")))
|
|
|
|
(text "{% if note -%}")
|
|
(span (text "/"))
|
|
(b (text "{{ note.title }}"))
|
|
(text "{%- endif %}"))
|
|
|
|
(text "{% if user and user.id == journal.owner and (not note or note.title != \"journal.css\") -%}")
|
|
(div
|
|
("class" "pillmenu")
|
|
(a
|
|
("class" "{% if not view_mode -%}active{%- endif %}")
|
|
("href" "/journals/{{ journal.id }}/{% if note -%} {{ note.id }} {%- else -%} 0 {%- endif %}")
|
|
("data-turbo" "false")
|
|
(icon (text "pencil")))
|
|
(a
|
|
("class" "{% if view_mode -%}active{%- endif %}")
|
|
("href" "/@{{ user.username }}/{{ journal.title }}{% if note -%} /{{ note.title }} {%- endif %}")
|
|
(icon (text "eye"))))
|
|
(text "{%- endif %}"))
|
|
(text "{%- endif %}")
|
|
|
|
; we're going to put some help panes in here if something is 0
|
|
; ...people got confused on the chats page because they somehow couldn't figure out how to open the sidebar
|
|
(text "{% if selected_journal == 0 -%}")
|
|
; no journal selected
|
|
(div
|
|
("class" "card w-full flex flex-col gap-2")
|
|
(h3 (str (text "journals:label.welcome")))
|
|
(span (str (text "journals:label.select_a_journal")))
|
|
(span ("class" "mobile") (str (text "journals:label.mobile_click_my_journals")))
|
|
(button
|
|
("onclick" "create_journal()")
|
|
(icon (text "plus"))
|
|
(str (text "journals:action.create_journal"))))
|
|
(text "{% elif selected_note == 0 -%}")
|
|
; journal selected, but no note is selected
|
|
(text "{% if not view_mode -%}")
|
|
; we're the journal owner and we're not in view mode
|
|
(div
|
|
("class" "card w-full flex flex-col gap-2")
|
|
(h3 (text "{{ journal.title }}"))
|
|
(span (str (text "journals:label.select_a_note")))
|
|
(button
|
|
("onclick" "create_note()")
|
|
(icon (text "plus"))
|
|
(str (text "journals:action.create_note"))))
|
|
|
|
; we'll also let users edit the journal's settings here i guess
|
|
(details
|
|
("class" "w-full")
|
|
(summary
|
|
("class" "button lowered w-full justify-start")
|
|
(icon (text "settings"))
|
|
(str (text "general:action.manage")))
|
|
|
|
(div
|
|
("class" "card flex flex-col gap-2 lowered")
|
|
(div
|
|
("class" "card-nest")
|
|
(div
|
|
("class" "card small")
|
|
(b
|
|
(text "Privacy")))
|
|
(div
|
|
("class" "card")
|
|
(select
|
|
("onchange" "change_journal_privacy(event)")
|
|
(option
|
|
("value" "Private")
|
|
("selected" "{% if journal.privacy == 'Private' -%}true{% else %}false{%- endif %}")
|
|
(text "Private"))
|
|
(option
|
|
("value" "Public")
|
|
("selected" "{% if journal.privacy == 'Public' -%}true{% else %}false{%- endif %}")
|
|
(text "Public")))))
|
|
|
|
(div
|
|
("class" "card-nest")
|
|
(div
|
|
("class" "card small")
|
|
(label
|
|
("for" "title")
|
|
(b (str (text "communities:label.title")))))
|
|
|
|
(form
|
|
("class" "card flex flex-col gap-2")
|
|
("onsubmit" "change_journal_title(event)")
|
|
(div
|
|
("class" "flex flex-col gap-1")
|
|
(input
|
|
("type" "text")
|
|
("name" "title")
|
|
("id" "title")
|
|
("placeholder" "title")
|
|
("required" "")
|
|
("minlength" "2")))
|
|
(button
|
|
("class" "primary")
|
|
(text "{{ icon \"check\" }}")
|
|
(span
|
|
(text "{{ text \"general:action.save\" }}")))))))
|
|
(text "{% else %}")
|
|
; we're in view mode; just show journal listing and notes as journal homepage
|
|
(div
|
|
("class" "card flex flex-col gap-2")
|
|
(text "{{ components::journal_listing(journal=journal, notes=notes, selected_note=selected_note, selected_journal=selected_journal, view_mode=view_mode, owner=owner) }}"))
|
|
(text "{%- endif %}")
|
|
(text "{% else %}")
|
|
; journal AND note selected
|
|
(text "{% if not view_mode -%}")
|
|
; not view mode; show editor
|
|
; import codemirror
|
|
(script ("src" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.js") ("data-turbo-temporary" "true"))
|
|
(script ("src" "https://unpkg.com/codemirror@5.39.2/addon/display/placeholder.js") ("data-turbo-temporary" "true"))
|
|
(link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.css") ("data-turbo-temporary" "true"))
|
|
|
|
(text "{% if note.title == \"journal.css\" -%}")
|
|
; css editor
|
|
(script ("src" "https://unpkg.com/codemirror@5.39.2/addon/edit/closebrackets.js") ("data-turbo-temporary" "true"))
|
|
(script ("src" "https://unpkg.com/codemirror@5.39.2/addon/hint/css-hint.js") ("data-turbo-temporary" "true"))
|
|
(script ("src" "https://unpkg.com/codemirror@5.39.2/addon/hint/show-hint.js") ("data-turbo-temporary" "true"))
|
|
(script ("src" "https://unpkg.com/codemirror@5.39.2/addon/lint/css-lint.js") ("data-turbo-temporary" "true"))
|
|
(script ("src" "https://unpkg.com/codemirror@5.39.2/mode/css/css.js") ("data-turbo-temporary" "true"))
|
|
(link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/addon/hint/show-hint.css") ("data-turbo-temporary" "true"))
|
|
(link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/addon/lint/lint.css") ("data-turbo-temporary" "true"))
|
|
|
|
(style
|
|
(text ".CodeMirror {
|
|
font-family: monospace !important;
|
|
font-size: 16px;
|
|
border: solid 1px var(--color-super-lowered);
|
|
border-radius: var(--radius);
|
|
}
|
|
|
|
.CodeMirror-line {
|
|
padding-left: 5px !important;
|
|
}"))
|
|
(text "{% else %}")
|
|
; markdown editor
|
|
(script ("src" "https://unpkg.com/codemirror@5.39.2/mode/markdown/markdown.js") ("data-turbo-temporary" "true"))
|
|
(text "{%- endif %}")
|
|
|
|
; tab bar
|
|
(text "{% if note.title != \"journal.css\" -%}")
|
|
(div
|
|
("class" "pillmenu")
|
|
(a
|
|
("href" "#/editor")
|
|
("data-tab-button" "editor")
|
|
("data-turbo" "false")
|
|
("class" "active")
|
|
(str (text "journals:label.editor")))
|
|
|
|
(a
|
|
("href" "#/preview")
|
|
("data-tab-button" "preview")
|
|
("data-turbo" "false")
|
|
(str (text "journals:label.preview_pane"))))
|
|
(text "{%- endif %}")
|
|
|
|
; tabs
|
|
(div
|
|
("data-tab" "editor")
|
|
("class" "flex flex-col gap-2 card")
|
|
("style" "animation: fadein ease-in-out 1 0.5s forwards running")
|
|
("id" "editor_tab"))
|
|
|
|
(div
|
|
("data-tab" "preview")
|
|
("class" "flex flex-col gap-2 card hidden")
|
|
("style" "animation: fadein ease-in-out 1 0.5s forwards running")
|
|
("id" "preview_tab"))
|
|
|
|
(button
|
|
("onclick" "change_note_content('{{ note.id }}')")
|
|
(icon (text "check"))
|
|
(str (text "general:action.save")))
|
|
|
|
; init codemirror
|
|
(script ("id" "editor_content") ("type" "text/markdown") (text "{{ note.content|remove_script_tags|safe }}"))
|
|
(script
|
|
(text "setTimeout(() => {
|
|
if (!document.getElementById(\"preview_tab\").shadowRoot) {
|
|
document.getElementById(\"preview_tab\").attachShadow({ mode: \"open\" });
|
|
}
|
|
|
|
globalThis.editor = CodeMirror(document.getElementById(\"editor_tab\"), {
|
|
value: document.getElementById(\"editor_content\").innerHTML,
|
|
mode: \"{% if note.title == 'journal.css' -%} css {%- else -%} markdown {%- endif %}\",
|
|
lineWrapping: true,
|
|
lineNumbers: \"{{ note.title }}\" === \"journal.css\",
|
|
autoCloseBrackets: true,
|
|
autofocus: true,
|
|
viewportMargin: Number.POSITIVE_INFINITY,
|
|
inputStyle: \"contenteditable\",
|
|
highlightFormatting: false,
|
|
fencedCodeBlockHighlighting: false,
|
|
xml: false,
|
|
smartIndent: true,
|
|
indentUnit: 4,
|
|
placeholder: `# {{ note.title }}`,
|
|
extraKeys: {
|
|
Home: \"goLineLeft\",
|
|
End: \"goLineRight\",
|
|
Enter: (cm) => {
|
|
cm.replaceSelection(\"\\n\");
|
|
},
|
|
},
|
|
});
|
|
|
|
editor.on(\"keydown\", (cm, e) => {
|
|
if (e.key.length > 1) {
|
|
// ignore all keys that aren't a letter
|
|
return;
|
|
}
|
|
|
|
CodeMirror.showHint(cm, CodeMirror.hint.css);
|
|
});
|
|
|
|
document.querySelector(\"[data-tab-button=editor]\").addEventListener(\"click\", async (e) => {
|
|
e.preventDefault();
|
|
trigger(\"atto::hooks::tabs:switch\", [\"editor\"]);
|
|
});
|
|
|
|
document.querySelector(\"[data-tab-button=preview]\").addEventListener(\"click\", async (e) => {
|
|
e.preventDefault();
|
|
const res = await (
|
|
await fetch(\"/api/v1/notes/preview\", {
|
|
method: \"POST\",
|
|
headers: {
|
|
\"Content-Type\": \"application/json\",
|
|
},
|
|
body: JSON.stringify({
|
|
content: globalThis.editor.getValue(),
|
|
}),
|
|
})
|
|
).text();
|
|
|
|
const preview_token = window.crypto.randomUUID();
|
|
document.getElementById(\"preview_tab\").shadowRoot.innerHTML = `${res}<style>
|
|
@import url(\"/api/v1/journals/{{ journal.id }}/journal.css?v=preview-${preview_token}\");
|
|
</style>`;
|
|
trigger(\"atto::hooks::tabs:switch\", [\"preview\"]);
|
|
});
|
|
}, 150);"))
|
|
(text "{% else %}")
|
|
; we're just viewing this note
|
|
(div
|
|
("class" "flex flex-col gap-2 card")
|
|
(text "{{ note.content|markdown|safe }}"))
|
|
|
|
(div
|
|
("class" "flex w-full justify-between gap-2")
|
|
(span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}")))
|
|
(text "{% if user and user.id == owner.id -%}")
|
|
(button
|
|
("class" "small")
|
|
("onclick" "{% if journal.privacy == \"Public\" -%}
|
|
trigger('atto::copy_text', ['{{ config.host }}/@{{ owner.username }}/{{ journal.title }}/{{ note.title }}'])
|
|
{%- else -%}
|
|
prompt_make_public();
|
|
trigger('atto::copy_text', ['{{ config.host }}/@{{ owner.username }}/{{ journal.title }}/{{ note.title }}'])
|
|
{%- endif %}")
|
|
(icon (text "share"))
|
|
(str (text "general:label.share")))
|
|
|
|
(script
|
|
(text "globalThis.prompt_make_public = async () => {
|
|
if (
|
|
!(await trigger(\"atto::confirm\", [
|
|
\"Would you like to make this journal public? This is required for others to view this note.\",
|
|
]))
|
|
) {
|
|
return;
|
|
}
|
|
|
|
change_journal_privacy({ target: { selectedOptions: [{ value: \"Public\" }] }, preventDefault: () => {} });
|
|
}"))
|
|
(text "{%- endif %}"))
|
|
(text "{%- endif %}")
|
|
(text "{%- endif %}")))
|
|
(style
|
|
(text "nav::after {
|
|
width: 100%;
|
|
left: 0;
|
|
}"))
|
|
(script
|
|
(text "window.JOURNAL_PROPS = {
|
|
selected_journal: \"{{ selected_journal }}\",
|
|
selected_note: \"{{ selected_note }}\",
|
|
};
|
|
|
|
// journals/notes
|
|
globalThis.create_journal = async () => {
|
|
const title = await trigger(\"atto::prompt\", [\"Journal title:\"]);
|
|
|
|
if (!title) {
|
|
return;
|
|
}
|
|
|
|
fetch(\"/api/v1/journals\", {
|
|
method: \"POST\",
|
|
headers: {
|
|
\"Content-Type\": \"application/json\",
|
|
},
|
|
body: JSON.stringify({
|
|
title,
|
|
}),
|
|
})
|
|
.then((res) => res.json())
|
|
.then((res) => {
|
|
trigger(\"atto::toast\", [
|
|
res.ok ? \"success\" : \"error\",
|
|
res.message,
|
|
]);
|
|
|
|
if (res.ok) {
|
|
setTimeout(() => {
|
|
window.location.href = `/journals/${res.payload}/0`;
|
|
}, 100);
|
|
}
|
|
});
|
|
}
|
|
|
|
globalThis.create_note = async () => {
|
|
const title = await trigger(\"atto::prompt\", [\"Note title:\"]);
|
|
|
|
if (!title) {
|
|
return;
|
|
}
|
|
|
|
fetch(\"/api/v1/notes\", {
|
|
method: \"POST\",
|
|
headers: {
|
|
\"Content-Type\": \"application/json\",
|
|
},
|
|
body: JSON.stringify({
|
|
title,
|
|
content: title === \"journal.css\" ? `/* ${title} */\\n` : `# ${title}`,
|
|
journal: \"{{ selected_journal }}\",
|
|
}),
|
|
})
|
|
.then((res) => res.json())
|
|
.then((res) => {
|
|
trigger(\"atto::toast\", [
|
|
res.ok ? \"success\" : \"error\",
|
|
res.message,
|
|
]);
|
|
|
|
if (res.ok) {
|
|
setTimeout(() => {
|
|
window.location.href = `/journals/{{ selected_journal }}/${res.payload}`;
|
|
}, 100);
|
|
}
|
|
});
|
|
}
|
|
|
|
globalThis.delete_journal = async (id) => {
|
|
if (
|
|
!(await trigger(\"atto::confirm\", [
|
|
\"Are you sure you would like to do this?\",
|
|
]))
|
|
) {
|
|
return;
|
|
}
|
|
|
|
fetch(`/api/v1/journals/${id}`, {
|
|
method: \"DELETE\",
|
|
})
|
|
.then((res) => res.json())
|
|
.then((res) => {
|
|
trigger(\"atto::toast\", [
|
|
res.ok ? \"success\" : \"error\",
|
|
res.message,
|
|
]);
|
|
|
|
if (res.ok) {
|
|
setTimeout(() => {
|
|
window.location.href = \"/journals\";
|
|
}, 100);
|
|
}
|
|
});
|
|
}
|
|
|
|
globalThis.delete_note = async (id) => {
|
|
if (
|
|
!(await trigger(\"atto::confirm\", [
|
|
\"Are you sure you would like to do this?\",
|
|
]))
|
|
) {
|
|
return;
|
|
}
|
|
|
|
fetch(`/api/v1/notes/${id}`, {
|
|
method: \"DELETE\",
|
|
})
|
|
.then((res) => res.json())
|
|
.then((res) => {
|
|
trigger(\"atto::toast\", [
|
|
res.ok ? \"success\" : \"error\",
|
|
res.message,
|
|
]);
|
|
|
|
if (res.ok) {
|
|
setTimeout(() => {
|
|
window.location.href = \"/journals/{{ selected_journal }}/0\";
|
|
}, 100);
|
|
}
|
|
});
|
|
}
|
|
|
|
globalThis.change_journal_title = async (e) => {
|
|
e.preventDefault();
|
|
fetch(\"/api/v1/journals/{{ selected_journal }}/title\", {
|
|
method: \"POST\",
|
|
headers: {
|
|
\"Content-Type\": \"application/json\",
|
|
},
|
|
body: JSON.stringify({
|
|
title: e.target.title.value,
|
|
}),
|
|
})
|
|
.then((res) => res.json())
|
|
.then((res) => {
|
|
trigger(\"atto::toast\", [
|
|
res.ok ? \"success\" : \"error\",
|
|
res.message,
|
|
]);
|
|
|
|
if (res.ok) {
|
|
e.reset();
|
|
}
|
|
});
|
|
}
|
|
|
|
globalThis.change_journal_privacy = async (e) => {
|
|
e.preventDefault();
|
|
const selected = e.target.selectedOptions[0];
|
|
fetch(\"/api/v1/journals/{% if journal -%} {{ journal.id }} {%- else -%} {{ selected_journal }} {%- endif %}/privacy\", {
|
|
method: \"POST\",
|
|
headers: {
|
|
\"Content-Type\": \"application/json\",
|
|
},
|
|
body: JSON.stringify({
|
|
privacy: selected.value,
|
|
}),
|
|
})
|
|
.then((res) => res.json())
|
|
.then((res) => {
|
|
trigger(\"atto::toast\", [
|
|
res.ok ? \"success\" : \"error\",
|
|
res.message,
|
|
]);
|
|
});
|
|
}
|
|
|
|
globalThis.change_note_title = async (id) => {
|
|
const title = await trigger(\"atto::prompt\", [\"New note title:\"]);
|
|
|
|
if (!title) {
|
|
return;
|
|
}
|
|
|
|
fetch(`/api/v1/notes/${id}/title`, {
|
|
method: \"POST\",
|
|
headers: {
|
|
\"Content-Type\": \"application/json\",
|
|
},
|
|
body: JSON.stringify({
|
|
title,
|
|
}),
|
|
})
|
|
.then((res) => res.json())
|
|
.then((res) => {
|
|
trigger(\"atto::toast\", [
|
|
res.ok ? \"success\" : \"error\",
|
|
res.message,
|
|
]);
|
|
|
|
if (res.ok) {
|
|
e.reset();
|
|
}
|
|
});
|
|
}
|
|
|
|
globalThis.change_note_content = async (id) => {
|
|
fetch(`/api/v1/notes/${id}/content`, {
|
|
method: \"POST\",
|
|
headers: {
|
|
\"Content-Type\": \"application/json\",
|
|
},
|
|
body: JSON.stringify({
|
|
content: globalThis.editor.getValue(),
|
|
}),
|
|
})
|
|
.then((res) => res.json())
|
|
.then((res) => {
|
|
trigger(\"atto::toast\", [
|
|
res.ok ? \"success\" : \"error\",
|
|
res.message,
|
|
]);
|
|
});
|
|
}
|
|
|
|
// sidebars
|
|
window.SIDEBARS_OPEN = false;
|
|
if (new URLSearchParams(window.location.search).get(\"nav\") === \"true\") {
|
|
window.SIDEBARS_OPEN = true;
|
|
}
|
|
|
|
if (
|
|
window.SIDEBARS_OPEN &&
|
|
!document.body.classList.contains(\"sidebars_shown\")
|
|
) {
|
|
toggle_sidebars();
|
|
window.SIDEBARS_OPEN = true;
|
|
}
|
|
|
|
for (const anchor of document.querySelectorAll(\"[data-turbo=false]\")) {
|
|
anchor.href += `?nav=${window.SIDEBARS_OPEN}`;
|
|
}
|
|
|
|
function toggle_sidebars() {
|
|
window.SIDEBARS_OPEN = !window.SIDEBARS_OPEN;
|
|
|
|
for (const anchor of document.querySelectorAll(
|
|
\"[data-turbo=false]\",
|
|
)) {
|
|
anchor.href = anchor.href.replace(
|
|
`?nav=${!window.SIDEBARS_OPEN}`,
|
|
`?nav=${window.SIDEBARS_OPEN}`,
|
|
);
|
|
}
|
|
|
|
const notes_list = document.getElementById(\"notes_list\");
|
|
|
|
if (document.body.classList.contains(\"sidebars_shown\")) {
|
|
// hide
|
|
document.body.classList.remove(\"sidebars_shown\");
|
|
notes_list.style.left = \"-200%\";
|
|
} else {
|
|
// show
|
|
document.body.classList.add(\"sidebars_shown\");
|
|
notes_list.style.left = \"0\";
|
|
}
|
|
}")))
|
|
(text "{% endblock %}")
|