commit 4456fcf33d07d214437552da9aea6dd524bbe3ae
parent 506327e34f6168796ca67aa701746e41e305c323
Author: MikoĊaj Lenczewski <mblenczewski@gmail.com>
Date: Sun, 12 Apr 2026 22:46:13 +0100
Built out layout, added index generation, improved templating
Diffstat:
27 files changed, 821 insertions(+), 271 deletions(-)
diff --git a/.editorconfig b/.editorconfig
@@ -5,14 +5,14 @@ end_of_line = lf
insert_final_newline = true
charset = utf-8
-[*.{c,h}]
-indent_style = tab
-indent_size = 8
-
[*.{sh}]
indent_style = tab
indent_size = 8
+[*.{py}]
+indent_style = space
+indent_size = 4
+
[*.{html,css,js}]
indent_style = space
indent_size = 2
diff --git a/.gitignore b/.gitignore
@@ -1 +1,12 @@
+# build artefacts
+gen/
+out/
+
+log*
+
+# python artefacts
+**/__pycache__/
+
+# per-user files
**/.*.swp
+tags
diff --git a/build.sh b/build.sh
@@ -0,0 +1,61 @@
+#!/bin/sh
+
+CATEGORIES="science society sport"
+
+set -ex
+
+mkdir -p gen out
+
+# copy over static assets
+cp -rv src/css/ src/js/ src/res/ public/ out/
+
+# template category pages
+for category in $CATEGORIES; do
+ mkdir -p gen/$category out/$category
+
+ # template category article pages
+ for article in src/html/$category/*.html; do
+ name="$(basename -s .html $article)"
+
+ # template article snippet
+ ./tools/template.py \
+ $article \
+ gen/$category/$name.list.html \
+ templates/listed-article.html \
+ url="/$category/$name.html"
+
+ # template full article
+ ./tools/template.py \
+ $article \
+ gen/$category/$name.html \
+ templates/article.html
+
+ ./tools/template.py \
+ gen/$category/$name.html \
+ out/$category/$name.html \
+ templates/global.html
+
+ done
+
+ # generate category index page fragment
+ ./tools/index.py \
+ gen/$category.index.html \
+ templates/listitem.html \
+ templates/index.html \
+ "/$category" \
+ gen/$category/*.list.html
+done
+
+# template main page
+./tools/index.py \
+ gen/index.html \
+ templates/listitem.html \
+ templates/index.html \
+ "/" \
+ gen/*.index.html
+
+# template static pages
+./tools/template.py gen/index.html out/index.html templates/global.html title="Lektura"
+./tools/template.py gen/science.index.html out/science.html templates/global.html title="Lektura: Science"
+./tools/template.py gen/society.index.html out/society.html templates/global.html title="Lektura: Society"
+./tools/template.py gen/sport.index.html out/sport.html templates/global.html title="Lektura: Sport"
diff --git a/clean.sh b/clean.sh
@@ -0,0 +1,5 @@
+#!/bin/sh
+
+set -ex
+
+rm -rf gen/ out/
diff --git a/index.html b/index.html
@@ -1,76 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
- <head>
- <title>Lektura</title>
-
- <meta charset="utf8" />
- <meta name="viewport" content="initial-scale=1.0, width=device-width" />
-
- <link rel="stylesheet" href="/style.css" />
- <link rel="stylesheet" href="/navbar.css" />
- <link rel="stylesheet" href="/layout.css" />
- </head>
- <body>
- <header>
- <div class="center pad-1em">
- <img style="max-width: 2em" src="/logo.svg" />
- </div>
- <hr />
- <nav id="global-menu" class="center pad-1em navbar">
- <input id="global-menu-toggle" type="checkbox" class="hidden navbar-toggle" />
- <ul class="navbar-menu">
- <li>
- <label class="navbar-toggle-label" for="global-menu-toggle">
- <img src="menu.svg" />
- </label>
- </li>
- <li><a href="index.html">Index</a></li>
- <li><a href="science.html">Science</a></li>
- <li><a href="sport.html">Sport</a></li>
- <li><a href="society.html">Society</a></li>
- </ul>
- </nav>
- <hr />
- </header>
- <main class="grid center">
- <div id="banner" class="grid">
- <div id="breaking-news">
- One
- </div>
- <div id="breaking-news-1">
- Two
- </div>
- <div id="breaking-news-2">
- Three
- </div>
- <div id="breaking-news-3">
- Four
- </div>
- </div>
- <div id="content">
- Lorem Ipsum Dolor Sit Amet.
- </div>
- <div id="sidebar">
- <section id="recent">
- <h3>Recent</h3>
- <ol>
- <li><a href="recent1.html">Recent 1</a></li>
- <li><a href="recent2.html">Recent 2</a></li>
- <li><a href="recent3.html">Recent 3</a></li>
- </ol>
- </section>
- <section id="polls">
- <h3>Polls</h3>
- <ol>
- <li><a href="poll1.html">Poll 1</a></li>
- <li><a href="poll2.html">Poll 2</a></li>
- <li><a href="poll3.html">Poll 3</a></li>
- </ol>
- </section>
- </div>
- </main>
- <footer>
- </footer>
- </body>
- <script src="/script.js"></script>
-</html>
diff --git a/layout.css b/layout.css
@@ -1,88 +0,0 @@
-main.grid {
- grid-template-rows: minmax(8em, 16em) minmax(8em, 16em) auto; /* TODO */
- grid-template-columns: 3fr 1fr;
-}
-
-#banner {
- height: 8em;
-}
-
-#banner.grid {
- grid-template-rows: 1fr 1fr;
- grid-template-columns: 3fr 1fr 1fr;
-}
-
-#banner > #breaking-news {
- grid-row: 1 / span 2;
- grid-column: 1;
-}
-
-#banner > #breaking-news-1 {
- grid-row: 1;
- grid-column: 2 / span 2;
-}
-
-#banner > #breaking-news-2 {
- grid-row: 2;
- grid-column: 2;
-}
-
-#banner > #breaking-news-3 {
- grid-row: 2;
- grid-column: 3;
-}
-
-#content {
- height: 80em;
- background-style: solid;
- background-color #f00;
-
- /* TODO */
-}
-
-#sidebar {
- /* TODO */
-}
-
-/* css selector explanation
- * --
- * main.grid > #content { ... }
- * --
- * - given an element of type `main` with the class `grid`
- * - for its direct child with id `content`
- */
-
-main.grid > #banner {
- grid-row: 1;
- grid-column: 1 / -1;
-}
-
-/* on mobile ... */
-@media screen and (max-width:576px) {
- /* the sidebar moves to immediately under the navbar */
- main.grid > #sidebar {
- grid-row: 2 / span 1;
- grid-column: 1 / span 2;
- }
-
- /* the content comes after the "sidebar" */
- main.grid > #content {
- grid-row: 3;
- grid-column: 1 / span 2;
- }
-}
-
-/* on desktop ... */
-@media screen and (min-width:577px) {
- /* the sidebar sits on the right-hand column (25% width) */
- main.grid > #sidebar {
- grid-row: 2 / span 1;
- grid-column: 2 / span 1;
- }
-
- /* the contents sit on the left-hand column (75% width) */
- main.grid > #content {
- grid-row: 2 / span 2;
- grid-column: 1 / span 1;
- }
-}
diff --git a/logo.svg b/logo.svg
@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
-<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
- <path d="M0 0 L512 0 L512 512 L0 512 L0 0" fill="rgb(0,0,0)" />
- <path d="M240 496 L80 336 L192 224 L192 16 L240 16 L240 240 L144 336 L240 432 L240 496" fill="rgb(255,255,255)" />
- <path d="M256 400 L192 336 L256 272 L320 336 L256 400" fill="rgb(255,165,0)" />
- <path d="M272 496 L432 336 L320 224 L320 16 L272 16 L272 240 L368 336 L272 432 L272 496" fill="rgb(255,255,255)" />
-</svg>
-
diff --git a/navbar.css b/navbar.css
@@ -1,58 +0,0 @@
-.navbar {
- padding-left: 1em;
- padding-right: 1em;
-}
-
-.navbar-menu {
- padding: 0;
- margin: 0;
- list-style: none;
-}
-
-.navbar-menu li {
- display: inline-block;
-}
-
-.navbar-toggle-label {
- cursor: pointer;
-}
-
-/* css selector explanation
- * --
- * .navbar-toggle ~ .navbar-menu li { ... }
- * --
- * - given an element with the `navbar-toggle` class,
- * - for all subsequent siblings with the `navbar-menu` class,
- * - for all their children of type <li>
- */
-
-/* on mobile ... */
-@media screen and (max-width:576px) {
- /* always display the hamburger menu */
- .navbar-toggle ~ .navbar-menu li:first-child {
- display: inline-block;
- }
-
- /* never display the remaining list items ... */
- .navbar-toggle ~ .navbar-menu li {
- display: none;
- }
-
- /* ... unless the hamburger menu button has been clicked */
- .navbar-toggle:checked ~ .navbar-menu li {
- display: block;
- }
-}
-
-/* on desktop ... */
-@media screen and (min-width:577px) {
- /* never display the hamburger menu */
- .navbar-menu li:first-child {
- display: none;
- }
-
- /* always display the remaining list items */
- .navbar-menu li {
- display: inline-block;
- }
-}
diff --git a/src/css/article.css b/src/css/article.css
@@ -0,0 +1,8 @@
+.articles {
+ list-style: none;
+ padding: 0;
+}
+
+.article {
+ background-color: mintcream;
+}
diff --git a/src/css/layout.css b/src/css/layout.css
@@ -0,0 +1,112 @@
+main.grid {
+ grid-template-rows: minmax(8em, 12em) auto auto; /* TODO */
+ grid-template-columns: 3fr 1fr;
+}
+
+#banner {
+ /* TODO */
+}
+
+#banner.grid {
+ grid-template-rows: 1fr 1fr;
+ grid-template-columns: 3fr 1fr 1fr;
+
+ background-color: lightgrey;
+}
+
+#banner > #breaking-news {
+ grid-row: 1 / span 2;
+ grid-column: 1;
+
+ background-color: red;
+}
+
+#banner > #breaking-news-1 {
+ grid-row: 1;
+ grid-column: 2 / span 2;
+
+ background-color: magenta;
+}
+
+#banner > #breaking-news-2 {
+ grid-row: 2;
+ grid-column: 2;
+
+ background-color: blue;
+}
+
+#banner > #breaking-news-3 {
+ grid-row: 2;
+ grid-column: 3;
+
+ background-color: yellow;
+}
+
+#content {
+ height: 80em;
+ background-color: grey;
+
+ /* TODO */
+}
+
+#sidebar {
+ background-color: cyan;
+
+ /* TODO */
+}
+
+/* css selector explanation
+ * --
+ * main.grid > #content { ... }
+ * --
+ * - given an element of type `main` with the class `grid`
+ * - for its direct child with id `content`
+ */
+
+main.grid > #banner {
+ grid-row: 1 / span 1;
+ grid-column: 1 / -1;
+}
+
+/* on mobile ... */
+@media screen and (max-width:576px) {
+ /* the sidebar moves to immediately under the banner */
+ main.grid > #sidebar {
+ grid-row: 2 / span 1;
+ grid-column: 1 / span 2;
+ }
+
+ /* the content comes after the "sidebar" */
+ main.grid > #content {
+ grid-row: 3;
+ grid-column: 1 / span 2;
+ }
+}
+
+/* on smaller screens ... */
+@media screen and (max-width:949px) {
+ /* the top banner has one less tile (for readability) ... */
+ #banner > #breaking-news-2 {
+ grid-column: 2 / span 2;
+ }
+
+ /* final tile is not displayed */
+ #banner > #breaking-news-3 {
+ display: none;
+ }
+}
+
+/* on desktop ... */
+@media screen and (min-width:577px) {
+ /* the sidebar sits on the right-hand column (25% width) */
+ main.grid > #sidebar {
+ grid-row: 2 / span 1;
+ grid-column: 2 / span 1;
+ }
+
+ /* the contents sit on the left-hand column (75% width) */
+ main.grid > #content {
+ grid-row: 2 / span 2;
+ grid-column: 1 / span 1;
+ }
+}
diff --git a/src/css/navbar.css b/src/css/navbar.css
@@ -0,0 +1,92 @@
+.navbar {
+}
+
+.navbar-menu {
+ margin-top: 0;
+ margin-bottom: 0;
+ list-style: none;
+}
+
+.navbar-menu li {
+ display: inline-block;
+}
+
+.navbar-toggle-label {
+ cursor: pointer;
+}
+
+/* css selector explanation
+ * --
+ * .navbar-toggle ~ .navbar-menu li { ... }
+ * --
+ * - given an element with the `navbar-toggle` class,
+ * - for all subsequent siblings with the `navbar-menu` class,
+ * - for all their children of type <li>
+ *
+ * --
+ * .navbar-toggle:checked + .navbar-menu { ... }
+ * --
+ * - given an element with the `navbar-toggle` class, that is currently checked
+ * - if its immediate (first next) sibling has the `navbar-menu` class
+ */
+
+/* on mobile ... */
+@media screen and (max-width:576px) {
+ /* always display the hamburger menu button */
+ .navbar-toggle-label {
+ display: inline-block;
+ }
+
+ /* dont pad navbar menu ... */
+ .navbar-menu {
+ padding: 0;
+ }
+
+ /* ... unless the hamburger menu button has been clicked */
+ .navbar-toggle:checked + .navbar-menu {
+ padding: 1em;
+ }
+
+ /* never display the remaining list items ... */
+ .navbar-toggle ~ .navbar-menu li {
+ display: none;
+ }
+
+ /* ... unless the hamburger menu button has been clicked */
+ .navbar-toggle:checked ~ .navbar-menu li {
+ display: block;
+ }
+
+ /* never display the bottom navbar border ... */
+ .navbar-divider {
+ display: none;
+ }
+
+ /* ... unless the hamburger menu buttong has been clicked */
+ .navbar-toggle:checked ~ .navbar-divider {
+ display: block;
+ }
+}
+
+/* on desktop ... */
+@media screen and (min-width:577px) {
+ /* never display the hamburger menu */
+ .navbar-toggle-label {
+ display: none;
+ }
+
+ /* always have 1em of padding around the navbar items */
+ .navbar-menu {
+ padding: 1em;
+ }
+
+ /* always display the remaining list items */
+ .navbar-menu li {
+ display: inline-block;
+ }
+
+ /* always display the bottom navbar border */
+ .navbar-divider {
+ display: block;
+ }
+}
diff --git a/src/css/style.css b/src/css/style.css
@@ -0,0 +1,48 @@
+body, hr, html {
+ height: 100%;
+ margin: 0!important;
+ padding: 0!important;
+}
+
+hr {
+ display: block;
+ height: 1px;
+ border: 0;
+ border-top: 1px solid;
+ border-color: inherit;
+}
+
+.center {
+ max-width: 80em;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.hidden {
+ display: none;
+}
+
+.flex {
+ display: flex;
+ justify-content: space-between;
+}
+
+.grid {
+ display: grid;
+}
+
+.pad-l-1em {
+ padding-left: 1em;
+}
+
+.pad-r-1em {
+ padding-right: 1em;
+}
+
+.pad-t-1em {
+ padding-top: 1em;
+}
+
+.pad-b-1em {
+ padding-bottom: 1em;
+}
diff --git a/src/html/science/article.html b/src/html/science/article.html
@@ -0,0 +1,16 @@
+title: Article
+tags:
+
+created-iso: 2026-04-10T21:18:37
+edited-iso: 2026-04-10T21:18:37
+
+snippet: Lorem Ipsum Dolor Sit Amet.
+caption: My figure caption
+
+img-cap: my image
+img-src: /res/img.png
+img-alt: my image alt
+---
+<p>
+ Article Body.
+</p>
diff --git a/src/html/society/article.html b/src/html/society/article.html
@@ -0,0 +1,16 @@
+title: Article
+tags:
+
+created-iso: 2026-04-13T21:18:37
+edited-iso: 2026-04-13T21:18:37
+
+snippet: Lorem Ipsum Dolor Sit Amet.
+caption: My figure caption
+
+img-cap: my image
+img-src: /res/img.png
+img-alt: my image alt
+---
+<p>
+ Article Body.
+</p>
diff --git a/src/html/sport/article.html b/src/html/sport/article.html
@@ -0,0 +1,16 @@
+title: Article
+tags:
+
+created-iso: 2026-04-12T21:18:37
+edited-iso: 2026-04-12T21:18:37
+
+snippet: Lorem Ipsum Dolor Sit Amet.
+caption: My figure caption
+
+img-cap: my image
+img-src: /res/img.png
+img-alt: my image alt
+---
+<p>
+ Article Body.
+</p>
diff --git a/script.js b/src/js/script.js
diff --git a/menu.svg b/src/res/menu.svg
diff --git a/style.css b/style.css
@@ -1,37 +0,0 @@
-body, hr, html {
- height: 100%;
- margin: 0!important;
- padding: 0!important;
-}
-
-hr {
- display: block;
- height: 1px;
- border: 0;
- border-top: 1px solid;
- border-color: inherit;
-}
-
-.center {
- max-width: 80em;
- margin-left: auto;
- margin-right: auto;
- /* padding: 1em; */
-}
-
-.pad-1em {
- padding: 1em;
-}
-
-.hidden {
- display: none;
-}
-
-.flex {
- display: flex;
- justify-content: space-between;
-}
-
-.grid {
- display: grid;
-}
diff --git a/templates/article.html b/templates/article.html
@@ -0,0 +1,27 @@
+<article class="article">
+ <figure class="article-img">
+ <img title="%img-cap%" src="%img-src%" alt="%img-alt%" />
+ <figcaption>%caption%</figcaption>
+ </figure>
+ <dl class="article-info">
+ <dt class="article-info-meta">Author:</dt>
+ <dd title="Author">
+ <span>%author%</span>
+ </dd>
+ <dt class="article-info-meta">Posted:</dt>
+ <dd title="Posted">
+ <time id="post-date" datetime="%created-iso%">%created-str%</time>
+ </dd>
+ <dt class="article-info-meta">Edited:</dt>
+ <dd title="Edited">
+ <time id="edit-date" datetime="%edited-iso%">%edited-str%</time>
+ </dd>
+ </dl>
+ <div class="article-text">
+ <h2><a href="%url%">%title%</a></h2>
+ <p>%snippet%</p>
+ <div>
+%content%
+ </div>
+ </div>
+</article>
diff --git a/templates/global.html b/templates/global.html
@@ -0,0 +1,79 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <title>%title%</title>
+
+ <meta charset="utf8" />
+ <meta name="viewport" content="initial-scale=1.0, width=device-width" />
+
+ <link rel="stylesheet" href="/css/style.css" />
+ <link rel="stylesheet" href="/css/navbar.css" />
+ <link rel="stylesheet" href="/css/layout.css" />
+ <link rel="stylesheet" href="/css/article.css" />
+ </head>
+ <body>
+ <header>
+ <div class="flex center pad-t-1em pad-b-1em">
+ <img style="max-width: 2em" src="/res/logo.svg" />
+ <label class="navbar-toggle-label" for="global-menu-toggle">
+ <img src="/res/menu.svg" />
+ </label>
+ </div>
+ <hr />
+ <nav id="global-menu" class="navbar">
+ <input id="global-menu-toggle" type="checkbox" class="hidden navbar-toggle" />
+ <ul class="center navbar-menu">
+ <li><a href="/index.html">Index</a></li>
+ <li><a href="/science.html">Science</a></li>
+ <li><a href="/society.html">Society</a></li>
+ <li><a href="/sport.html">Sport</a></li>
+ </ul>
+ <hr class="navbar-divider" />
+ </nav>
+ </header>
+ <main class="grid center">
+ <div id="banner" class="grid">
+ <div id="breaking-news">
+ One
+ </div>
+ <div id="breaking-news-1">
+ Two
+ </div>
+ <div id="breaking-news-2">
+ Three
+ </div>
+ <div id="breaking-news-3">
+ Four
+ </div>
+ </div>
+ <div id="content">
+%content%
+ </div>
+ <div id="sidebar">
+ <section id="recent">
+ <h3>Recent</h3>
+ <ol>
+ <li><a href="recent1.html">Recent 1</a></li>
+ <li><a href="recent2.html">Recent 2</a></li>
+ <li><a href="recent3.html">Recent 3</a></li>
+ </ol>
+ </section>
+ <section id="polls">
+ <h3>Polls</h3>
+ <ol>
+ <li><a href="poll1.html">Poll 1</a></li>
+ <li><a href="poll2.html">Poll 2</a></li>
+ <li><a href="poll3.html">Poll 3</a></li>
+ </ol>
+ </section>
+ </div>
+ </main>
+ <footer>
+ <hr />
+ <div class="center pad-t-1em pad-b-1em">
+ <span>Lektura © 2026</span>
+ </div>
+ </footer>
+ </body>
+ <script src="/js/script.js"></script>
+</html>
diff --git a/templates/index.html b/templates/index.html
@@ -0,0 +1,5 @@
+<section id="content-body">
+ <ul class="articles">
+%content%
+ </ul>
+</section>
diff --git a/templates/listed-article.html b/templates/listed-article.html
@@ -0,0 +1,24 @@
+<article class="article">
+ <figure class="article-img">
+ <img title="%img-cap%" src="%img-src%" alt="%img-alt%" />
+ <figcaption>%caption%</figcaption>
+ </figure>
+ <dl class="article-info">
+ <dt class="article-info-meta">Author:</dt>
+ <dd title="Author">
+ <span>%author%</span>
+ </dd>
+ <dt class="article-info-meta">Posted:</dt>
+ <dd title="Posted">
+ <time id="post-date" datetime="%created-iso%">%created-str%</time>
+ </dd>
+ <dt class="article-info-meta">Edited:</dt>
+ <dd title="Edited">
+ <time id="edit-date" datetime="%edited-iso%">%edited-str%</time>
+ </dd>
+ </dl>
+ <div class="article-text">
+ <h2><a href="%url%">%title%</a></h2>
+ <p>%snippet%</p>
+ </div>
+</article>
diff --git a/templates/listitem.html b/templates/listitem.html
@@ -0,0 +1 @@
+%content%
diff --git a/tools/convert.py b/tools/convert.py
@@ -0,0 +1,16 @@
+#!/usr/bin/env python3
+
+import argparse
+
+argparser = argparse.ArgumentParser(
+ description='Converts markdown source files into html fragments')
+
+argparser.add_argument(['-s', '--src'], 'src', required=True)
+argparser.add_argument(['-d', '--dst'], 'dst', required=True)
+
+if __name__ == '__main__':
+ args = argparser.parse_args()
+
+ print(args)
+
+
diff --git a/tools/index.py b/tools/index.py
@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+
+import argparse
+import html.parser
+import os
+import pathlib
+
+import template
+
+
+def dirpath(path):
+ if os.path.isdir(path):
+ return path
+
+ raise argparse.ArgumentError()
+
+
+argparser = argparse.ArgumentParser(
+ description='Indexes a given set of html fragments')
+
+argparser.add_argument('dst', type=argparse.FileType('w'),
+ help='The destination html fragment')
+argparser.add_argument('art_tpl', metavar='element-tpl', type=argparse.FileType('r'),
+ help='The html fragment template for each list element')
+argparser.add_argument('idx_tpl', metavar='index-tpl', type=argparse.FileType('r'),
+ help='The html fragment template for the index page')
+argparser.add_argument('urlroot', type=str,
+ help='The base url for each article (e.g. "/sport")')
+argparser.add_argument('srcs', nargs='*',
+ help='A list of html fragments to index')
+
+
+class TagParser(html.parser.HTMLParser):
+ def __init__(self, **kwargs):
+ super().__init__()
+
+ self.filter_tag = kwargs.get('tag', None)
+ self.filter_id = kwargs.get('id', None)
+ self.found_data = ''
+ self.found_attrs = []
+
+ self.have_tag = False
+ self.have_data = False
+
+ @staticmethod
+ def get_attr(key, attrs, default):
+ for attr in attrs:
+ if attr[0] == key:
+ return attr[1]
+
+ return default
+
+ def handle_starttag(self, tag, attrs):
+ if self.have_tag:
+ return
+
+ if self.filter_tag and self.filter_tag != tag:
+ return
+
+ if self.filter_id and self.filter_id != self.get_attr('id', attrs, ''):
+ return
+
+ self.have_tag = True
+ self.found_attrs = attrs
+
+ def handle_startendtag(self, tag, attrs):
+ self.handle_starttag(tag, attrs)
+
+ def handle_data(self, data):
+ if not self.have_tag:
+ return
+
+ if self.have_tag and self.have_data:
+ return
+
+ self.have_data = True
+ self.found_data = data
+
+
+
+def index(srcs, article_tpl, index_tpl, urlroot):
+ content = ''
+
+ def by_post_date(srcpath):
+ # NOTE: this parser expects at least one element of type `<time>` and
+ # with an id of `post-date` to be present in the given article.
+ # if this requirement is not satisfied, it throws an error
+ parser = TagParser(tag='time', id='post-date')
+ content = template.content(srcpath)
+ parser.feed(content)
+
+ if not parser.have_tag or not parser.have_data:
+ raise Exception(f'Article: {srcpath} lacks element <time id="post-date" ... />')
+
+ isodate = TagParser.get_attr('datetime', parser.found_attrs, None)
+ print(isodate)
+
+ return template.fromisodate(isodate)
+
+ for article in sorted(srcs, key=by_post_date, reverse=True):
+ srcpath = pathlib.Path(article)
+
+ keys = template.article_keys(srcpath)
+ keys |= {
+ 'url': f'{urlroot}/{srcpath.name}',
+ }
+
+ content += template.instantiate(article_tpl, keys)
+
+ keys = { 'content': content }
+
+ return template.instantiate(index_tpl, keys)
+
+
+if __name__ == '__main__':
+ args = argparser.parse_args()
+
+ article_tpl = ''
+ with args.art_tpl as tpl:
+ article_tpl = tpl.read()
+
+ index_tpl = ''
+ with args.idx_tpl as tpl:
+ index_tpl = tpl.read()
+
+ with args.dst as dst:
+ dst.write(index(args.srcs, article_tpl, index_tpl, args.urlroot))
+
diff --git a/tools/template.py b/tools/template.py
@@ -0,0 +1,149 @@
+#!/usr/bin/env python3
+
+import argparse
+import datetime
+import html
+import html.parser
+import os
+import pathlib
+
+
+argparser = argparse.ArgumentParser(
+ description='Templates html fragments into full html pages')
+
+argparser.add_argument('src', type=argparse.FileType('r'),
+ help='The source html fragment')
+argparser.add_argument('dst', type=argparse.FileType('w'),
+ help='The destination html file')
+argparser.add_argument('tpl', type=argparse.FileType('r'),
+ help='The source html template')
+argparser.add_argument('kwargs', nargs='*',
+ help='A list of custom \'key=val\' pairs to replace in the template')
+
+
+def map_kwargs(kwargs):
+ return dict([arg.split('=', maxsplit=1) for arg in kwargs])
+
+
+def titleify(srcpath):
+ slug, ext = srcpath.rsplit('.', maxsplit=1)
+
+ seps = ['-', '_']
+ for sep in seps:
+ slug = slug.replace(sep, ' ')
+
+ frags = [substr.capitalize() for substr in slug.split(' ')]
+ title = ' '.join(frags)
+
+ return title
+
+
+def created(srcpath):
+ info = os.stat(srcpath)
+ return datetime.datetime.fromtimestamp(info.st_ctime)
+
+
+def edited(srcpath):
+ info = os.stat(srcpath)
+ return datetime.datetime.fromtimestamp(info.st_mtime)
+
+
+def fromisodate(isodate):
+ return datetime.datetime.fromisoformat(isodate)
+
+
+TIMESTAMP = '%d %B %Y'
+
+METADATA_SEPARATOR = '---'
+
+
+def readfile(srcpath):
+ data = ''
+ with open(srcpath, 'r') as src:
+ data = src.read()
+
+ if METADATA_SEPARATOR in data:
+ return data.split(METADATA_SEPARATOR, maxsplit=1)
+
+ return '', data
+
+
+def metadata(srcpath):
+ [metadata, *_] = readfile(srcpath)
+
+ keys = {}
+ for line in metadata.split('\n'):
+ line = line.strip()
+ if line:
+ [key, *val] = line.split(':', maxsplit=1)
+ keys[key] = ''.join(val).strip()
+
+ return keys
+
+
+def content(srcpath):
+ [_, *contents] = readfile(srcpath)
+
+ return ' '.join(contents)
+
+
+def article_keys(srcpath):
+ name = pathlib.Path(srcpath).name
+
+ keys = {
+ 'title': titleify(name),
+ 'created-iso': created(srcpath).isoformat(),
+ 'edited-iso': edited(srcpath).isoformat(),
+ 'content': content(srcpath),
+ }
+
+ keys |= metadata(srcpath)
+
+ keys |= {
+ 'created-str': fromisodate(keys['created-iso']).strftime(TIMESTAMP),
+ 'edited-str': fromisodate(keys['edited-iso']).strftime(TIMESTAMP),
+ }
+
+ # print(srcpath, keys)
+
+ return keys
+
+
+def instantiate(tpl, kwargs):
+ splits = []
+
+ cur = 0
+ end = len(tpl)
+ while cur < end:
+ key_start = tpl.find('%', cur)
+ key_end = tpl.find('%', key_start + 1)
+
+ if key_start == -1:
+ splits.append([cur, end, ''])
+ break
+ else:
+ splits.append([cur, key_start, tpl[key_start + 1:key_end]])
+
+ cur = key_end + 1
+
+ out = ''
+ for [tpl_start, tpl_end, key] in splits:
+ out += tpl[tpl_start:tpl_end]
+ out += kwargs.get(key, '')
+
+ return out
+
+
+if __name__ == '__main__':
+ args = argparser.parse_args()
+
+ template = ''
+ with args.tpl as tpl:
+ template = tpl.read()
+
+ with args.dst as dst:
+ keys = article_keys(args.src.name)
+ keys |= map_kwargs(args.kwargs)
+
+ dst.write(instantiate(template, keys))
+
diff --git a/tools/utils.py b/tools/utils.py
@@ -0,0 +1,3 @@
+"""
+utils.py: helper functions
+"""