Old blog

See [[Jekyll]], where the sordid saga started.

  • ask Obsidian developers to make file moves visible to Git if the vault is a Git repository
flowchart TB

A[Hard] -->|Text| B(Round)
B --> C{Decision}
C -->|One| D[Result 1]
C -->|Two| E[Result 2]

Jekyll finally pissed me off

When I listed TODO link unresistable to any programmer urge to write certain kinds of systems, I neglected to mention a static site generator.

I've been using Jekyll since I migrated my blog from Blogger TODO when; I then moved my notes from Roam into Obsidian TODO when link and merged them into the static site with my blog; that required customizing my Jekyll setup TODO move Jekyll customization notes into the Jekyll page and link it here.

In March 2026, my site stopped publishing; error messages were meaningless, and it took a lot of time to figure out what is the problem by deleting files until it went away. It turned out that Jekyll plugin I used for handling wiki links TODO link breaks when an index page contains a wiki link (e.g., a [[TODO]]). Previous communications with the author of the plugin made it clear that he considers Jekyll to be a legacy system (but it remained unclear what is the suggested replacement).

At this point, I looked at a few of the modern static site generators like 11ty TODO link - and decided to write my own ;)

New Possibilities

It occurred to me that if I write my own site generator, I can make it Obsidian-aware:

  • it can read Obsidian configuration
  • it can publish daily notes as dated blog posts

It also occurred to me that I can make alter-rebbe.org TODO link, a site where I publish some archive documents in the TEI format static (again) - if I add support for TEI to my generator:

  • link resolution
  • facsimiles
  • multi-lingual names

It also occurred to me that I can add publishing to X Articles - if X ever makes an API for that. This would allow me to own my blog - and at the same time use X ;)

Farewell, Jekyll

Events of 2026:

  • Mar 17: My Jekyll setup (wiki-refs plugin) broke down
  • Mar 20: I created my site publisher repository
  • I became aware of ZIO Blocks Yaml released on Mar 11 and ZIO Blocks Markdown released on Jan 30
  • Mar 22: I filed ZIO Blocks pull request
  • Apr 30: I switched my personal website to use my site publisher vis GitHub Actions Workflow
  • May 04: I removed all traces of Jekyll from my personal website
  • May 06: I became aware of the ZIO Blocks HTML that was added on Apr 25-May 01, and started switching to it from my home-grown HTML DSL
  • May 07: switched
  • June/July: working on TEI support
  • July/August: Asciidoc support and chunking
  • Aug 7: PDF generation (with Grok's help)
  • Aug 9: switching OpenTorah website from Asciidoctor and Jekyll to site generator

The first milestone for this project is to replace Jekyll on my personal website. In under two month and a little less than 2000 lines of Scala code, I have:

  • no support for paginating the post list, which I did not use with Jekyll anyway;
  • instead of the Ruby code highlighter Rouge, my publisher uses Highlights.js; it loads only the language modules used in the page; TODO unsupported language mapping...
  • while I did manage to list children pages using Jekyll, I never figured out how to list subdirectories; with my own publisher this is not a problem ;)
  • Jekyll ignores Markdown files without front matter; I do not;
  • wiki links
  • link resolution
  • front-matter defaults auto-added pages;
  • header pages are configured in their frontmatter, with FA icons (and defaults) ;)
  • transclusion
  • directory navigation: up, prev, next;
  • path navigation;
  • insertion of missing index pages;

Write about all the plugins I used:

  • jekyll-optional-front-matter
  • jekyll-sitemap
  • jekyll-feed
  • jekyll-mentions
  • jekyll-avatar
  • jekyll-wikirefs

Design

Opinionated, because I am writing this for myself:

  • everything is written in Scala
  • no plugins
  • no SCSS
  • no template languages
  • no layouts

The layout is Minima-inspired CSS and HTML, not a Jekyll theme engine.

Code lives in site-publisher. Dialect conversion sits next to XxxMarkup; shared IR and resolution sit in markup/ (Citation, Bibliography, Footnote, Glossary, Section, …). There is no feature/ package.

Pipeline

Site coordinates. Pages scans the source tree and builds the page graph, including synthetic pages (/posts, /tags, /errors, sitemap.xml, …).

Per document:

  1. Dialect Markup (md / adoc / html / tei / docbook) plus FrontMatterXml.Element
  2. Dialect converters emit shared IR (leftover soup on XxxMarkup: quoteblock, [!tip], TEI cit, DocBook sidebar, …)
  3. HTML-shaped leftovers → IR in HtmlIr.normalize (Aside, Quote, Strike, Figure, PdfEmbed, Video). HtmlMarkup.process is title + nest sections + that pass; Markdown and AsciiDoc finish there. TEI and DocBook do not: leftovers are still native names until their converters run.
  4. PageContent.apply prepares once: sections/ids, internal-link marks, wiki embed (images, audio, video, PDF), footnote harvest
  5. PageContent.markupContent resolves per chunk: select XML, append referenced footnotes, resolve citations, resolve links and tooltips, inject TOC
  6. Minima-inspired HTML → write (textContent or copy assets)

.xml files are disambiguated by root element (TEI, DocBook article / book / …).

SEO

Seo.head is the {% seo %} stand-in: derived tags from site config and front matter, no extra keys. Document title is Page | Site title (home, or when they match, is just the site title). og:title / twitter:title stay the page title. Description and author fall back to the site. Canonical and og:url are site.url + path. og:type is article when the page has a date, else website. JSON-LD is WebSite (home), BlogPosting (dated/post), or WebPage; the article itemtype matches. Generator is this publisher (https://github.com/dubinsky/site-publisher), including Atom . No images, Facebook, or webmaster proofs.

Page description is also the feed

and the /posts list teaser (p.post-excerpt) when set. Not an auto-excerpt of the body; site description is not a fallback there.

Markup

Supported: [[Markdown]], [[AsciiDoc]], HTML, [[TEI]], [[DocBook]].

Cross-markup transclusion means stylesheets (and MathJax etc.) are included even when a page’s source dialect would not need them — unless we later compute the set of markups actually used.

Front matter

Internal (in the markup file) or external (same name, .yaml / .yml). Both present is an error.

A file is markup if its extension is associated with a dialect (.md, .adoc, …) or it is .xml whose root element is associated with a dialect. TODO: front-matter boolean asset to declare that a file is not markup.

[[Jekyll]] ignored Markdown without front matter; this publisher does not.

Directories and posts

Directory pages, navigation (up/prev/next), path navigation, missing index pages. Header links are header-pages in _site_config.yml (ordered source or site paths); FA icons stay on the page (icon in front matter).

Site config home (absolute path, resolved with Pages.find after the tree including chunks exists) occupies /index.html with a Refresh Alias to that page (target.path, so a chunked TOC is not rewritten to P.html). A synthetic root DirectoryPage is dropped from pages (not written); an authored index plus home is PageError.Duplicate. Does not flatten the chunk tree onto /.

Posts: _posts/, _drafts/, Obsidian daily-notes folder (from .obsidian). Auto-post vs permalink. Filename convention YYYY-MM-DD-title. _posts is emptied out of the directory listing (Posts.isDirectoryEmptiedOut).

Chunking

I used asciidoctor-multipage extension to split AsciiDoc into per-section HTML. It has long-known issues, (footnotes) and only works for AsciiDoc.

The publisher chunks any supported markup. The term is chunking (DocBook XSLT), not multi-page. The TOC/preamble chunk is P/index.html (DirectoryPage.fileName), not P/P.html; section chunks are siblings under P/. No synthetic DirectoryPage listing is created for that folder (ChunkedMarkupPage.parent is the unchunked document’s parent). P.html remains the full document. Pages.find matches an existing page path before title-walk, so /P/index.html is the TOC chunk and is not rewritten to /P.html; wiki [[P]] still lands on the unchunked file. Relative *.html and ./ / ../ hrefs are joined to the linking page’s directory (Path.resolveFrom). Site-header icon links (MarkupPage.formatLinks) switch between P.html and P/index.html when chunk is on, and to P.pdf when pdf is on; they sit with up/prev/next and are print-hidden with the rest of the site header.

Paging

Same page-graph idea as chunking (extra pages, path.add, header prev/next), but only the synthetic /posts listing is paged, and the cut is list items, not sections. Site config paginate-posts: N in _site_config.yml (omitted or < 1 is off). No front-matter paginate, no authored-list paging, no Liquid paginator. Extra batch pages are registered after the tree scan so Posts.posts is complete. Posts.batchContent slices ul.post-list; page 1 stays /posts.html; further batches are /posts/2.html, …. A nav.pagination sits under the list. rel=prev/next is the pager sequence.

Not Jekyll’s /page/:num/ index layout — that would move /posts.html.

Sections and TOC

Canonical IR: nested div.section with a heading (HTML hN nested after convert; TEI already nested, heading is tei-head; DocBook section / sectN / nested chapter become div, heading is db-title). Permalinks and missing ids are added on that IR (Section.normalize). xml:id is copied to id. TOC walks through non-section wrappers; a heading need not be the first child (pb/fw before head).

Document title (process second value, same as HTML h1 / DocBook db-title): TEI titleStmt/title (tei-title after Xml2Html; ="main" if several); store / collection child title. Not body head, not bibl/cit titles, not entity names. Stripped from the tree. Empty titleStmt (common in the archive) leaves Page.title to front matter then the file name. If both front-matter title and the document title are present and differ (trimmed), PageError.AmbiguousTitle; Page.title still prefers the document title.

Kramdown {:toc} is a TOC placeholder in Markdown.

DocBook

Same shape as TEI: .xml files, root-element disambiguation, Xml2Html("db") then dialect converters, no HtmlIr.normalize. Prefix db (titledb-title). Claimed roots: article, book, chapter, appendix, part, set, preface, refentry, topic — not section. Nested section / sect1sect5 / simplesect / chapter / appendix / preface rename to div then Section.mark; a claimed root is not renamed (so a chapter file stays ). Document title is the root or info/articleinfo/… title, stripped from the body (HTML h1 analog). CALS tgroup is unwrapped; row/entry become tr/td (class entry kept). No DocBook XSLT and no org.podval.docbook package.

IR converters run in a second pass so IR class is not prefixed to db-class: footnote / footnoteref, glosslist / glossary, variablelist (plain dl), bibliography / citation / biblioref, programlisting / code / literal, co / calloutlist, note/tip/warning/caution/important, sidebar, blockquote/epigraph, emphasis roles (bold, strikethrough), figure / imagedata, videodata. No task lists, wiki links, or PDF embeds. DocBook 4 (no namespace) and 5 (default xmlns="http://docbook.org/ns/docbook") both match on local names. link is renamed to a without class link (that class is the section permalink).

Footnotes

IR: stub span.footnote-link with footnote-correlation-id; body span.footnote with the same id. Harvest numbers in document-link order, strip bodies, append referenced bodies after chunk select, turn stubs into numbered s.

Tooltips: Tip.attachTip returns span.footnote-ref containing the and span.footnote-tip as siblings. Recurse only into the tip (attachTips = false) so links in the note resolve without nested tips.

AsciiDoc leftover div#footnotes and Markdown div.footnotes are stripped as spurious containers.

Task lists

IR is GFM-shaped, not FlexMark or Asciidoctor soup:

<ul class="task-list">
  <li class="task-list-item">
    <input type="checkbox" class="task-list-item-checkbox" disabled checked>
    item text
  </li>
</ul>

TaskList is IR only (classes, checkbox / asItem / asList). Markdown leftovers (FlexMark li.task-list-item, checkbox,  ) convert in MarkdownMarkup. AsciiDoc leftovers (ul.checklist; default html5 /, %interactive , icons=font Font Awesome) convert in AsciiDocMarkup.cleanup. Mixed lists: only task items get task-list-item; the parent gets task-list if it has any. CSS in layout.css styles only these classes. HTML that is already IR is left alone.

Callouts

IR: span.callout with data-value and the number as text, in the verbatim block; ol.callout-list of li annotations. Not harvested like footnotes (the list is already next to the listing). CSS styles only these classes; markers are user-select: none so copy-paste from a listing omits them.

AsciiDoc leftovers (b.conum, i.conum + guard (1), div.colist wrapping ol or a table when icons is set) convert in AsciiDocMarkup.cleanup. HTML that is already IR is left alone.

Not feasible as Markdown (or TEI) syntax. A fenced block is opaque — CommonMark does not parse inlines inside it — so <1> or <.> is just source text: it stays in copy-paste, fights the highlighter, and is not bound to a following list. An ordered list after the fence is just a list. What Markdown and Obsidian call a “callout” is an admonition (> [!NOTE]), a different feature. Quarto’s code annotations (# <1> in a fence plus an ordered list) copy AsciiDoc but are Quarto-only; FlexMark and Obsidian do not understand them. Line numbers (Pandoc .numberLines) and line highlighting (Docusaurus / VitePress {1,3-5}) number or highlight lines; they do not attach explanations. TEI has no listing-callout convention either.

Admonitions

IR: div.admonition with data-type (lowercase) and div.admonition-title; Obsidian +/- fold is details/summary (open when +). CSS styles only these classes (accent from data-type). Not harvested. HTML that is already IR is left alone.

AsciiDoc leftovers (div.admonitionblock table, td.icon / td.content, optional content div.title) convert in AsciiDocMarkup.cleanup. Markdown: FlexMark blockquote whose first

starts with [!type] (Obsidian core callouts; no plugin) converts in MarkdownMarkup.convert. Type is kept as written (Obsidian important is not collapsed to tip).

Asides

IR:

Quotes

IR:

, optional div.quote-title and footer.quote-attribution (cite kept as-is). CSS styles only these classes (the decorative opening mark stays on blockquote). Not harvested. HTML that is already IR is left alone.

AsciiDoc leftovers (div.quoteblock; optional div.title; inner blockquote; optional div.attribution) convert in AsciiDocMarkup.cleanupquoteblock is not unwrapped as a spurious wrapper, or title and attribution would become siblings of the quote. Markdown CommonMark > and bare HTML

get class="quote" in HtmlIr.normalize (after Obsidian [!type] has already become an admonition). A Markdown em-dash line is not treated as attribution.

TEI leftovers (quote; cit grouping quote/q with bibl/biblStruct/ref) convert in TeiMarkup.process second pass so IR class is not prefixed. Inner bibl on a quote becomes attribution. Bare q stays HTML q. / are not turned into attribution text. Standalone bibl is not a quote.

Strikethrough

IR is HTML (user-agent line-through; no extra class). FlexMark ~~ emits it once StrikethroughExtension is on. AsciiDoc leftover span/mark.line-through and HTML become in HtmlIr.normalize. TEI is already the element. Not harvested.

Figures

IR:

, optional figcaption.figure-caption. Not harvested. HTML that is already IR is left alone. Inline images stay .

AsciiDoc leftovers (div.imageblock; div.content already unwrapped; optional div.title after the image) convert in AsciiDocMarkup.cleanup. Markdown/HTML: a

whose only child is (or a lone linked ) becomes a figure in HtmlIr.normalize; title on the image is the caption and is removed from the . TEI: becomes in the first pass;

with / converts in the second pass so IR class is not prefixed. Wiki ![[image]] embeds stay (resolved after convert); Obsidian |WIDTH or |WIDTHxHEIGHT become width/height. AssetRef rewrites local src/data after link resolution.

PDF embeds

IR: div.pdf-embed wrapping (inner fallback ) and a sibling p.pdf-embed-link. Not a figure. Not harvested. Print CSS hides the object so Chromium pdf: true does not snapshot the inner viewer; the sibling link remains.

Bare HTML is wrapped in HtmlIr.normalize. Wiki ![[file.pdf]] becomes this IR in WikiLink.embed (after convert; fragment page= / height=). #page=N is put on data and href. Height is --pdf-embed-height on the wrapper (digits → px). No PDF.js. No