diff --git a/.gitignore b/.gitignore
index 95ab1bd..edf4096 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
*~
\#*\#
.\#*
+examples/*/*/public
# Byte-compiled / optimized / DLL files
__pycache__/
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 0000000..1bf3cdb
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,17 @@
+# Examples
+
+This directory contains runnable `httk-web` examples.
+
+- `modern/`: recommended v2-first examples (Jinja2 templates, modern metadata).
+- `legacy/`: compatibility-mode examples that use deprecated legacy syntax/features.
+
+Modern includes:
+- `minimal/`
+- `rst_site/` (modern replacement for `rst_templator`)
+- `blog/` (modern replacement for legacy blog example)
+- `search_app/` (modern replacement for legacy search example)
+
+Legacy now includes migrated variants of the original `6_website` examples,
+including blog/search-style examples.
+
+Legacy examples are kept to validate migration behavior and should not be used as the default style for new projects.
diff --git a/examples/legacy/README.md b/examples/legacy/README.md
new file mode 100644
index 0000000..9624a04
--- /dev/null
+++ b/examples/legacy/README.md
@@ -0,0 +1,14 @@
+# Legacy Examples
+
+These examples intentionally use deprecated legacy features and require compatibility mode.
+
+- `static_simple/`
+- `hello_world_app/`
+- `rst_templator/`
+- `blog/`
+- `search_app/`
+
+Use the provided `serve_legacy_*.py` and `publish_legacy_*.py` scripts.
+
+`search_app/` is included as a migrated legacy example, but it depends on legacy
+`httk` database modules that are not part of `httk-web`.
diff --git a/examples/legacy/blog/publish_legacy_blog.py b/examples/legacy/blog/publish_legacy_blog.py
new file mode 100644
index 0000000..c654cfc
--- /dev/null
+++ b/examples/legacy/blog/publish_legacy_blog.py
@@ -0,0 +1,6 @@
+from pathlib import Path
+
+from httk.web import publish
+
+ROOT = Path(__file__).parent
+publish(ROOT / "src", ROOT / "public", "http://127.0.0.1/", compatibility_mode=True)
diff --git a/examples/legacy/blog/serve_legacy_blog.py b/examples/legacy/blog/serve_legacy_blog.py
new file mode 100644
index 0000000..e3f984f
--- /dev/null
+++ b/examples/legacy/blog/serve_legacy_blog.py
@@ -0,0 +1,6 @@
+from pathlib import Path
+
+from httk.web import serve
+
+ROOT = Path(__file__).parent
+serve(ROOT / "src", port=8080, compatibility_mode=True, config_name="config_dynamic")
diff --git a/examples/legacy/blog/src/config.httkweb b/examples/legacy/blog/src/config.httkweb
new file mode 100644
index 0000000..7a7c446
--- /dev/null
+++ b/examples/legacy/blog/src/config.httkweb
@@ -0,0 +1,4 @@
+---
+maintitle: My formidable website
+menuitems-list: index, blog, contact
+---
diff --git a/examples/legacy/blog/src/config_dynamic.httkweb b/examples/legacy/blog/src/config_dynamic.httkweb
new file mode 100644
index 0000000..6ace46b
--- /dev/null
+++ b/examples/legacy/blog/src/config_dynamic.httkweb
@@ -0,0 +1,5 @@
+---
+maintitle: My formidable website
+menuitems-list: index, blog, contact
+urls_without_ext: true
+---
diff --git a/examples/legacy/blog/src/content/404.md b/examples/legacy/blog/src/content/404.md
new file mode 100644
index 0000000..313b364
--- /dev/null
+++ b/examples/legacy/blog/src/content/404.md
@@ -0,0 +1,6 @@
+---
+Title: My Website - Missing page
+Name: "404"
+Template: "404"
+---
+The page could not be found.
diff --git a/examples/legacy/blog/src/content/blog.md b/examples/legacy/blog/src/content/blog.md
new file mode 100644
index 0000000..3bd0295
--- /dev/null
+++ b/examples/legacy/blog/src/content/blog.md
@@ -0,0 +1,12 @@
+--------
+Title: My Website - Blog posts
+Name: Blog
+Template: blog
+Base_template: base_default
+--------
+
+Blog
+====
+
+Below is a list of all blog posts available here.
+
diff --git a/examples/legacy/blog/src/content/blogposts/another_post.md b/examples/legacy/blog/src/content/blogposts/another_post.md
new file mode 100644
index 0000000..f8acc9e
--- /dev/null
+++ b/examples/legacy/blog/src/content/blogposts/another_post.md
@@ -0,0 +1,13 @@
+--------
+Title: My Website - Another post
+Author: Rickard Armiento
+Date: 2023-10-04
+Name: Another post
+Template: blogpost
+Base_template: base_default
+--------
+
+Another post
+============
+
+This is another post
diff --git a/examples/legacy/blog/src/content/blogposts/hello-everyone.md b/examples/legacy/blog/src/content/blogposts/hello-everyone.md
new file mode 100644
index 0000000..a4608d5
--- /dev/null
+++ b/examples/legacy/blog/src/content/blogposts/hello-everyone.md
@@ -0,0 +1,13 @@
+--------
+Title: My Website - Hello Everyone
+Author: Rickard Armiento
+Date: 2023-10-03
+Name: Hello Everyone
+Template: blogpost
+Base_template: base_default
+--------
+
+Hello everyone
+==============
+
+Hello everyone, this is the first blog post.
diff --git a/examples/legacy/blog/src/content/blogposts/third_post.md b/examples/legacy/blog/src/content/blogposts/third_post.md
new file mode 100644
index 0000000..7b424b5
--- /dev/null
+++ b/examples/legacy/blog/src/content/blogposts/third_post.md
@@ -0,0 +1,13 @@
+--------
+Title: My Website - Third post
+Author: Rickard Armiento
+Date: 2023-10-05
+Name: Third post
+Template: blogpost
+Base_template: base_default
+--------
+
+Third post
+==========
+
+This is the third post
diff --git a/examples/legacy/blog/src/content/contact.md b/examples/legacy/blog/src/content/contact.md
new file mode 100644
index 0000000..74eef13
--- /dev/null
+++ b/examples/legacy/blog/src/content/contact.md
@@ -0,0 +1,12 @@
+-------
+Title: My Website - Contact
+Name: Contact
+Template: default
+Base_template: base_default
+------
+
+Contact
+=======
+
+Contact me here: someone@example.com
+
diff --git a/examples/legacy/blog/src/content/index.md b/examples/legacy/blog/src/content/index.md
new file mode 100644
index 0000000..702570e
--- /dev/null
+++ b/examples/legacy/blog/src/content/index.md
@@ -0,0 +1,33 @@
+-----------
+Title: My Website
+Name: Main
+Date: 2023-09-27
+Version: 1
+Author: Rickard Armiento
+Template: default
+Base_template: base_default
+-----------
+
+My website
+==========
+
+This is my formidable website. It contains the following sections:
+
+* [Blog](blog)
+* [Contact](contact)
+
+Subheading
+----------
+
+Here is some nice math:
+
+\(\int (x+y) dx\)
+
+And a code segment:
+
+``` python
+x = 1
+if x == 1:
+ # indented four spaces
+ print("x is 1.")
+```
diff --git a/examples/legacy/blog/src/functions/init.py b/examples/legacy/blog/src/functions/init.py
new file mode 100644
index 0000000..3fb9ea3
--- /dev/null
+++ b/examples/legacy/blog/src/functions/init.py
@@ -0,0 +1,39 @@
+import datetime
+import os
+from pathlib import Path
+
+
+def execute(global_data, **kargs):
+ prefix = Path(__file__).resolve().parents[1] / "content"
+ filterlist = [".md", ".rst", ".html"]
+ path = "blogposts"
+
+ # Chicken-or-egg problem of having to partially render blog posts to sort them, but their renders refer to the other blogposts
+ global_data['blogposts'] = []
+ global_data['blogposts_latest'] = []
+
+ def listdirsorted(path):
+ return [
+ x[0]
+ for x in sorted(
+ [
+ (
+ fn,
+ datetime.datetime.strptime(
+ str(global_data['pages'](os.path.join(path, fn), 'date')), "%Y-%m-%d"
+ ),
+ )
+ for fn in os.listdir(prefix / path)
+ ],
+ key=lambda x: x[1],
+ reverse=True,
+ )
+ ]
+
+ global_data['blogposts'] = [
+ os.path.join(path, f)
+ for f in listdirsorted(path)
+ if os.path.isfile(prefix / path / f) and any([f.endswith(t) for t in filterlist])
+ ]
+
+ global_data['blogposts_latest'] = global_data['blogposts'][:5]
diff --git a/examples/legacy/blog/src/static/favicon.ico b/examples/legacy/blog/src/static/favicon.ico
new file mode 100644
index 0000000..89273a9
Binary files /dev/null and b/examples/legacy/blog/src/static/favicon.ico differ
diff --git a/examples/dummy.py b/examples/legacy/blog/src/static/img/.gitignore
similarity index 100%
rename from examples/dummy.py
rename to examples/legacy/blog/src/static/img/.gitignore
diff --git a/examples/legacy/blog/src/static/js/paginate.js b/examples/legacy/blog/src/static/js/paginate.js
new file mode 100644
index 0000000..fc77e63
--- /dev/null
+++ b/examples/legacy/blog/src/static/js/paginate.js
@@ -0,0 +1,104 @@
+// Based on https://stackoverflow.com/a/72308579 by rootShiv
+
+function setup_pagination(table) {
+ var filter = $("#filter").val().toLowerCase();
+ var lastPage = 1;
+ $("#maxRows").on("change", function (evt) {
+ lastPage = 1;
+ $(".pagination").find("li").slice(1, -1).remove();
+ var trnum = 0;
+ var maxRows = parseInt($(this).val());
+ $(table + " tr").each(function (el) {
+ a = $(this);
+ txtValue = a.text().toLowerCase();
+ if (!filter || txtValue.indexOf(filter) > -1) {
+ trnum++;
+ }
+ });
+ var pagenum = Math.ceil(trnum / maxRows);
+ for (var i = 1; i <= pagenum; i++) {
+ $(".pagination #insert-pages-here")
+ .before(
+ '
'+(i)+
+ '(current) ')
+ .show();
+ }
+ $('.pagination [data-page="1"]').addClass("active");
+ $(".pagination li").on("click", function (evt) {
+ evt.stopImmediatePropagation();
+ evt.preventDefault();
+ var pageNum = $(this).attr("data-page");
+ activate_page(pageNum, table, filter);
+ });
+ pagination_limits();
+ }).val(10).change();
+ activate_page(1, table, filter);
+}
+
+function activate_page(pageNum, table, filter) {
+ var maxRows = parseInt($("#maxRows").val());
+ if (pageNum == "prev") {
+ if (lastPage == 1) {
+ return;
+ }
+ pageNum = --lastPage;
+ }
+ if (pageNum == "next") {
+ if (lastPage == $(".pagination li").length - 2) {
+ return;
+ }
+ pageNum = ++lastPage;
+ }
+ lastPage = pageNum;
+ var trIndex = 0;
+ $(".pagination li").removeClass("active");
+ $('.pagination [data-page="' + lastPage + '"]').addClass("active");
+ pagination_limits();
+ $(table + " tr").each(function () {
+
+ a = $(this);
+ txtValue = a.text().toLowerCase();
+ if (!filter || txtValue.indexOf(filter) > -1) {
+ trIndex++;
+ if (trIndex > maxRows * pageNum || trIndex <= maxRows * pageNum - maxRows) {
+ $(this).hide();
+ } else {
+ $(this).show();
+ }
+ } else {
+ $(this).hide();
+ }
+ });
+}
+
+function pagination_limits() {
+ if ($(".pagination li").length > 7) {
+ if ($(".pagination li.active").attr("data-page") <= 3) {
+ $(".pagination li:gt(5)").hide();
+ $(".pagination li:lt(5)").show();
+ $('.pagination [data-page="next"]').show();
+ }
+ if ($(".pagination li.active").attr("data-page") > 3) {
+ $(".pagination li:gt(0)").hide();
+ $('.pagination [data-page="next"]').show();
+ for (
+ let i = parseInt($(".pagination li.active").attr("data-page")) - 2;
+ i <= parseInt($(".pagination li.active").attr("data-page")) + 2;
+ i++
+ ) {
+ $('.pagination [data-page="' + i + '"]').show();
+ }
+ }
+ }
+}
+
+$('#filterform').on('reset', function(e)
+{
+ setTimeout(function() { setup_pagination("#table-id"); });
+});
+
+$('#filterform').on('submit',function(event) {
+ event.preventDefault();
+});
+
+setup_pagination("#table-id");
diff --git a/examples/legacy/blog/src/static/resources/css/httk.css b/examples/legacy/blog/src/static/resources/css/httk.css
new file mode 100644
index 0000000..a3dd0e3
--- /dev/null
+++ b/examples/legacy/blog/src/static/resources/css/httk.css
@@ -0,0 +1,342 @@
+body {
+ padding-top: 0rem;
+}
+.navbar-custom {
+}
+.navbar-custom .navbar-nav .nav-link {
+ color: grey;
+}
+.navbar-custom .nav-link.active,
+.navbar-custom .nav-link:hover {
+ color: black;
+}
+.border-3 {
+ border-width:3px !important;
+}
+.border-orange {
+ border-color: #f80 !important;
+}
+.border-darkgrey {
+ border-color: #888 !important;
+}
+.orange {
+ color: #f80 !important;
+}
+.dropdown .dropdown-menu .dropdown-item:active, .dropdown .dropdown-menu .dropdown-item:hover{background-color: white; color: black;}
+.dropdown .dropdown-menu .dropdown-item {background-color: white; color: grey;}
+
+h1, h2, h3 {
+ padding-top: 1em;
+}
+
+.publist a {
+ font-style: italic;
+}
+
+* {
+ box-sizing: border-box; }
+
+body {
+ padding: 0;
+ margin: 0;
+ font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
+ font-size: 16px;
+ line-height: 1.5;
+ color: #606c71; }
+
+a {
+ color: #1e6bb8;
+ text-decoration: none; }
+ a:hover {
+ text-decoration: underline; }
+
+.page-header .btn {
+ display: inline-block;
+ margin-bottom: 1rem;
+ color: rgba(255, 255, 255, 0.7);
+ background-color: rgba(255, 255, 255, 0.08);
+ border-color: rgba(255, 255, 255, 0.2);
+ border-style: solid;
+ border-width: 1px;
+ border-radius: 0.3rem;
+ transition: color 0.2s, background-color 0.2s, border-color 0.2s; }
+.page-header .btn:hover {
+ color: rgba(255, 255, 255, 0.8);
+ text-decoration: none;
+ background-color: rgba(255, 255, 255, 0.2);
+ border-color: rgba(255, 255, 255, 0.3); }
+.page-header .btn + .btn {
+ margin-left: 1rem; }
+ @media screen and (min-width: 64em) {
+ .page-header .btn {
+ padding: 0.75rem 1rem; } }
+ @media screen and (min-width: 42em) and (max-width: 64em) {
+ .page-header .btn {
+ padding: 0.6rem 0.9rem;
+ font-size: 0.9rem; } }
+ @media screen and (max-width: 42em) {
+ .page-header .btn {
+ display: block;
+ width: 100%;
+ padding: 0.75rem;
+ font-size: 0.9rem; }
+ .btn + .btn {
+ margin-top: 1rem;
+ margin-left: 0; } }
+
+.page-header {
+ color: #fff;
+ text-align: center;
+ #background-color: #155799;
+ background-color: #114a85;
+ #background-image: linear-gradient(120deg, #80b8df, #155799);
+ #background-image: linear-gradient(120deg, #80b8df, #103481);
+ #background-image: linear-gradient(120deg, #103481, #80b8df);
+ #background-image: linear-gradient(120deg, #A03481, #FA380f);
+ background-image: linear-gradient(120deg, #703481, #6A0000);
+ }
+ .page-header img {
+ height:96px;
+ }
+ @media screen and (min-width: 64em) {
+ .page-header {
+ padding: 3rem 4rem; } }
+ @media screen and (min-width: 42em) and (max-width: 64em) {
+ .page-header {
+ padding: 3rem 4rem; } }
+ @media screen and (max-width: 42em) {
+ .page-header {
+ padding: 2rem 1rem; } }
+
+.project-name {
+ margin-top: 0;
+ margin-bottom: 0.1rem; }
+ @media screen and (min-width: 64em) {
+ .project-name {
+ font-size: 3.25rem; } }
+ @media screen and (min-width: 42em) and (max-width: 64em) {
+ .project-name {
+ font-size: 2.25rem; } }
+ @media screen and (max-width: 42em) {
+ .project-name {
+ font-size: 1.75rem; } }
+
+.project-tagline {
+ margin-bottom: 2rem;
+ font-weight: normal;
+ opacity: 0.7; }
+ @media screen and (min-width: 64em) {
+ .project-tagline {
+ font-size: 1.25rem; } }
+ @media screen and (min-width: 42em) and (max-width: 64em) {
+ .project-tagline {
+ font-size: 1.15rem; } }
+ @media screen and (max-width: 42em) {
+ .project-tagline {
+ font-size: 1rem; } }
+
+.main-content {
+ word-wrap: break-word; }
+ .main-content :first-child {
+ margin-top: 0; }
+ @media screen and (min-width: 64em) {
+ .main-content {
+ max-width: 64rem;
+ padding: 2rem 6rem;
+ margin: 0 auto;
+ font-size: 1.1rem; } }
+ @media screen and (min-width: 42em) and (max-width: 64em) {
+ .main-content {
+ padding: 2rem 4rem;
+ font-size: 1.1rem; } }
+ @media screen and (max-width: 42em) {
+ .main-content {
+ padding: 2rem 1rem;
+ font-size: 1rem; } }
+ .main-content img {
+ max-width: 100%; }
+ .main-content h1,
+ .main-content h2,
+ .main-content h3,
+ .main-content h4,
+ .main-content h5,
+ .main-content h6 {
+ margin-top: 2rem;
+ margin-bottom: 1rem;
+ font-weight: normal;
+ color: #2255AA; }
+ .main-content p {
+ margin-bottom: 1em; }
+ .main-content code {
+ padding: 2px 4px;
+ font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
+ font-size: 0.9rem;
+ color: #567482;
+ background-color: #f3f6fa;
+ border-radius: 0.3rem; }
+ .main-content pre {
+ padding: 0.8rem;
+ margin-top: 0;
+ margin-bottom: 1rem;
+ font: 1rem Consolas, "Liberation Mono", Menlo, Courier, monospace;
+ color: #567482;
+ word-wrap: normal;
+ background-color: #f3f6fa;
+ border: solid 1px #dce6f0;
+ border-radius: 0.3rem; }
+ .main-content pre > code {
+ padding: 0;
+ margin: 0;
+ font-size: 0.9rem;
+ color: #567482;
+ word-break: normal;
+ white-space: pre;
+ background: transparent;
+ border: 0; }
+ .main-content .highlight {
+ margin-bottom: 1rem; }
+ .main-content .highlight pre {
+ margin-bottom: 0;
+ word-break: normal; }
+ .main-content .highlight pre,
+ .main-content pre {
+ padding: 0.8rem;
+ overflow: auto;
+ font-size: 0.9rem;
+ line-height: 1.45;
+ border-radius: 0.3rem;
+ -webkit-overflow-scrolling: touch; }
+ .main-content pre code,
+ .main-content pre tt {
+ display: inline;
+ max-width: initial;
+ padding: 0;
+ margin: 0;
+ overflow: initial;
+ line-height: inherit;
+ word-wrap: normal;
+ background-color: transparent;
+ border: 0; }
+ .main-content pre code:before, .main-content pre code:after,
+ .main-content pre tt:before,
+ .main-content pre tt:after {
+ content: normal; }
+ .main-content ul,
+ .main-content ol {
+ margin-top: 0; }
+ .main-content blockquote {
+ padding: 0 1rem;
+ margin-left: 0;
+ color: #819198;
+ border-left: 0.3rem solid #dce6f0; }
+ .main-content blockquote > :first-child {
+ margin-top: 0; }
+ .main-content blockquote > :last-child {
+ margin-bottom: 0; }
+ .main-content table {
+ display: block;
+ width: 100%;
+ overflow: auto;
+ word-break: normal;
+ word-break: keep-all;
+ -webkit-overflow-scrolling: touch; }
+ .main-content table th {
+ font-weight: bold; }
+ .main-content table th,
+ .main-content table td {
+ padding: 0.5rem 1rem;
+ border: 1px solid #e9ebec; }
+ .main-content dl {
+ padding: 0; }
+ .main-content dl dt {
+ padding: 0;
+ margin-top: 1rem;
+ font-size: 1rem;
+ font-weight: bold; }
+ .main-content dl dd {
+ padding: 0;
+ margin-bottom: 1rem; }
+ .main-content hr {
+ height: 2px;
+ padding: 0;
+ margin: 1rem 0;
+ background-color: #eff0f1;
+ border: 0; }
+
+.site-footer {
+ padding-top: 2rem;
+ margin-top: 2rem;
+ border-top: solid 1px #eff0f1; }
+ @media screen and (min-width: 64em) {
+ .site-footer {
+ font-size: 1rem; } }
+ @media screen and (min-width: 42em) and (max-width: 64em) {
+ .site-footer {
+ font-size: 1rem; } }
+ @media screen and (max-width: 42em) {
+ .site-footer {
+ font-size: 0.9rem; } }
+
+.site-footer-owner {
+ display: block;
+ font-weight: bold; }
+
+.site-footer-credits {
+ color: #819198; }
+
+.project-name {
+ font-weight: bold;
+ display: inline;
+}
+.top-left-dropdown {
+ background-color: #eff0f1;
+ border-color: #dfe0e1;
+ color: grey;
+}
+.dropdown-toggle {
+ background-color: #ffffff;
+ border-color: #dfe0e1;
+ color: grey;
+}
+@media all and (min-width: 992px) {
+ .navbar .nav-item .dropdown-menu{ display: none; }
+ .navbar .nav-item:hover .dropdown-menu{ display: block; }
+ .navbar .nav-item .dropdown-menu{ margin-top:0; }
+}
+blockquote {
+ margin: 1em 0em 1em 5em;
+ color: grey;
+}
+
+.main-content img {
+ display: block;
+ margin: 2em;
+ margin-left: auto;
+ margin-right: auto;
+ width: 100%;
+}
+
+.main-content img.align-left {
+ float: left;
+ margin-top: 0.5ex;
+ margin-bottom: 0.5ex;
+ margin-right: 2em;
+ margin-left: -5em;
+}
+
+.main-content img.align-right {
+ float: right;
+ margin-top: 0.5ex;
+ margin-bottom: 0.5ex;
+ margin-right: -5em;
+ margin-left: 2em;
+}
+
+.main-content img.oversize {
+ max-width: none;
+ width: 135%;
+ margin-top: 0.5ex;
+ margin-bottom: 0.5ex;
+ margin-left: -5em;
+ margin-right: -5em;
+}
diff --git a/examples/legacy/blog/src/templates/404.httkweb.html b/examples/legacy/blog/src/templates/404.httkweb.html
new file mode 100644
index 0000000..912ade8
--- /dev/null
+++ b/examples/legacy/blog/src/templates/404.httkweb.html
@@ -0,0 +1,2 @@
+{content}
+{error_404_reason:unquoted:if:Reason given from webserver: {error_404_reason}}
diff --git a/examples/legacy/blog/src/templates/base_default.httkweb.html b/examples/legacy/blog/src/templates/base_default.httkweb.html
new file mode 100644
index 0000000..a8f839b
--- /dev/null
+++ b/examples/legacy/blog/src/templates/base_default.httkweb.html
@@ -0,0 +1,43 @@
+
+
+
+
+ {page.title}
+
+
+
+
+
+
+
+
+
+
+ {maintitle}
+
+
+
+
+{content}
+
+
+
+
+
+
+
+
diff --git a/examples/legacy/blog/src/templates/blog.httkweb.html b/examples/legacy/blog/src/templates/blog.httkweb.html
new file mode 100644
index 0000000..c9e5c08
--- /dev/null
+++ b/examples/legacy/blog/src/templates/blog.httkweb.html
@@ -0,0 +1,46 @@
+
+
+
+
+
+ {content}
+
+
+
+
+
+ 10
+
+
+
+
+
+
+
diff --git a/examples/legacy/blog/src/templates/blogpost.httkweb.html b/examples/legacy/blog/src/templates/blogpost.httkweb.html
new file mode 100644
index 0000000..f41c654
--- /dev/null
+++ b/examples/legacy/blog/src/templates/blogpost.httkweb.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+ ← blog / {page.name}
+
+ {content}
+
+
+
+
diff --git a/examples/legacy/blog/src/templates/default.httkweb.html b/examples/legacy/blog/src/templates/default.httkweb.html
new file mode 100644
index 0000000..8b13b37
--- /dev/null
+++ b/examples/legacy/blog/src/templates/default.httkweb.html
@@ -0,0 +1,23 @@
+
diff --git a/examples/legacy/hello_world_app/publish_legacy_hello_world.py b/examples/legacy/hello_world_app/publish_legacy_hello_world.py
new file mode 100755
index 0000000..5b6218d
--- /dev/null
+++ b/examples/legacy/hello_world_app/publish_legacy_hello_world.py
@@ -0,0 +1,6 @@
+from pathlib import Path
+
+from httk.web import publish
+
+ROOT = Path(__file__).parent
+publish(ROOT / "src", ROOT / "public", "./", compatibility_mode=True)
diff --git a/examples/legacy/hello_world_app/serve_legacy_hello_world.py b/examples/legacy/hello_world_app/serve_legacy_hello_world.py
new file mode 100755
index 0000000..a59305e
--- /dev/null
+++ b/examples/legacy/hello_world_app/serve_legacy_hello_world.py
@@ -0,0 +1,6 @@
+from pathlib import Path
+
+from httk.web import serve
+
+ROOT = Path(__file__).parent
+serve(ROOT / "src", port=8080, compatibility_mode=True)
diff --git a/examples/legacy/hello_world_app/src/config.httkweb b/examples/legacy/hello_world_app/src/config.httkweb
new file mode 100644
index 0000000..519c199
--- /dev/null
+++ b/examples/legacy/hello_world_app/src/config.httkweb
@@ -0,0 +1,3 @@
+---
+website_name: Httk hello world demo
+---
diff --git a/examples/legacy/hello_world_app/src/content/404.httkweb b/examples/legacy/hello_world_app/src/content/404.httkweb
new file mode 100644
index 0000000..eb5118f
--- /dev/null
+++ b/examples/legacy/hello_world_app/src/content/404.httkweb
@@ -0,0 +1,10 @@
+---
+Title: 404
+Date: 2018-08-16
+Version: 1
+Author: Rickard Armiento
+Template: default
+---
+The page could not be found.
+
+
diff --git a/examples/legacy/hello_world_app/src/content/index.httkweb b/examples/legacy/hello_world_app/src/content/index.httkweb
new file mode 100644
index 0000000..c4950ca
--- /dev/null
+++ b/examples/legacy/hello_world_app/src/content/index.httkweb
@@ -0,0 +1,13 @@
+---
+Title: Search
+Date: 2018-08-16
+Version: 1
+Author: Rickard Armiento
+hello_world-function: hello_world::hello_world_result
+---
+
+============================================
+This example runs a function that greets you
+============================================
+
+The output is shown below.
diff --git a/examples/legacy/hello_world_app/src/functions/hello_world.py b/examples/legacy/hello_world_app/src/functions/hello_world.py
new file mode 100644
index 0000000..16f7e7a
--- /dev/null
+++ b/examples/legacy/hello_world_app/src/functions/hello_world.py
@@ -0,0 +1,3 @@
+def execute(global_data, **kargs):
+ print("Debug: running hello_world function.")
+ return "Hello " + global_data['greeter']
diff --git a/examples/legacy/hello_world_app/src/functions/init.py b/examples/legacy/hello_world_app/src/functions/init.py
new file mode 100644
index 0000000..0a65fbe
--- /dev/null
+++ b/examples/legacy/hello_world_app/src/functions/init.py
@@ -0,0 +1,3 @@
+def execute(global_data, **kargs):
+ print("Debug: running website initialization function.")
+ global_data['greeter'] = "world"
diff --git a/examples/legacy/hello_world_app/src/static/img/Example.png b/examples/legacy/hello_world_app/src/static/img/Example.png
new file mode 100644
index 0000000..aa61359
Binary files /dev/null and b/examples/legacy/hello_world_app/src/static/img/Example.png differ
diff --git a/examples/legacy/hello_world_app/src/static/resources/css/httkdemo.css b/examples/legacy/hello_world_app/src/static/resources/css/httkdemo.css
new file mode 100644
index 0000000..262c8e8
--- /dev/null
+++ b/examples/legacy/hello_world_app/src/static/resources/css/httkdemo.css
@@ -0,0 +1,40 @@
+body {
+ padding-top: 0rem;
+}
+.navbar-custom {
+}
+.navbar-custom .navbar-nav .nav-link {
+ color: rgba(0,0,0,.5);
+}
+.navbar-custom .nav-link.active,
+.navbar-custom .nav-link:hover {
+ color: #000000;
+}
+.border-3 {
+ border-width:3px !important;
+}
+.border-orange {
+ border-color: #f80 !important;
+}
+.border-darkgrey {
+ border-color: #888 !important;
+}
+.orange {
+ color: #f80 !important;
+}
+.dropdown .dropdown-menu .dropdown-item:active, .dropdown .dropdown-menu .dropdown-item:hover{background-color: white; color: black;}
+.dropdown .dropdown-menu .dropdown-item {background-color: white; color: grey;}
+
+.align-right {
+ float: right;
+ margin: 2em;
+}
+
+h1, h2, h3 {
+ padding-top: 1em;
+}
+
+.publist a {
+ font-style: italic;
+}
+
diff --git a/examples/legacy/hello_world_app/src/templates/base_default.httkweb.html b/examples/legacy/hello_world_app/src/templates/base_default.httkweb.html
new file mode 100644
index 0000000..8c6a4d8
--- /dev/null
+++ b/examples/legacy/hello_world_app/src/templates/base_default.httkweb.html
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+{page.title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {content}
+
+
+
+
+
+
+
diff --git a/examples/legacy/hello_world_app/src/templates/default.httkweb.html b/examples/legacy/hello_world_app/src/templates/default.httkweb.html
new file mode 100644
index 0000000..390c2fc
--- /dev/null
+++ b/examples/legacy/hello_world_app/src/templates/default.httkweb.html
@@ -0,0 +1,2 @@
+{content}
+{page.hello_world}
diff --git a/examples/legacy/hello_world_app/src/templates/hello_world_result.httkweb.html b/examples/legacy/hello_world_app/src/templates/hello_world_result.httkweb.html
new file mode 100644
index 0000000..a9ed31a
--- /dev/null
+++ b/examples/legacy/hello_world_app/src/templates/hello_world_result.httkweb.html
@@ -0,0 +1 @@
+Result of hello world function: {result}
diff --git a/examples/legacy/rst_templator/publish_legacy_rst_templator.py b/examples/legacy/rst_templator/publish_legacy_rst_templator.py
new file mode 100644
index 0000000..c654cfc
--- /dev/null
+++ b/examples/legacy/rst_templator/publish_legacy_rst_templator.py
@@ -0,0 +1,6 @@
+from pathlib import Path
+
+from httk.web import publish
+
+ROOT = Path(__file__).parent
+publish(ROOT / "src", ROOT / "public", "http://127.0.0.1/", compatibility_mode=True)
diff --git a/examples/legacy/rst_templator/serve_legacy_rst_templator.py b/examples/legacy/rst_templator/serve_legacy_rst_templator.py
new file mode 100644
index 0000000..e3f984f
--- /dev/null
+++ b/examples/legacy/rst_templator/serve_legacy_rst_templator.py
@@ -0,0 +1,6 @@
+from pathlib import Path
+
+from httk.web import serve
+
+ROOT = Path(__file__).parent
+serve(ROOT / "src", port=8080, compatibility_mode=True, config_name="config_dynamic")
diff --git a/examples/legacy/rst_templator/src/config.rst b/examples/legacy/rst_templator/src/config.rst
new file mode 100644
index 0000000..b1ba0d0
--- /dev/null
+++ b/examples/legacy/rst_templator/src/config.rst
@@ -0,0 +1,6 @@
+:menuitems-list:
+ - index
+ - contact
+ - bare
+
+
diff --git a/examples/legacy/rst_templator/src/config_dynamic.rst b/examples/legacy/rst_templator/src/config_dynamic.rst
new file mode 100644
index 0000000..5821c3d
--- /dev/null
+++ b/examples/legacy/rst_templator/src/config_dynamic.rst
@@ -0,0 +1,6 @@
+:menuitems-list:
+ - index
+ - contact
+ - bare
+:urls_without_ext: true
+
diff --git a/examples/legacy/rst_templator/src/content/404.rst b/examples/legacy/rst_templator/src/content/404.rst
new file mode 100644
index 0000000..733dd19
--- /dev/null
+++ b/examples/legacy/rst_templator/src/content/404.rst
@@ -0,0 +1,12 @@
+:Title: Contact
+:Date: 2018-08-16
+:Version: 1
+:Author: Rickard Armiento
+:Layout: default
+
+Not found
+=========
+
+The page could not be found.
+
+
diff --git a/examples/legacy/rst_templator/src/content/bare.rst b/examples/legacy/rst_templator/src/content/bare.rst
new file mode 100644
index 0000000..faac02a
--- /dev/null
+++ b/examples/legacy/rst_templator/src/content/bare.rst
@@ -0,0 +1,13 @@
+:Title: Bare
+:Date: 2018-08-16
+:Version: 1
+:Author: Rickard Armiento
+:Layout: default
+:Base_template: bare
+
+Bare
+====
+
+This page has no header, etc.
+
+
diff --git a/examples/legacy/rst_templator/src/content/contact.rst b/examples/legacy/rst_templator/src/content/contact.rst
new file mode 100644
index 0000000..9e9047f
--- /dev/null
+++ b/examples/legacy/rst_templator/src/content/contact.rst
@@ -0,0 +1,11 @@
+:Title: Contact
+:Date: 2018-08-16
+:Version: 1
+:Author: Rickard Armiento
+:Layout: default
+
+Contact
+=======
+
+You should contact us
+
diff --git a/examples/legacy/rst_templator/src/content/index.rst b/examples/legacy/rst_templator/src/content/index.rst
new file mode 100644
index 0000000..bacedab
--- /dev/null
+++ b/examples/legacy/rst_templator/src/content/index.rst
@@ -0,0 +1,509 @@
+:Title: Front page
+:Date: 2018-08-16
+:Version: 1
+:Author: Rickard Armiento
+:Layout: default
+
+:Address: Example Street 123
+ Sweden
+:Contact: example@example.com
+:organization: httk
+:field name: This is a generic bibliographic field.
+:field name 2:
+ Generic bibliographic fields may contain multiple body elements.
+
+ Like this.
+
+======================
+ httk example website
+======================
+
+Below follows some example content from the docutils demo.txt file.
+
+.. Above is the document title, and below is the subtitle.
+ They are transformed from section titles after parsing.
+
+--------------------------------
+ Examples of Syntax Constructs
+--------------------------------
+
+.. meta::
+ :keywords: reStructuredText, demonstration, demo, parser
+ :description lang=en: A demonstration of the reStructuredText
+ markup language, containing examples of all basic
+ constructs and many advanced constructs.
+
+.. To get numbered headings, use this:
+ .. contents:: Table of Contents
+ .. section-numbering::
+
+Structural Elements
+===================
+
+Section Title
+-------------
+
+That's it, the text just above this line.
+
+Transitions
+-----------
+
+Here's a transition:
+
+---------
+
+It divides the section.
+
+Body Elements
+=============
+
+Paragraphs
+----------
+
+A paragraph.
+
+Inline Markup
+`````````````
+
+Paragraphs contain text and may contain inline markup: *emphasis*,
+**strong emphasis**, ``inline literals``, standalone hyperlinks
+(http://www.python.org), external hyperlinks (Python_), internal
+cross-references (example_), external hyperlinks with embedded URIs
+(`Python web site `__), footnote references
+(manually numbered [1]_, anonymous auto-numbered [#]_, labeled
+auto-numbered [#label]_, or symbolic [*]_), citation references
+([CIT2002]_), substitution references, and _`inline
+hyperlink targets` (see Targets_ below for a reference back to here).
+Character-level inline markup is also possible (although exceedingly
+ugly!) in *re*\ ``Structured``\ *Text*.
+
+The default role for interpreted text is `Title Reference`. Here are
+some explicit interpreted text roles: a PEP reference (:PEP:`287`); an
+RFC reference (:RFC:`2822`); a :sub:`subscript`; a :sup:`superscript`;
+and explicit roles for :emphasis:`standard` :strong:`inline`
+:literal:`markup`.
+
+.. DO NOT RE-WRAP THE FOLLOWING PARAGRAPH!
+
+Let's test wrapping and whitespace significance in inline literals:
+``This is an example of --inline-literal --text, --including some--
+strangely--hyphenated-words. Adjust-the-width-of-your-browser-window
+to see how the text is wrapped. -- ---- -------- Now note the
+spacing between the words of this sentence (words
+should be grouped in pairs).``
+
+If the ``--pep-references`` option was supplied, there should be a
+live link to PEP 258 here.
+
+Bullet Lists
+------------
+
+- A bullet list
+
+ + Nested bullet list.
+ + Nested item 2.
+
+- Item 2.
+
+ Paragraph 2 of item 2.
+
+ * Nested bullet list.
+ * Nested item 2.
+
+ - Third level.
+ - Item 2.
+
+ * Nested item 3.
+
+Enumerated Lists
+----------------
+
+1. Arabic numerals.
+
+ a) lower alpha)
+
+ (i) (lower roman)
+
+ A. upper alpha.
+
+ I) upper roman)
+
+2. Lists that don't start at 1:
+
+ 3. Three
+
+ 4. Four
+
+ C. C
+
+ D. D
+
+ iii. iii
+
+ iv. iv
+
+#. List items may also be auto-enumerated.
+
+Definition Lists
+----------------
+
+Term
+ Definition
+Term : classifier
+ Definition paragraph 1.
+
+ Definition paragraph 2.
+Term
+ Definition
+
+Field Lists
+-----------
+
+:what: Field lists map field names to field bodies, like database
+ records. They are often part of an extension syntax. They are
+ an unambiguous variant of RFC 2822 fields.
+
+:how arg1 arg2:
+
+ The field marker is a colon, the field name, and a colon.
+
+ The field body may contain one or more body elements, indented
+ relative to the field marker.
+
+Option Lists
+------------
+
+For listing command-line options:
+
+-a command-line option "a"
+-b file options can have arguments
+ and long descriptions
+--long options can be long also
+--input=file long options can also have
+ arguments
+
+--very-long-option
+ The description can also start on the next line.
+
+ The description may contain multiple body elements,
+ regardless of where it starts.
+
+-x, -y, -z Multiple options are an "option group".
+-v, --verbose Commonly-seen: short & long options.
+-1 file, --one=file, --two file
+ Multiple options with arguments.
+/V DOS/VMS-style options too
+
+There must be at least two spaces between the option and the
+description.
+
+Literal Blocks
+--------------
+
+Literal blocks are indicated with a double-colon ("::") at the end of
+the preceding paragraph (over there ``-->``). They can be indented::
+
+ if literal_block:
+ text = 'is left as-is'
+ spaces_and_linebreaks = 'are preserved'
+ markup_processing = None
+
+Or they can be quoted without indentation::
+
+>> Great idea!
+>
+> Why didn't I think of that?
+
+Line Blocks
+-----------
+
+| This is a line block. It ends with a blank line.
+| Each new line begins with a vertical bar ("|").
+| Line breaks and initial indents are preserved.
+| Continuation lines are wrapped portions of long lines;
+ they begin with a space in place of the vertical bar.
+| The left edge of a continuation line need not be aligned with
+ the left edge of the text above it.
+
+| This is a second line block.
+|
+| Blank lines are permitted internally, but they must begin with a "|".
+
+Take it away, Eric the Orchestra Leader!
+
+ | A one, two, a one two three four
+ |
+ | Half a bee, philosophically,
+ | must, *ipso facto*, half not be.
+ | But half the bee has got to be,
+ | *vis a vis* its entity. D'you see?
+ |
+ | But can a bee be said to be
+ | or not to be an entire bee,
+ | when half the bee is not a bee,
+ | due to some ancient injury?
+ |
+ | Singing...
+
+Block Quotes
+------------
+
+Block quotes consist of indented body elements:
+
+ My theory by A. Elk. Brackets Miss, brackets. This theory goes
+ as follows and begins now. All brontosauruses are thin at one
+ end, much much thicker in the middle and then thin again at the
+ far end. That is my theory, it is mine, and belongs to me and I
+ own it, and what it is too.
+
+ -- Anne Elk (Miss)
+
+Doctest Blocks
+--------------
+
+>>> print 'Python-specific usage examples; begun with ">>>"'
+Python-specific usage examples; begun with ">>>"
+>>> print '(cut and pasted from interactive Python sessions)'
+(cut and pasted from interactive Python sessions)
+
+Tables
+------
+
+Here's a grid table followed by a simple table:
+
++------------------------+------------+----------+----------+
+| Header row, column 1 | Header 2 | Header 3 | Header 4 |
+| (header rows optional) | | | |
++========================+============+==========+==========+
+| body row 1, column 1 | column 2 | column 3 | column 4 |
++------------------------+------------+----------+----------+
+| body row 2 | Cells may span columns. |
++------------------------+------------+---------------------+
+| body row 3 | Cells may | - Table cells |
++------------------------+ span rows. | - contain |
+| body row 4 | | - body elements. |
++------------------------+------------+----------+----------+
+| body row 5 | Cells may also be | |
+| | empty: ``-->`` | |
++------------------------+-----------------------+----------+
+
+===== ===== ======
+ Inputs Output
+------------ ------
+ A B A or B
+===== ===== ======
+False False False
+True False True
+False True True
+True True True
+===== ===== ======
+
+Footnotes
+---------
+
+.. [1] A footnote contains body elements, consistently indented by at
+ least 3 spaces.
+
+ This is the footnote's second paragraph.
+
+.. [#label] Footnotes may be numbered, either manually (as in [1]_) or
+ automatically using a "#"-prefixed label. This footnote has a
+ label so it can be referred to from multiple places, both as a
+ footnote reference ([#label]_) and as a hyperlink reference
+ (label_).
+
+.. [#] This footnote is numbered automatically and anonymously using a
+ label of "#" only.
+
+(Remark: one example was removed here, because it caused unicode error.)
+
+.. [*] This footnote shows the next symbol in the sequence.
+
+Citations
+---------
+
+.. [CIT2002] Citations are text-labeled footnotes. They may be
+ rendered separately and differently from footnotes.
+
+Here's a reference to the above, [CIT2002]_.
+
+Targets
+-------
+
+.. _example:
+
+This paragraph is pointed to by the explicit "example" target. A
+reference can be found under `Inline Markup`_, above. `Inline
+hyperlink targets`_ are also possible.
+
+Section headers are implicit targets, referred to by name. See
+Targets_, which is a subsection of `Body Elements`_.
+
+Explicit external targets are interpolated into references such as
+"Python_".
+
+.. _Python: http://www.python.org/
+
+Targets may be indirect and anonymous. Thus `this phrase`__ may also
+refer to the Targets_ section.
+
+__ Targets_
+
+Duplicate Target Names
+``````````````````````
+
+Duplicate names in section headers or other implicit targets will
+generate "info" (level-1) system messages. Duplicate names in
+explicit targets will generate "warning" (level-2) system messages.
+
+Duplicate Target Names
+``````````````````````
+
+Since there are two "Duplicate Target Names" section headers, we
+cannot uniquely refer to either of them by name.
+
+Directives
+----------
+
+.. contents:: :local:
+
+These are just a sample of the many reStructuredText Directives. For
+others, please see
+http://docutils.sourceforge.net/docs/ref/rst/directives.html.
+
+Document Parts
+``````````````
+
+An example of the "contents" directive can be seen above this section
+(a local, untitled table of contents_).
+
+Images
+``````
+
+An image directive (also clickable -- a hyperlink reference):
+
+.. image:: img/Example.png
+ :target: directives_
+
+A figure directive:
+
+.. figure:: img/Example.png
+ :alt: reStructuredText, the markup syntax
+
+ A figure is an image with a caption and/or a legend:
+
+ +------------+-----------------------------------------------+
+ | re | Revised, revisited, based on 're' module. |
+ +------------+-----------------------------------------------+
+ | Structured | Structure-enhanced text, structuredtext. |
+ +------------+-----------------------------------------------+
+ | Text | Well it is, isn't it? |
+ +------------+-----------------------------------------------+
+
+ This paragraph is also part of the legend.
+
+Admonitions
+```````````
+
+.. Attention:: Directives at large.
+
+.. Caution::
+
+ Don't take any wooden nickels.
+
+.. DANGER:: Mad scientist at work!
+
+.. Error:: Does not compute.
+
+.. Hint:: It's bigger than a bread box.
+
+.. Important::
+ - Wash behind your ears.
+ - Clean up your room.
+ - Call your mother.
+ - Back up your data.
+
+.. Note:: This is a note.
+
+.. Tip:: 15% if the service is good.
+
+.. WARNING:: Strong prose may provoke extreme mental exertion.
+ Reader discretion is strongly advised.
+
+.. admonition:: And, by the way...
+
+ You can make up your own admonition too.
+
+Topics, Sidebars, and Rubrics
+`````````````````````````````
+
+.. sidebar:: Sidebar Title
+ :subtitle: Optional Subtitle
+
+ This is a sidebar. It is for text outside the flow of the main
+ text.
+
+ .. rubric:: This is a rubric inside a sidebar
+
+ Sidebars often appears beside the main text with a border and
+ background color.
+
+.. topic:: Topic Title
+
+ This is a topic.
+
+.. rubric:: This is a rubric
+
+Target Footnotes
+````````````````
+
+.. target-notes::
+
+Replacement Text
+````````````````
+
+I recommend you try |Python|_.
+
+.. |Python| replace:: Python, *the* best language around
+
+Compound Paragraph
+``````````````````
+
+.. compound::
+
+ This paragraph contains a literal block::
+
+ Connecting... OK
+ Transmitting data... OK
+ Disconnecting... OK
+
+ and thus consists of a simple paragraph, a literal block, and
+ another simple paragraph. Nonetheless it is semantically *one*
+ paragraph.
+
+This construct is called a *compound paragraph* and can be produced
+with the "compound" directive.
+
+Substitution Definitions
+------------------------
+
+An inline image (|example|) example:
+
+.. |EXAMPLE| image:: img/Example.png
+
+(Substitution definitions are not visible in the HTML source.)
+
+Comments
+--------
+
+Here's one:
+
+.. Comments begin with two dots and a space. Anything may
+ follow, except for the syntax of footnotes, hyperlink
+ targets, directives, or substitution definitions.
+
+ Double-dashes -- "--" -- must be escaped somehow in HTML output.
+
+(View the HTML source to see the comment.)
+
+
diff --git a/examples/legacy/rst_templator/src/static/img/Example.png b/examples/legacy/rst_templator/src/static/img/Example.png
new file mode 100644
index 0000000..aa61359
Binary files /dev/null and b/examples/legacy/rst_templator/src/static/img/Example.png differ
diff --git a/examples/legacy/rst_templator/src/static/resources/css/httkdemo.css b/examples/legacy/rst_templator/src/static/resources/css/httkdemo.css
new file mode 100644
index 0000000..262c8e8
--- /dev/null
+++ b/examples/legacy/rst_templator/src/static/resources/css/httkdemo.css
@@ -0,0 +1,40 @@
+body {
+ padding-top: 0rem;
+}
+.navbar-custom {
+}
+.navbar-custom .navbar-nav .nav-link {
+ color: rgba(0,0,0,.5);
+}
+.navbar-custom .nav-link.active,
+.navbar-custom .nav-link:hover {
+ color: #000000;
+}
+.border-3 {
+ border-width:3px !important;
+}
+.border-orange {
+ border-color: #f80 !important;
+}
+.border-darkgrey {
+ border-color: #888 !important;
+}
+.orange {
+ color: #f80 !important;
+}
+.dropdown .dropdown-menu .dropdown-item:active, .dropdown .dropdown-menu .dropdown-item:hover{background-color: white; color: black;}
+.dropdown .dropdown-menu .dropdown-item {background-color: white; color: grey;}
+
+.align-right {
+ float: right;
+ margin: 2em;
+}
+
+h1, h2, h3 {
+ padding-top: 1em;
+}
+
+.publist a {
+ font-style: italic;
+}
+
diff --git a/examples/legacy/rst_templator/src/templates/bare.httkweb.html b/examples/legacy/rst_templator/src/templates/bare.httkweb.html
new file mode 100644
index 0000000..edaf100
--- /dev/null
+++ b/examples/legacy/rst_templator/src/templates/bare.httkweb.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+ {page.title}
+
+
+
+
+
+
+
+
+ {content}
+
+
+
+
+
+
diff --git a/examples/legacy/rst_templator/src/templates/bare.templator.html b/examples/legacy/rst_templator/src/templates/bare.templator.html
new file mode 100644
index 0000000..8ec27be
--- /dev/null
+++ b/examples/legacy/rst_templator/src/templates/bare.templator.html
@@ -0,0 +1,26 @@
+$def with (content)
+
+
+
+
+
+
+
+
+
+ $page.title
+
+
+
+
+
+
+
+
+ $:content
+
+
+
+
+
+
diff --git a/examples/legacy/rst_templator/src/templates/base_default.httkweb.html b/examples/legacy/rst_templator/src/templates/base_default.httkweb.html
new file mode 100644
index 0000000..c0b6804
--- /dev/null
+++ b/examples/legacy/rst_templator/src/templates/base_default.httkweb.html
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+ {page.title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {content}
+
+
+
+
+
+
diff --git a/examples/legacy/rst_templator/src/templates/base_default.templator.html b/examples/legacy/rst_templator/src/templates/base_default.templator.html
new file mode 100644
index 0000000..e721fe6
--- /dev/null
+++ b/examples/legacy/rst_templator/src/templates/base_default.templator.html
@@ -0,0 +1,52 @@
+$def with (content)
+
+
+
+
+
+
+
+
+
+ $page.title
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $:content
+
+
+
+
+
+
diff --git a/examples/legacy/rst_templator/src/templates/default.httkweb.html b/examples/legacy/rst_templator/src/templates/default.httkweb.html
new file mode 100644
index 0000000..24db9fc
--- /dev/null
+++ b/examples/legacy/rst_templator/src/templates/default.httkweb.html
@@ -0,0 +1 @@
+{content}
diff --git a/examples/legacy/rst_templator/src/templates/default.templator.html b/examples/legacy/rst_templator/src/templates/default.templator.html
new file mode 100644
index 0000000..9ad37b9
--- /dev/null
+++ b/examples/legacy/rst_templator/src/templates/default.templator.html
@@ -0,0 +1,2 @@
+$def with(content)
+$:content
diff --git a/examples/legacy/search_app/example.sqlite b/examples/legacy/search_app/example.sqlite
new file mode 100644
index 0000000..532e3bd
Binary files /dev/null and b/examples/legacy/search_app/example.sqlite differ
diff --git a/examples/legacy/search_app/publish_legacy_search_app.py b/examples/legacy/search_app/publish_legacy_search_app.py
new file mode 100644
index 0000000..c654cfc
--- /dev/null
+++ b/examples/legacy/search_app/publish_legacy_search_app.py
@@ -0,0 +1,6 @@
+from pathlib import Path
+
+from httk.web import publish
+
+ROOT = Path(__file__).parent
+publish(ROOT / "src", ROOT / "public", "http://127.0.0.1/", compatibility_mode=True)
diff --git a/examples/legacy/search_app/serve_legacy_search_app.py b/examples/legacy/search_app/serve_legacy_search_app.py
new file mode 100644
index 0000000..a59305e
--- /dev/null
+++ b/examples/legacy/search_app/serve_legacy_search_app.py
@@ -0,0 +1,6 @@
+from pathlib import Path
+
+from httk.web import serve
+
+ROOT = Path(__file__).parent
+serve(ROOT / "src", port=8080, compatibility_mode=True)
diff --git a/examples/legacy/search_app/src/config.httkweb b/examples/legacy/search_app/src/config.httkweb
new file mode 100644
index 0000000..df3a977
--- /dev/null
+++ b/examples/legacy/search_app/src/config.httkweb
@@ -0,0 +1,6 @@
+---
+website_name: Httk search demo
+other_websites_names-list: Another website, Open Materials Database
+other_websites_urls-list: https://example.com, http://openmaterialsdb.se
+menuitems-list: index
+---
diff --git a/examples/legacy/search_app/src/content/404.httkweb b/examples/legacy/search_app/src/content/404.httkweb
new file mode 100644
index 0000000..eb5118f
--- /dev/null
+++ b/examples/legacy/search_app/src/content/404.httkweb
@@ -0,0 +1,10 @@
+---
+Title: 404
+Date: 2018-08-16
+Version: 1
+Author: Rickard Armiento
+Template: default
+---
+The page could not be found.
+
+
diff --git a/examples/legacy/search_app/src/content/index.httkweb b/examples/legacy/search_app/src/content/index.httkweb
new file mode 100644
index 0000000..6926f53
--- /dev/null
+++ b/examples/legacy/search_app/src/content/index.httkweb
@@ -0,0 +1,10 @@
+---
+Title: Search
+Date: 2018-08-16
+Version: 1
+Author: Rickard Armiento
+Template: search_page
+search_result-function: search:q,!compound:search_result
+material_details-function: details:compound:material_details
+base_template_app: base_app
+---
diff --git a/examples/legacy/search_app/src/functions/details.py b/examples/legacy/search_app/src/functions/details.py
new file mode 100644
index 0000000..b6e67bd
--- /dev/null
+++ b/examples/legacy/search_app/src/functions/details.py
@@ -0,0 +1,29 @@
+# mypy: ignore-errors
+
+from httk.atomistic import Compound
+
+
+def execute(compound, global_data, **kargs):
+
+ search = global_data['store'].searcher()
+ search_compound = search.variable(Compound)
+ search.add(search_compound.element_wyckoff_sequence == compound)
+
+ search.output(search_compound, 'compound')
+
+ compound = list(search)[0][0][0]
+ tags = compound.get_tags()
+ tags = [{'tag': tags[tag].tag, 'value': tags[tag].value} for tag in tags]
+
+ names = ", ".join([x.name for x in compound.get_names()])
+
+ output = {
+ 'id': compound.element_wyckoff_sequence,
+ 'formula': compound.formula,
+ 'anonymous_formula': compound.anonymous_formula,
+ 'spacegroup_number': compound.spacegroup_number,
+ 'tags': tags,
+ 'names': names,
+ }
+
+ return output
diff --git a/examples/legacy/search_app/src/functions/init.py b/examples/legacy/search_app/src/functions/init.py
new file mode 100644
index 0000000..8e5436f
--- /dev/null
+++ b/examples/legacy/search_app/src/functions/init.py
@@ -0,0 +1,13 @@
+# mypy: ignore-errors
+
+import httk.db
+
+import httk
+
+
+def execute(global_data, **kargs):
+
+ backend = httk.db.backend.Sqlite('../../../Tutorial/tutorial_data/tutorial.sqlite')
+ store = httk.db.store.SqlStore(backend)
+
+ global_data['store'] = store
diff --git a/examples/legacy/search_app/src/functions/search.py b/examples/legacy/search_app/src/functions/search.py
new file mode 100644
index 0000000..42222a6
--- /dev/null
+++ b/examples/legacy/search_app/src/functions/search.py
@@ -0,0 +1,49 @@
+# mypy: ignore-errors
+
+import shlex
+
+from httk.atomistic import Compound
+from httk.atomistic.compound import CompoundName
+
+
+def execute(q, global_data, **kargs):
+
+ output = []
+
+ parts = shlex.split(q)
+
+ search = global_data['store'].searcher()
+ search_compound = search.variable(Compound)
+
+ # TODO: this should work:
+ # search_compound_tag = search.variable(CompoundTag,parent=search_compound, subkey='compound')
+ search_compound_name = search.variable(
+ CompoundName, parent=search_compound, parentkey='Compound_id', subkey='compound_Compound_sid'
+ )
+
+ # search.add(search_compound_tag.compound == search_compound)
+
+ for part in parts:
+ criterion = search_compound_name.name.like('%' + part + '%')
+ criterion = criterion or search_compound.formula_symbols.is_in(part)
+ search.add(criterion)
+ search.output(search_compound, 'compound')
+
+ i = 0
+ for match, header in list(search):
+ compound = match[0]
+ names = ", ".join([x.name for x in compound.get_names()])
+
+ i += 1
+ output += [
+ {
+ 'index': i,
+ 'id': compound.element_wyckoff_sequence,
+ 'formula': compound.formula,
+ 'anonymous_formula': compound.anonymous_formula,
+ 'spacegroup': compound.spacegroup_number,
+ 'names': names,
+ }
+ ]
+
+ return output
diff --git a/examples/legacy/search_app/src/static/img/Example.png b/examples/legacy/search_app/src/static/img/Example.png
new file mode 100644
index 0000000..aa61359
Binary files /dev/null and b/examples/legacy/search_app/src/static/img/Example.png differ
diff --git a/examples/legacy/search_app/src/static/resources/css/httkdemo.css b/examples/legacy/search_app/src/static/resources/css/httkdemo.css
new file mode 100644
index 0000000..262c8e8
--- /dev/null
+++ b/examples/legacy/search_app/src/static/resources/css/httkdemo.css
@@ -0,0 +1,40 @@
+body {
+ padding-top: 0rem;
+}
+.navbar-custom {
+}
+.navbar-custom .navbar-nav .nav-link {
+ color: rgba(0,0,0,.5);
+}
+.navbar-custom .nav-link.active,
+.navbar-custom .nav-link:hover {
+ color: #000000;
+}
+.border-3 {
+ border-width:3px !important;
+}
+.border-orange {
+ border-color: #f80 !important;
+}
+.border-darkgrey {
+ border-color: #888 !important;
+}
+.orange {
+ color: #f80 !important;
+}
+.dropdown .dropdown-menu .dropdown-item:active, .dropdown .dropdown-menu .dropdown-item:hover{background-color: white; color: black;}
+.dropdown .dropdown-menu .dropdown-item {background-color: white; color: grey;}
+
+.align-right {
+ float: right;
+ margin: 2em;
+}
+
+h1, h2, h3 {
+ padding-top: 1em;
+}
+
+.publist a {
+ font-style: italic;
+}
+
diff --git a/examples/legacy/search_app/src/templates/base_app.httkweb.html b/examples/legacy/search_app/src/templates/base_app.httkweb.html
new file mode 100644
index 0000000..4de0943
--- /dev/null
+++ b/examples/legacy/search_app/src/templates/base_app.httkweb.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+ {page.title}
+
+
+
+
+
+
+ {content}
+
+
+
+
+
diff --git a/examples/legacy/search_app/src/templates/base_default.httkweb.html b/examples/legacy/search_app/src/templates/base_default.httkweb.html
new file mode 100644
index 0000000..bd3fad4
--- /dev/null
+++ b/examples/legacy/search_app/src/templates/base_default.httkweb.html
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+{page.title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {content}
+
+
+
+
+
diff --git a/examples/legacy/search_app/src/templates/default.httkweb.html b/examples/legacy/search_app/src/templates/default.httkweb.html
new file mode 100644
index 0000000..24db9fc
--- /dev/null
+++ b/examples/legacy/search_app/src/templates/default.httkweb.html
@@ -0,0 +1 @@
+{content}
diff --git a/examples/legacy/search_app/src/templates/material_details.httkweb.html b/examples/legacy/search_app/src/templates/material_details.httkweb.html
new file mode 100644
index 0000000..d17d10d
--- /dev/null
+++ b/examples/legacy/search_app/src/templates/material_details.httkweb.html
@@ -0,0 +1,16 @@
+Materials info
+
+ Formula: {result[formula]}
+ Anonymous formula: {result[anonymous_formula]}
+ Spacegroup number: {result[spacegroup_number]}
+ Names: {result[names]}
+
+Tags:
+
+ {result[tags]:repeat:
+
+ {{item[tag]}}
+ {{item[value]}}
+
+ }
+
diff --git a/examples/legacy/search_app/src/templates/search_page.httkweb.html b/examples/legacy/search_app/src/templates/search_page.httkweb.html
new file mode 100644
index 0000000..c09cc78
--- /dev/null
+++ b/examples/legacy/search_app/src/templates/search_page.httkweb.html
@@ -0,0 +1,15 @@
+
+
+{page.search_result}
+{page.material_details}
+
diff --git a/examples/legacy/search_app/src/templates/search_result.httkweb.html b/examples/legacy/search_app/src/templates/search_result.httkweb.html
new file mode 100644
index 0000000..6eed70a
--- /dev/null
+++ b/examples/legacy/search_app/src/templates/search_result.httkweb.html
@@ -0,0 +1,19 @@
+Search results:
+
+#
+Compound id
+Formula
+Anonymous formula
+Names
+Spacegroup
+{result:repeat::
+
+ {{item[index]}}
+ {{item[id]}}
+ {{item[formula]}}
+ {{item[anonymous_formula]}}
+ {{item[names]}}
+ {{item[spacegroup]}}
+
+}
+
diff --git a/examples/legacy/static_simple/publish_legacy_static_simple.py b/examples/legacy/static_simple/publish_legacy_static_simple.py
new file mode 100755
index 0000000..c654cfc
--- /dev/null
+++ b/examples/legacy/static_simple/publish_legacy_static_simple.py
@@ -0,0 +1,6 @@
+from pathlib import Path
+
+from httk.web import publish
+
+ROOT = Path(__file__).parent
+publish(ROOT / "src", ROOT / "public", "http://127.0.0.1/", compatibility_mode=True)
diff --git a/examples/legacy/static_simple/serve_legacy_static_simple.py b/examples/legacy/static_simple/serve_legacy_static_simple.py
new file mode 100755
index 0000000..e3f984f
--- /dev/null
+++ b/examples/legacy/static_simple/serve_legacy_static_simple.py
@@ -0,0 +1,6 @@
+from pathlib import Path
+
+from httk.web import serve
+
+ROOT = Path(__file__).parent
+serve(ROOT / "src", port=8080, compatibility_mode=True, config_name="config_dynamic")
diff --git a/examples/legacy/static_simple/src/config.httkweb b/examples/legacy/static_simple/src/config.httkweb
new file mode 100644
index 0000000..d97f29d
--- /dev/null
+++ b/examples/legacy/static_simple/src/config.httkweb
@@ -0,0 +1,4 @@
+---
+menuitems-list: index, contact, bare
+---
+
diff --git a/examples/legacy/static_simple/src/config_dynamic.httkweb b/examples/legacy/static_simple/src/config_dynamic.httkweb
new file mode 100644
index 0000000..dcc358d
--- /dev/null
+++ b/examples/legacy/static_simple/src/config_dynamic.httkweb
@@ -0,0 +1,4 @@
+---
+menuitems-list: index, contact, bare
+urls_without_ext: true
+---
diff --git a/examples/legacy/static_simple/src/content/404.httkweb b/examples/legacy/static_simple/src/content/404.httkweb
new file mode 100644
index 0000000..9d62814
--- /dev/null
+++ b/examples/legacy/static_simple/src/content/404.httkweb
@@ -0,0 +1,10 @@
+---
+Title: Contact
+Date: 2018-08-16
+Version: 1
+Author: Rickard Armiento
+Template: 404
+---
+The page could not be found.
+
+
diff --git a/examples/legacy/static_simple/src/content/bare.httkweb b/examples/legacy/static_simple/src/content/bare.httkweb
new file mode 100644
index 0000000..11495ac
--- /dev/null
+++ b/examples/legacy/static_simple/src/content/bare.httkweb
@@ -0,0 +1,15 @@
+---
+Title: Bare
+Date: 2018-08-16
+Version: 1
+Author: Rickard Armiento
+Template: default
+Base_template: bare
+---
+
+Bare
+====
+
+This page has no header, etc.
+
+
diff --git a/examples/legacy/static_simple/src/content/contact.httkweb b/examples/legacy/static_simple/src/content/contact.httkweb
new file mode 100644
index 0000000..e32967a
--- /dev/null
+++ b/examples/legacy/static_simple/src/content/contact.httkweb
@@ -0,0 +1,13 @@
+---
+Title: Contact
+Date: 2018-08-16
+Version: 1
+Author: Rickard Armiento
+Template: default
+---
+
+Contact
+=======
+
+You should contact us
+
diff --git a/examples/legacy/static_simple/src/content/index.httkweb b/examples/legacy/static_simple/src/content/index.httkweb
new file mode 100644
index 0000000..6608dc5
--- /dev/null
+++ b/examples/legacy/static_simple/src/content/index.httkweb
@@ -0,0 +1,34 @@
+---
+Title: Front page
+Date: 2018-08-16
+Version: 1
+Author: Rickard Armiento
+Template: default
+
+Address: Example Street 123
+ Sweden
+Contact: example@example.com
+organization: httk
+field name: This is a generic bibliographic field.
+field name 2:
+ Generic bibliographic fields may contain multiple body elements.
+
+ Like this.
+---
+
+This is the front page
+======================
+
+You can write with *emphasis*, **strong emphasis**, and as `interpreted text`.
+
+This is an anchor link: `You can do subtitles`_
+
+You escape, e.g., \* and \`` with \\
+
+
+You can do subtitles
+--------------------
+Welcome to this page.
+
+You can do external hyperlinks, like `Python `_.
+
diff --git a/examples/legacy/static_simple/src/static/img/Example.png b/examples/legacy/static_simple/src/static/img/Example.png
new file mode 100644
index 0000000..aa61359
Binary files /dev/null and b/examples/legacy/static_simple/src/static/img/Example.png differ
diff --git a/examples/legacy/static_simple/src/static/resources/css/httkdemo.css b/examples/legacy/static_simple/src/static/resources/css/httkdemo.css
new file mode 100644
index 0000000..262c8e8
--- /dev/null
+++ b/examples/legacy/static_simple/src/static/resources/css/httkdemo.css
@@ -0,0 +1,40 @@
+body {
+ padding-top: 0rem;
+}
+.navbar-custom {
+}
+.navbar-custom .navbar-nav .nav-link {
+ color: rgba(0,0,0,.5);
+}
+.navbar-custom .nav-link.active,
+.navbar-custom .nav-link:hover {
+ color: #000000;
+}
+.border-3 {
+ border-width:3px !important;
+}
+.border-orange {
+ border-color: #f80 !important;
+}
+.border-darkgrey {
+ border-color: #888 !important;
+}
+.orange {
+ color: #f80 !important;
+}
+.dropdown .dropdown-menu .dropdown-item:active, .dropdown .dropdown-menu .dropdown-item:hover{background-color: white; color: black;}
+.dropdown .dropdown-menu .dropdown-item {background-color: white; color: grey;}
+
+.align-right {
+ float: right;
+ margin: 2em;
+}
+
+h1, h2, h3 {
+ padding-top: 1em;
+}
+
+.publist a {
+ font-style: italic;
+}
+
diff --git a/examples/legacy/static_simple/src/templates/404.httkweb.html b/examples/legacy/static_simple/src/templates/404.httkweb.html
new file mode 100644
index 0000000..912ade8
--- /dev/null
+++ b/examples/legacy/static_simple/src/templates/404.httkweb.html
@@ -0,0 +1,2 @@
+{content}
+{error_404_reason:unquoted:if:Reason given from webserver: {error_404_reason}}
diff --git a/examples/legacy/static_simple/src/templates/bare.httkweb.html b/examples/legacy/static_simple/src/templates/bare.httkweb.html
new file mode 100644
index 0000000..efd99da
--- /dev/null
+++ b/examples/legacy/static_simple/src/templates/bare.httkweb.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+ {page.title}
+
+
+
+
+
+
+
+
+ {content}
+
+
+
+
+
+
diff --git a/examples/legacy/static_simple/src/templates/base_default.httkweb.html b/examples/legacy/static_simple/src/templates/base_default.httkweb.html
new file mode 100644
index 0000000..e14e145
--- /dev/null
+++ b/examples/legacy/static_simple/src/templates/base_default.httkweb.html
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+ {page.title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {content}
+
+
+
+
+
+
diff --git a/examples/legacy/static_simple/src/templates/default.httkweb.html b/examples/legacy/static_simple/src/templates/default.httkweb.html
new file mode 100644
index 0000000..24db9fc
--- /dev/null
+++ b/examples/legacy/static_simple/src/templates/default.httkweb.html
@@ -0,0 +1 @@
+{content}
diff --git a/examples/modern/blog/README.md b/examples/modern/blog/README.md
new file mode 100644
index 0000000..23ceb15
--- /dev/null
+++ b/examples/modern/blog/README.md
@@ -0,0 +1,19 @@
+# Modern Blog Example
+
+Modernized replacement for the old legacy blog example.
+
+- Markdown content
+- Jinja2 templates
+- Python function bootstrap via `src/functions/init.py`
+
+Run:
+
+```bash
+python serve.py
+```
+
+Publish:
+
+```bash
+python publish.py
+```
diff --git a/examples/modern/blog/publish.py b/examples/modern/blog/publish.py
new file mode 100644
index 0000000..2b958af
--- /dev/null
+++ b/examples/modern/blog/publish.py
@@ -0,0 +1,6 @@
+from pathlib import Path
+
+from httk.web import publish
+
+ROOT = Path(__file__).parent
+publish(ROOT / "src", ROOT / "public", "http://127.0.0.1/")
diff --git a/examples/modern/blog/serve.py b/examples/modern/blog/serve.py
new file mode 100644
index 0000000..ce3cd1a
--- /dev/null
+++ b/examples/modern/blog/serve.py
@@ -0,0 +1,6 @@
+from pathlib import Path
+
+from httk.web import serve
+
+ROOT = Path(__file__).parent
+serve(ROOT / "src", port=8080)
diff --git a/examples/modern/blog/src/content/blog.md b/examples/modern/blog/src/content/blog.md
new file mode 100644
index 0000000..fc1672c
--- /dev/null
+++ b/examples/modern/blog/src/content/blog.md
@@ -0,0 +1,6 @@
+---
+title: All Blog Posts
+template: blog_index
+---
+
+Browse all posts below.
diff --git a/examples/modern/blog/src/content/blogposts/hello-modern.md b/examples/modern/blog/src/content/blogposts/hello-modern.md
new file mode 100644
index 0000000..6c59aa5
--- /dev/null
+++ b/examples/modern/blog/src/content/blogposts/hello-modern.md
@@ -0,0 +1,8 @@
+---
+title: Hello Modern Blog
+author: httk-web
+date: 2026-03-10
+template: blog_post
+---
+
+This is the first modern blog post.
diff --git a/examples/modern/blog/src/content/blogposts/migration-notes.md b/examples/modern/blog/src/content/blogposts/migration-notes.md
new file mode 100644
index 0000000..2c21baa
--- /dev/null
+++ b/examples/modern/blog/src/content/blogposts/migration-notes.md
@@ -0,0 +1,8 @@
+---
+title: Migration Notes
+author: httk-web
+date: 2026-03-20
+template: blog_post
+---
+
+This post summarizes migration from legacy templates to Jinja2.
diff --git a/examples/modern/blog/src/content/contact.md b/examples/modern/blog/src/content/contact.md
new file mode 100644
index 0000000..750a90b
--- /dev/null
+++ b/examples/modern/blog/src/content/contact.md
@@ -0,0 +1,6 @@
+---
+title: Contact
+template: default
+---
+
+Contact us at `contact@example.com`.
diff --git a/examples/modern/blog/src/content/index.md b/examples/modern/blog/src/content/index.md
new file mode 100644
index 0000000..934a1de
--- /dev/null
+++ b/examples/modern/blog/src/content/index.md
@@ -0,0 +1,6 @@
+---
+title: Modern Blog Home
+template: blog_home
+---
+
+Welcome to the modern blog example.
diff --git a/examples/modern/blog/src/functions/init.py b/examples/modern/blog/src/functions/init.py
new file mode 100644
index 0000000..0be7c01
--- /dev/null
+++ b/examples/modern/blog/src/functions/init.py
@@ -0,0 +1,14 @@
+from pathlib import Path
+
+
+def execute(global_data, **kwargs):
+ content_root = Path(__file__).resolve().parents[1] / "content" / "blogposts"
+ posts = []
+ for path in sorted(content_root.glob("*.md")):
+ rel = f"blogposts/{path.stem}"
+ date = global_data["pages"](rel, "date")
+ posts.append((str(date or ""), rel))
+
+ posts.sort(reverse=True)
+ global_data["blogposts"] = [rel for _, rel in posts]
+ global_data["blogposts_latest"] = global_data["blogposts"][:5]
diff --git a/examples/modern/blog/src/templates/base_default.html.j2 b/examples/modern/blog/src/templates/base_default.html.j2
new file mode 100644
index 0000000..cc46843
--- /dev/null
+++ b/examples/modern/blog/src/templates/base_default.html.j2
@@ -0,0 +1,21 @@
+
+
+
+
+
+ {{ title or "Modern Blog" }}
+
+
+
+
+ {{ content }}
+
+
+
diff --git a/examples/modern/blog/src/templates/blog_home.html.j2 b/examples/modern/blog/src/templates/blog_home.html.j2
new file mode 100644
index 0000000..eea2349
--- /dev/null
+++ b/examples/modern/blog/src/templates/blog_home.html.j2
@@ -0,0 +1,13 @@
+
+ {{ title }}
+ {{ content }}
+ Latest posts
+
+
diff --git a/examples/modern/blog/src/templates/blog_index.html.j2 b/examples/modern/blog/src/templates/blog_index.html.j2
new file mode 100644
index 0000000..b429883
--- /dev/null
+++ b/examples/modern/blog/src/templates/blog_index.html.j2
@@ -0,0 +1,12 @@
+
+ {{ title }}
+ {{ content }}
+
+ {% for rel in blogposts %}
+
+ {{ pages(rel, 'title') }}
+ by {{ pages(rel, 'author') }} ({{ pages(rel, 'date') }})
+
+ {% endfor %}
+
+
diff --git a/examples/modern/blog/src/templates/blog_post.html.j2 b/examples/modern/blog/src/templates/blog_post.html.j2
new file mode 100644
index 0000000..0e92ad1
--- /dev/null
+++ b/examples/modern/blog/src/templates/blog_post.html.j2
@@ -0,0 +1,5 @@
+
+ {{ title }}
+ {{ date }} by {{ author }}
+ {{ content }}
+
diff --git a/examples/modern/blog/src/templates/default.html.j2 b/examples/modern/blog/src/templates/default.html.j2
new file mode 100644
index 0000000..6711c79
--- /dev/null
+++ b/examples/modern/blog/src/templates/default.html.j2
@@ -0,0 +1,4 @@
+
+ {{ title }}
+ {{ content }}
+
diff --git a/examples/modern/minimal/README.md b/examples/modern/minimal/README.md
new file mode 100644
index 0000000..b958aaa
--- /dev/null
+++ b/examples/modern/minimal/README.md
@@ -0,0 +1,13 @@
+# Minimal Modern Example
+
+Serve:
+
+```bash
+python serve.py
+```
+
+Publish:
+
+```bash
+python publish.py
+```
diff --git a/examples/modern/minimal/publish.py b/examples/modern/minimal/publish.py
new file mode 100644
index 0000000..2b958af
--- /dev/null
+++ b/examples/modern/minimal/publish.py
@@ -0,0 +1,6 @@
+from pathlib import Path
+
+from httk.web import publish
+
+ROOT = Path(__file__).parent
+publish(ROOT / "src", ROOT / "public", "http://127.0.0.1/")
diff --git a/examples/modern/minimal/serve.py b/examples/modern/minimal/serve.py
new file mode 100644
index 0000000..ce3cd1a
--- /dev/null
+++ b/examples/modern/minimal/serve.py
@@ -0,0 +1,6 @@
+from pathlib import Path
+
+from httk.web import serve
+
+ROOT = Path(__file__).parent
+serve(ROOT / "src", port=8080)
diff --git a/examples/modern/minimal/src/content/index.md b/examples/modern/minimal/src/content/index.md
new file mode 100644
index 0000000..a0542ac
--- /dev/null
+++ b/examples/modern/minimal/src/content/index.md
@@ -0,0 +1,8 @@
+---
+title: Minimal Modern Example
+template: default
+---
+
+# Hello from httk-web
+
+This is the modern example using Jinja2 templates.
diff --git a/examples/modern/minimal/src/templates/base_default.html.j2 b/examples/modern/minimal/src/templates/base_default.html.j2
new file mode 100644
index 0000000..f92656a
--- /dev/null
+++ b/examples/modern/minimal/src/templates/base_default.html.j2
@@ -0,0 +1,13 @@
+
+
+
+
+
+ {{ title }}
+
+
+
+ {{ content }}
+
+
+
diff --git a/examples/modern/minimal/src/templates/default.html.j2 b/examples/modern/minimal/src/templates/default.html.j2
new file mode 100644
index 0000000..1050544
--- /dev/null
+++ b/examples/modern/minimal/src/templates/default.html.j2
@@ -0,0 +1,4 @@
+
+ {{ title }}
+ {{ content }}
+
diff --git a/examples/modern/rst_site/README.md b/examples/modern/rst_site/README.md
new file mode 100644
index 0000000..93f36e4
--- /dev/null
+++ b/examples/modern/rst_site/README.md
@@ -0,0 +1,19 @@
+# Modern RST Site
+
+Modernized replacement for the old `rst_templator` example.
+
+- Uses `.rst` content.
+- Uses Jinja2 templates (`.html.j2`).
+- No legacy templator/web.py syntax.
+
+Run:
+
+```bash
+python serve.py
+```
+
+Publish:
+
+```bash
+python publish.py
+```
diff --git a/examples/modern/rst_site/publish.py b/examples/modern/rst_site/publish.py
new file mode 100644
index 0000000..2b958af
--- /dev/null
+++ b/examples/modern/rst_site/publish.py
@@ -0,0 +1,6 @@
+from pathlib import Path
+
+from httk.web import publish
+
+ROOT = Path(__file__).parent
+publish(ROOT / "src", ROOT / "public", "http://127.0.0.1/")
diff --git a/examples/modern/rst_site/serve.py b/examples/modern/rst_site/serve.py
new file mode 100644
index 0000000..ce3cd1a
--- /dev/null
+++ b/examples/modern/rst_site/serve.py
@@ -0,0 +1,6 @@
+from pathlib import Path
+
+from httk.web import serve
+
+ROOT = Path(__file__).parent
+serve(ROOT / "src", port=8080)
diff --git a/examples/modern/rst_site/src/content/about.rst b/examples/modern/rst_site/src/content/about.rst
new file mode 100644
index 0000000..d3c19b0
--- /dev/null
+++ b/examples/modern/rst_site/src/content/about.rst
@@ -0,0 +1,7 @@
+:Title: About This Example
+:Template: default
+
+About
+=====
+
+This is a small multi-page RST site.
diff --git a/examples/modern/rst_site/src/content/index.rst b/examples/modern/rst_site/src/content/index.rst
new file mode 100644
index 0000000..70e1455
--- /dev/null
+++ b/examples/modern/rst_site/src/content/index.rst
@@ -0,0 +1,14 @@
+:Title: Modern RST Front Page
+:Author: httk-web
+:Date: 2026-03-27
+:Template: default
+
+Modern RST Example
+==================
+
+This page is written in **reStructuredText** and rendered through the modern
+Jinja2 template layer.
+
+- No web.py templator syntax
+- No `.templator.html` templates
+- Python 3.11+ only
diff --git a/examples/modern/rst_site/src/templates/base_default.html.j2 b/examples/modern/rst_site/src/templates/base_default.html.j2
new file mode 100644
index 0000000..266e78b
--- /dev/null
+++ b/examples/modern/rst_site/src/templates/base_default.html.j2
@@ -0,0 +1,19 @@
+
+
+
+
+
+ {{ title or "Modern RST Site" }}
+
+
+
+
+ {{ content }}
+
+
+
diff --git a/examples/modern/rst_site/src/templates/default.html.j2 b/examples/modern/rst_site/src/templates/default.html.j2
new file mode 100644
index 0000000..1050544
--- /dev/null
+++ b/examples/modern/rst_site/src/templates/default.html.j2
@@ -0,0 +1,4 @@
+
+ {{ title }}
+ {{ content }}
+
diff --git a/examples/modern/search_app/README.md b/examples/modern/search_app/README.md
new file mode 100644
index 0000000..333458f
--- /dev/null
+++ b/examples/modern/search_app/README.md
@@ -0,0 +1,15 @@
+# Modern Search Example
+
+Modernized replacement for the old search app example.
+
+- Dynamic function injection from query parameters
+- Jinja2 templates for page and fragment rendering
+- In-memory sample dataset (no legacy `httk.db` dependency)
+
+Run:
+
+```bash
+python serve.py
+```
+
+Then open `http://127.0.0.1:8080/` and search for terms like `si` or `oxide`.
diff --git a/examples/modern/search_app/publish.py b/examples/modern/search_app/publish.py
new file mode 100644
index 0000000..2b958af
--- /dev/null
+++ b/examples/modern/search_app/publish.py
@@ -0,0 +1,6 @@
+from pathlib import Path
+
+from httk.web import publish
+
+ROOT = Path(__file__).parent
+publish(ROOT / "src", ROOT / "public", "http://127.0.0.1/")
diff --git a/examples/modern/search_app/serve.py b/examples/modern/search_app/serve.py
new file mode 100644
index 0000000..ce3cd1a
--- /dev/null
+++ b/examples/modern/search_app/serve.py
@@ -0,0 +1,6 @@
+from pathlib import Path
+
+from httk.web import serve
+
+ROOT = Path(__file__).parent
+serve(ROOT / "src", port=8080)
diff --git a/examples/modern/search_app/src/content/index.md b/examples/modern/search_app/src/content/index.md
new file mode 100644
index 0000000..6434025
--- /dev/null
+++ b/examples/modern/search_app/src/content/index.md
@@ -0,0 +1,8 @@
+---
+title: Modern Search Demo
+template: search_page
+results-function: search:q:search_results
+details-function: details:material_id:material_details
+---
+
+Use the search box to query the sample material dataset.
diff --git a/examples/modern/search_app/src/functions/details.py b/examples/modern/search_app/src/functions/details.py
new file mode 100644
index 0000000..943bc78
--- /dev/null
+++ b/examples/modern/search_app/src/functions/details.py
@@ -0,0 +1,5 @@
+def execute(material_id, global_data, **kwargs):
+ for row in global_data.get("materials", []):
+ if row.get("id") == material_id:
+ return row
+ return None
diff --git a/examples/modern/search_app/src/functions/init.py b/examples/modern/search_app/src/functions/init.py
new file mode 100644
index 0000000..e0c9304
--- /dev/null
+++ b/examples/modern/search_app/src/functions/init.py
@@ -0,0 +1,6 @@
+def execute(global_data, **kwargs):
+ global_data["materials"] = [
+ {"id": "mp-149", "formula": "Si", "name": "Silicon", "spacegroup": "Fd-3m"},
+ {"id": "mp-13", "formula": "Fe", "name": "Iron", "spacegroup": "Im-3m"},
+ {"id": "mp-22862", "formula": "Al2O3", "name": "Aluminum Oxide", "spacegroup": "R-3c"},
+ ]
diff --git a/examples/modern/search_app/src/functions/search.py b/examples/modern/search_app/src/functions/search.py
new file mode 100644
index 0000000..282a0b7
--- /dev/null
+++ b/examples/modern/search_app/src/functions/search.py
@@ -0,0 +1,12 @@
+def execute(q, global_data, **kwargs):
+ needle = str(q).strip().lower()
+ if not needle:
+ return []
+
+ materials = global_data.get("materials", [])
+ out = []
+ for row in materials:
+ text = " ".join([str(row.get("id", "")), str(row.get("formula", "")), str(row.get("name", ""))]).lower()
+ if needle in text:
+ out.append(row)
+ return out
diff --git a/examples/modern/search_app/src/templates/base_default.html.j2 b/examples/modern/search_app/src/templates/base_default.html.j2
new file mode 100644
index 0000000..ce6296a
--- /dev/null
+++ b/examples/modern/search_app/src/templates/base_default.html.j2
@@ -0,0 +1,13 @@
+
+
+
+
+
+ {{ title or "Modern Search" }}
+
+
+
+ {{ content }}
+
+
+
diff --git a/examples/modern/search_app/src/templates/material_details.html.j2 b/examples/modern/search_app/src/templates/material_details.html.j2
new file mode 100644
index 0000000..f353aa9
--- /dev/null
+++ b/examples/modern/search_app/src/templates/material_details.html.j2
@@ -0,0 +1,10 @@
+{% if result %}
+
+ ID: {{ result['id'] }}
+ Formula: {{ result['formula'] }}
+ Name: {{ result['name'] }}
+ Spacegroup: {{ result['spacegroup'] }}
+
+{% else %}
+No details found.
+{% endif %}
diff --git a/examples/modern/search_app/src/templates/search_page.html.j2 b/examples/modern/search_app/src/templates/search_page.html.j2
new file mode 100644
index 0000000..42cb258
--- /dev/null
+++ b/examples/modern/search_app/src/templates/search_page.html.j2
@@ -0,0 +1,21 @@
+
+ {{ title }}
+ {{ content }}
+
+
+
+ {% if results %}
+ {{ results }}
+ {% endif %}
+
+ {% if details %}
+ Material details
+ {{ details }}
+ {% endif %}
+
diff --git a/examples/modern/search_app/src/templates/search_results.html.j2 b/examples/modern/search_app/src/templates/search_results.html.j2
new file mode 100644
index 0000000..d925a6a
--- /dev/null
+++ b/examples/modern/search_app/src/templates/search_results.html.j2
@@ -0,0 +1,24 @@
+
+
+
+ ID
+ Formula
+ Name
+ Spacegroup
+
+
+
+ {% for row in result %}
+
+
+
+ {{ row['id'] }}
+
+
+ {{ row['formula'] }}
+ {{ row['name'] }}
+ {{ row['spacegroup'] }}
+
+ {% endfor %}
+
+
diff --git a/src/httk/web/api.py b/src/httk/web/api.py
index efa64f8..0b5472c 100644
--- a/src/httk/web/api.py
+++ b/src/httk/web/api.py
@@ -15,9 +15,15 @@ def create_asgi_app(
*,
baseurl: str | None = None,
compatibility_mode: bool = False,
+ config_name: str = "config",
debug: bool = False,
) -> Starlette:
- config = SiteConfig.from_srcdir(srcdir=srcdir, baseurl=baseurl, compatibility_mode=compatibility_mode)
+ config = SiteConfig.from_srcdir(
+ srcdir=srcdir,
+ baseurl=baseurl,
+ compatibility_mode=compatibility_mode,
+ config_name=config_name,
+ )
engine = SiteEngine(config)
return create_app(engine=engine, debug=debug)
@@ -29,9 +35,16 @@ def serve(
port: int = 8080,
baseurl: str | None = None,
compatibility_mode: bool = False,
+ config_name: str = "config",
debug: bool = False,
) -> None:
- app = create_asgi_app(srcdir=srcdir, baseurl=baseurl, compatibility_mode=compatibility_mode, debug=debug)
+ app = create_asgi_app(
+ srcdir=srcdir,
+ baseurl=baseurl,
+ compatibility_mode=compatibility_mode,
+ config_name=config_name,
+ debug=debug,
+ )
run_dev_server(app=app, host=host, port=port)
@@ -41,7 +54,13 @@ def publish(
baseurl: str,
*,
compatibility_mode: bool = False,
+ config_name: str = "config",
) -> PublishReport:
- config = SiteConfig.from_srcdir(srcdir=srcdir, baseurl=baseurl, compatibility_mode=compatibility_mode)
+ config = SiteConfig.from_srcdir(
+ srcdir=srcdir,
+ baseurl=baseurl,
+ compatibility_mode=compatibility_mode,
+ config_name=config_name,
+ )
engine = SiteEngine(config)
return publish_site(engine=engine, outdir=outdir)
diff --git a/src/httk/web/engine/site_engine.py b/src/httk/web/engine/site_engine.py
index 2352de1..5537234 100644
--- a/src/httk/web/engine/site_engine.py
+++ b/src/httk/web/engine/site_engine.py
@@ -26,6 +26,8 @@ def __init__(self, config: SiteConfig) -> None:
else:
self.template_engine = JinjaTemplateEngine(template_dir=config.template_dir)
self.function_handler = PythonFunctionHandler(functions_dir=config.functions_dir)
+ self.global_data: dict[str, object] = self._load_global_config_metadata()
+ self._run_init_function()
def resolve(self, route: str) -> ResolvedRoute:
return resolve_route(config=self.config, route=route)
@@ -65,8 +67,17 @@ def render(
route_key = normalize_route(route)
warnings: list[str] = []
- template_name = self._metadata_string(metadata, "template", default="default")
- base_template_name = self._metadata_string(metadata, "base_template", default="base_default")
+ render_mode = "serve" if request is not None else "publish"
+ template_name = self._metadata_string(
+ metadata,
+ f"template_{render_mode}",
+ default=self._metadata_string(metadata, "template", default="default"),
+ )
+ base_template_name = self._metadata_string(
+ metadata,
+ f"base_template_{render_mode}",
+ default=self._metadata_string(metadata, "base_template", default="base_default"),
+ )
context = self._build_template_context(
route_key=route_key,
@@ -121,7 +132,8 @@ def _build_template_context(
postvars: dict[str, str],
request: HttpRequestContext,
) -> dict[str, object]:
- context: dict[str, object] = dict(metadata)
+ context: dict[str, object] = dict(self.global_data)
+ context.update(metadata)
page_cache: dict[str, tuple[str, dict[str, object]]] = {}
def first_value(*values: object) -> object:
@@ -170,8 +182,9 @@ def pages(path: str, field: str) -> object:
page_html, page_metadata = self._render_content_without_templates(target)
page_cache[normalized] = (page_html, page_metadata)
- if field in page_metadata:
- return page_metadata[field]
+ metadata_value = self._metadata_field_value(page_metadata, field)
+ if metadata_value is not None:
+ return metadata_value
if field in {"content", "html"}:
return page_html
if field == "relurl":
@@ -184,11 +197,20 @@ def pages(path: str, field: str) -> object:
context["query"] = dict(query)
context["postvars"] = dict(postvars)
context["request"] = request
- context["page"] = {
- "relurl": route_key,
- "absurl": self._absolute_url(route_key),
- "relbaseurl": self._relative_base(route_key),
+ page_data = {
+ key: value
+ for key, value in metadata.items()
+ if isinstance(key, str) and key and not key.startswith("_") and not key.lower().endswith("-function")
}
+ page_data.update(
+ {
+ "relurl": route_key,
+ "absurl": self._absolute_url(route_key),
+ "relbaseurl": self._relative_base(route_key),
+ "functionurl": self._absolute_url(route_key),
+ }
+ )
+ context["page"] = page_data
return context
def _apply_function_injections(
@@ -200,7 +222,7 @@ def _apply_function_injections(
route_key: str,
warnings: list[str],
) -> None:
- function_keys = [key for key in metadata if key.endswith("-function")]
+ function_keys = [key for key in metadata if isinstance(key, str) and key.lower().endswith("-function")]
for function_key in function_keys:
try:
@@ -291,3 +313,67 @@ def _metadata_string(self, metadata: dict[str, object], key: str, *, default: st
stripped = value.strip()
return stripped if stripped else default
return default
+
+ def _load_global_config_metadata(self) -> dict[str, object]:
+ config_name = self.config.config_name.strip()
+ if not config_name:
+ return {}
+
+ for suffix, renderer in RENDERERS_BY_SUFFIX.items():
+ candidate = self.config.srcdir / f"{config_name}{suffix}"
+ if not candidate.exists() or not candidate.is_file():
+ continue
+ rendered = renderer.render(candidate)
+ metadata = dict(rendered.metadata)
+ return self._normalize_legacy_list_keys(metadata)
+
+ return {}
+
+ def _run_init_function(self) -> None:
+ init_file = self.config.functions_dir / "init.py"
+ if not init_file.exists() or not init_file.is_file():
+ return
+
+ init_context = dict(self.global_data)
+ init_context["pages"] = self._global_pages_helper
+ self.function_handler.execute(function_name="init", params={}, global_data=init_context)
+ self.global_data.update(init_context)
+
+ def _normalize_legacy_list_keys(self, metadata: dict[str, object]) -> dict[str, object]:
+ normalized = dict(metadata)
+ for key, value in list(metadata.items()):
+ if not isinstance(key, str) or not key.endswith("-list"):
+ continue
+ base_key = key[: -len("-list")]
+ if isinstance(value, list):
+ normalized[base_key] = value
+ elif isinstance(value, str):
+ normalized[base_key] = [x.strip() for x in value.split(",") if x.strip()]
+ elif value is None:
+ normalized[base_key] = []
+ else:
+ normalized[base_key] = [value]
+ return normalized
+
+ def _global_pages_helper(self, path: str, field: str) -> object:
+ target = self.resolve(path)
+ if target.kind != "content" or target.source_path is None:
+ return None
+
+ page_html, page_metadata = self._render_content_without_templates(target)
+ metadata_value = self._metadata_field_value(page_metadata, field)
+ if metadata_value is not None:
+ return metadata_value
+ if field in {"content", "html"}:
+ return page_html
+ if field == "relurl":
+ return normalize_route(path)
+ return None
+
+ def _metadata_field_value(self, metadata: dict[str, object], field: str) -> object:
+ if field in metadata:
+ return metadata[field]
+ for key, value in metadata.items():
+ if isinstance(key, str) and key.lower() == field.lower():
+ return value
+ return None
diff --git a/src/httk/web/model/config.py b/src/httk/web/model/config.py
index ee1fcec..221aa10 100644
--- a/src/httk/web/model/config.py
+++ b/src/httk/web/model/config.py
@@ -12,6 +12,7 @@ class SiteConfig:
functions_subdir: str = "functions"
baseurl: str | None = None
compatibility_mode: bool = False
+ config_name: str = "config"
@classmethod
def from_srcdir(
@@ -20,8 +21,14 @@ def from_srcdir(
*,
baseurl: str | None = None,
compatibility_mode: bool = False,
+ config_name: str = "config",
) -> Self:
- return cls(srcdir=Path(srcdir).resolve(), baseurl=baseurl, compatibility_mode=compatibility_mode)
+ return cls(
+ srcdir=Path(srcdir).resolve(),
+ baseurl=baseurl,
+ compatibility_mode=compatibility_mode,
+ config_name=config_name,
+ )
@property
def content_dir(self) -> Path:
@@ -37,4 +44,11 @@ def template_dir(self) -> Path:
@property
def functions_dir(self) -> Path:
- return self.srcdir / self.functions_subdir
+ primary = self.srcdir / self.functions_subdir
+ if primary.exists():
+ return primary
+ if self.compatibility_mode:
+ legacy = self.srcdir / "_functions"
+ if legacy.exists():
+ return legacy
+ return primary
diff --git a/src/httk/web/renderers/_frontmatter.py b/src/httk/web/renderers/_frontmatter.py
index 3d886ca..0307356 100644
--- a/src/httk/web/renderers/_frontmatter.py
+++ b/src/httk/web/renderers/_frontmatter.py
@@ -39,6 +39,8 @@ def split_front_matter(text: str) -> tuple[dict[str, Any], str]:
target_list = normalized[base_key]
if isinstance(value, list):
target_list.extend(value)
+ elif isinstance(value, str):
+ target_list.extend([x.strip() for x in value.split(",") if x.strip()])
elif value is not None:
target_list.append(value)
else:
diff --git a/src/httk/web/templating/_legacy_formatter.py b/src/httk/web/templating/_legacy_formatter.py
new file mode 100644
index 0000000..b2a7e69
--- /dev/null
+++ b/src/httk/web/templating/_legacy_formatter.py
@@ -0,0 +1,185 @@
+import ast
+import string
+from html import escape
+from typing import Any, Mapping, Sequence
+
+from markupsafe import Markup
+
+
+class UnquotedText(str):
+ """Marker type for values that should bypass auto-escaping."""
+
+
+class HttkTemplateFormatter(string.Formatter):
+ def __init__(self) -> None:
+ super().__init__()
+ self._current_args: Sequence[Any] = ()
+ self._current_kwargs: Mapping[str, Any] = {}
+
+ def format_field(self, value: Any, spec: str) -> str:
+ return self._format_field(value, spec, quote=None)
+
+ def _format_field(self, value: Any, spec: str, quote: bool | None) -> str:
+ if spec == "unquoted" or spec.startswith("unquoted:"):
+ return self._format_field(value, spec[len("unquoted:") :], quote=False)
+ if spec == "quote" or spec.startswith("quote:"):
+ return self._format_field(value, spec[len("quote:") :], quote=True)
+
+ if spec.startswith("repeat:"):
+ template = spec.partition("::")[-1]
+ new_kwargs: dict[str, Any] = dict(self._current_kwargs)
+ if "item" in new_kwargs:
+ prior_items = new_kwargs.get("items")
+ if isinstance(prior_items, list):
+ new_kwargs["items"] = [new_kwargs["item"]] + prior_items
+ else:
+ new_kwargs["items"] = [new_kwargs["item"]]
+ if "index" in new_kwargs:
+ prior_indices = new_kwargs.get("indices")
+ if isinstance(prior_indices, list):
+ new_kwargs["indices"] = [new_kwargs["index"]] + prior_indices
+ else:
+ new_kwargs["indices"] = [new_kwargs["index"]]
+ if "index1" in new_kwargs:
+ prior_indices = new_kwargs.get("indices")
+ if isinstance(prior_indices, list):
+ new_kwargs["indices"] = [new_kwargs["index1"]] + prior_indices
+ else:
+ new_kwargs["indices"] = [new_kwargs["index1"]]
+
+ def update_and_return(update: dict[str, Any]) -> dict[str, Any]:
+ new_kwargs.update(update)
+ return new_kwargs
+
+ if value is None:
+ raise ValueError(f"HttkTemplateFormatter: asked to loop over None for spec: {spec}")
+ if isinstance(value, dict):
+ return "".join(
+ [
+ self.format(
+ template,
+ **(update_and_return({"item": value[key], "index": key, "index1": key})),
+ )
+ for key in value
+ ]
+ )
+ if not isinstance(value, Sequence):
+ return ""
+ return "".join(
+ [
+ self.format(
+ template,
+ **(update_and_return({"item": value[i], "index": i, "index1": i + 1})),
+ )
+ for i in range(len(value))
+ ]
+ )
+
+ if spec == "call" or spec.startswith("call:"):
+ callargs, _sep, newspec = spec.partition("::")
+ callargs_list = callargs.split(":")
+ parsed_callargs: list[Any] = []
+ for arg in callargs_list:
+ if arg.startswith("{") and arg.endswith("}"):
+ parsed_callargs.append(self.get_field(arg[1:-1], self._current_args, self._current_kwargs)[0])
+ else:
+ parsed_callargs.append(arg)
+
+ if hasattr(value, "__repr__") and ("of float object" in repr(value) or "of int object" in repr(value)):
+ for callarg_idx in range(1, len(parsed_callargs)):
+ try:
+ if parsed_callargs[callarg_idx] == "nan":
+ parsed_callargs[callarg_idx] = float("nan")
+ else:
+ parsed_callargs[callarg_idx] = ast.literal_eval(str(parsed_callargs[callarg_idx]))
+ except (ValueError, SyntaxError):
+ pass
+
+ if not callable(value):
+ raise TypeError(f"Templateengine_httk: tried to call non-callable value: {value!r}")
+ result = value(*parsed_callargs[1:])
+ return self._format_field(result, newspec, quote=quote)
+
+ if spec.startswith("getitem:") or spec.startswith("getattr:"):
+ x, _dummy, newspec = spec.partition(":")[2].partition("::")
+ call_func: str | None = None
+ if ":call" in x:
+ call_func = x.partition(".")[-1].split(":")[0]
+ x = x.partition(".")[0]
+ idx: Any
+ if x.startswith("{") and x.endswith("}"):
+ idx = self.get_field(x[1:-1], self._current_args, self._current_kwargs)[0]
+ else:
+ idx = x
+ try:
+ val = value[idx] if spec.startswith("getitem:") else getattr(value, str(idx))
+ except (TypeError, KeyError, AttributeError, IndexError):
+ try:
+ int_idx = int(str(idx))
+ val = value[int_idx] if spec.startswith("getitem:") else ""
+ except (TypeError, ValueError, KeyError, IndexError):
+ return ""
+
+ if newspec == "" and call_func is None:
+ return str(val)
+
+ if call_func is not None:
+ val = getattr(val, call_func)
+ newspec = "call" + spec.partition(":call")[-1]
+ return self._format_field(val, newspec, quote=quote)
+
+ if (
+ spec.startswith("if:")
+ or spec.startswith("if-not:")
+ or spec.startswith("if-set:")
+ or spec.startswith("if-unset:")
+ ):
+ outcome = (
+ (spec.startswith("if:") and bool(value))
+ or (spec.startswith("if-not:") and not bool(value))
+ or (spec.startswith("if-set:") and value is not None)
+ or (spec.startswith("if-unset:") and value is None)
+ )
+
+ if "::else::" in spec:
+ if not outcome:
+ template = spec.partition("::else::")[-1]
+ else:
+ template = spec.partition("::else::")[0].partition("::")[-1]
+ else:
+ if not outcome:
+ return ""
+ template = spec.partition("::")[-1]
+ return self.format(template, **dict(self._current_kwargs))
+
+ if value is None:
+ return ""
+
+ output = super().format_field(value, spec)
+ if quote is None:
+ quote = not isinstance(value, (UnquotedText, Markup))
+ if quote:
+ output = escape(output, quote=True).replace("'", "'")
+ return output
+
+ def get_field(
+ self,
+ field_name: str,
+ args: Sequence[Any],
+ kwargs: Mapping[str, Any],
+ ) -> tuple[Any, Any]:
+ try:
+ return super().get_field(field_name, args, kwargs)
+ except (KeyError, AttributeError):
+ return None, field_name
+
+ def vformat(self, format_string: str, args: Sequence[Any], kwargs: Mapping[str, Any]) -> str:
+ previous_args = self._current_args
+ previous_kwargs = self._current_kwargs
+ self._current_args = args
+ self._current_kwargs = kwargs
+ try:
+ return super().vformat(format_string, args, kwargs)
+ finally:
+ self._current_args = previous_args
+ self._current_kwargs = previous_kwargs
diff --git a/src/httk/web/templating/httk_compat.py b/src/httk/web/templating/httk_compat.py
index f69ea1d..0cfecdb 100644
--- a/src/httk/web/templating/httk_compat.py
+++ b/src/httk/web/templating/httk_compat.py
@@ -1,5 +1,9 @@
from pathlib import Path
+from markupsafe import Markup
+
+from ._legacy_formatter import HttkTemplateFormatter, UnquotedText
+from .base import TemplateRenderInput
from .jinja2_engine import JinjaTemplateEngine
LEGACY_TEMPLATE_SUFFIXES: tuple[str, ...] = (
@@ -21,3 +25,42 @@ class HttkCompatTemplateEngine(JinjaTemplateEngine):
def __init__(self, template_dir: Path) -> None:
super().__init__(template_dir, template_suffixes=LEGACY_TEMPLATE_SUFFIXES)
+ self._legacy_formatter = HttkTemplateFormatter()
+
+ def render(self, render_input: TemplateRenderInput) -> str:
+ template_key = self._resolve_template(render_input.template_name)
+ base_key = self._resolve_template(render_input.base_template_name)
+
+ context = dict(render_input.context)
+ content = render_input.content_html
+ if template_key is not None:
+ content = self._render_with_resolved_template(template_key, content=content, context=context)
+
+ if base_key is not None:
+ content = self._render_with_resolved_template(base_key, content=content, context=context)
+
+ return content
+
+ def render_fragment(self, *, template_name: str, context: dict[str, object]) -> str | None:
+ template_key = self._resolve_fragment_template(template_name)
+ if template_key is None:
+ return None
+
+ if template_key.endswith(".httkweb.html"):
+ template_text = (self.template_dir / template_key).read_text(encoding="utf-8")
+ return self._legacy_formatter.format(template_text, **dict(context))
+
+ template = self._environment.get_template(template_key)
+ return template.render(**context)
+
+ def _render_with_resolved_template(self, template_key: str, *, content: str, context: dict[str, object]) -> str:
+ if not template_key.endswith(".httkweb.html"):
+ template = self._environment.get_template(template_key)
+ working = dict(context)
+ working["content"] = Markup(content)
+ return template.render(**working)
+
+ template_text = (self.template_dir / template_key).read_text(encoding="utf-8")
+ working = dict(context)
+ working["content"] = UnquotedText(content)
+ return self._legacy_formatter.format(template_text, **working)
diff --git a/tests/test_api.py b/tests/test_api.py
index 33540ba..1caf399 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -72,3 +72,111 @@ def test_publish_compatibility_mode_prefers_httkweb_templates(tmp_path: Path) ->
rendered = (out / "index.html").read_text(encoding="utf-8")
assert "legacy=" in rendered
assert "modern=" not in rendered
+
+
+def test_create_asgi_app_compatibility_mode_supports_legacy_repeat(tmp_path: Path) -> None:
+ src = tmp_path / "src"
+ (src / "content").mkdir(parents=True)
+ (src / "static").mkdir(parents=True)
+ (src / "templates").mkdir(parents=True)
+
+ (src / "content" / "index.md").write_text(
+ "---\n"
+ "template: default\n"
+ "menuitems:\n"
+ " - alpha\n"
+ " - beta\n"
+ "---\n\n"
+ "hello",
+ encoding="utf-8",
+ )
+ (src / "templates" / "default.httkweb.html").write_text(
+ "{menuitems:repeat::{{item}} } {content}",
+ encoding="utf-8",
+ )
+
+ app = create_asgi_app(src, compatibility_mode=True)
+
+ with TestClient(app) as client:
+ response = client.get("/")
+
+ assert response.status_code == 200
+ assert "alpha " in response.text
+ assert "beta " in response.text
+ assert "hello" in response.text
+
+
+def test_create_asgi_app_compatibility_mode_supports_legacy_pages_call(tmp_path: Path) -> None:
+ src = tmp_path / "src"
+ (src / "content").mkdir(parents=True)
+ (src / "static").mkdir(parents=True)
+ (src / "templates").mkdir(parents=True)
+
+ (src / "content" / "about.md").write_text("---\ntitle: About Page\n---\n\nAbout body", encoding="utf-8")
+ (src / "content" / "index.md").write_text(
+ "---\n"
+ "template: default\n"
+ "menuitems:\n"
+ " - about\n"
+ "---\n\n"
+ "home",
+ encoding="utf-8",
+ )
+ (src / "templates" / "default.httkweb.html").write_text(
+ "{menuitems:repeat::{{pages:call:{{item}}:title}} }{content}",
+ encoding="utf-8",
+ )
+
+ app = create_asgi_app(src, compatibility_mode=True)
+
+ with TestClient(app) as client:
+ response = client.get("/")
+
+ assert response.status_code == 200
+ assert "About Page " in response.text
+
+
+def test_create_asgi_app_compatibility_mode_loads_config_frontmatter(tmp_path: Path) -> None:
+ src = tmp_path / "src"
+ (src / "content").mkdir(parents=True)
+ (src / "static").mkdir(parents=True)
+ (src / "templates").mkdir(parents=True)
+
+ (src / "config.httkweb").write_text("---\nmenuitems-list: index, contact, bare\n---\n", encoding="utf-8")
+ (src / "content" / "index.md").write_text("---\ntemplate: default\n---\n\nhello", encoding="utf-8")
+ (src / "templates" / "default.httkweb.html").write_text(
+ "{menuitems:repeat::{{item}} } {content}",
+ encoding="utf-8",
+ )
+
+ app = create_asgi_app(src, compatibility_mode=True)
+ with TestClient(app) as client:
+ response = client.get("/")
+
+ assert response.status_code == 200
+ assert "index " in response.text
+ assert "contact " in response.text
+ assert "bare " in response.text
+
+
+def test_create_asgi_app_compatibility_mode_runs_init_function(tmp_path: Path) -> None:
+ src = tmp_path / "src"
+ (src / "content").mkdir(parents=True)
+ (src / "static").mkdir(parents=True)
+ (src / "templates").mkdir(parents=True)
+ (src / "functions").mkdir(parents=True)
+
+ (src / "functions" / "init.py").write_text(
+ "def execute(global_data, **kwargs):\n"
+ " global_data['website_name'] = 'Legacy Site'\n",
+ encoding="utf-8",
+ )
+ (src / "content" / "index.md").write_text("---\ntemplate: default\n---\n\nhello", encoding="utf-8")
+ (src / "templates" / "default.httkweb.html").write_text("{website_name}:{content}", encoding="utf-8")
+
+ app = create_asgi_app(src, compatibility_mode=True)
+ with TestClient(app) as client:
+ response = client.get("/")
+
+ assert response.status_code == 200
+ assert "Legacy Site:" in response.text