Site Publisher
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. Sheets are tokens.css, base.css, chrome.css, prose.css, tei.css, and print.css, imported by style.css. facsimile.css is linked only on the viewer page. tei.css stays on every page: a
transclusion copies TEI elements into the host, and the host is often Markdown.
Code lives in site-publisher. Dialect conversion sits next to XxxMarkup;
shared IR sits in markup/ (Citation, Bibliography, Footnote, Glossary, Section, Transclusion, …). Page
chrome and generated indexes sit in page/ (PageHeader, PagedList,
CollectionIndex, EntityLists.generate). The page graph and link/asset lookup
sit in site/ (Pages.resolve, Pages.resolveAsset, BackLink / BackLinks, TransclusionEdge). There is no feature/ package.
Gradle plugin
The SSG stays plugin-free. Sites already have Gradle; the wrapper that used to be copied (sitePublisher configuration + JavaExec) lives in plugin id org.podval.tools.site-publisher (plugin/ in this repo). It runs Site.main the way org.podval.tools.scalajs wraps Scala.js.
The plugin JAR is Java and must not implementation-depend on the publisher: Scala 3,
Playwright, and AsciidoctorJ stay off the Gradle daemon. The plugin only creates a sitePublisher configuration on the consumer and puts the library coordinate
there. generateSite / serveSite / prettyPrintSite are JavaExec (Java 25, native-access flags, Playwright env). generateSite and prettyPrintSite are not dependencies of build. generateSite and
serveSite always pass --pretty-print=false. prettyPrintSite
passes --pretty-print and does not declare the target directory as an output. generateSite's declared output is the target directory; inputs are the source tree minus that
directory and Gradle build/. Up-to-date can skip a generate run; the tool itself is not
incremental.
Layout. Empty Gradle root (rootProject.name = site-publisher). Library child publisher/ (project.name = org.podval.tools.publisher, group org.podval.tools). Plugin child plugin/ (artifact org.podval.tools:site-publisher-plugin). Artifact id is project.name of the project that publishes components.java, not a privilege of
being the root. After the name remap, task paths are :org.podval.tools.publisher:… and
:site-publisher-plugin:… (not the directory names). One pluginManagement {
includeBuild } of the repo supplies the plugin id (pass a String path). A second includeBuild of the same directory in the settings body substitutes the library child. pluginManagement alone does not substitute org.podval.tools:org.podval.tools.publisher now that that module is not the included-build
root. Do not mavenLocal() to dogfood.
The plugin is Java, not Scala: a Scala plugin would still load a Scala runtime into the daemon for
a thin wrapper, and sharing SiteOptions from the library would pull Playwright with
it.
How to apply the plugin, site { } flags, library version override, and CI: README
Gradle plugin.
org.podval.xml is a standalone library (org.podval:org.podval.xml, https://github.com/dubinsky/xml).
OpenTorah can depend on it without the site generator. The wholesale replacement of OpenTorah ElementTo/Parser/Unparser is planned in the OpenTorah repo file
xml-codec-plan.md.
XmlParser.parseXml / parseResource load a document from a string or
classpath resource. xi:include stays in the tree (store/collection indexes use @href as a page ref).
A catalog is a named wrapper whose children are one record type: codec.decodeCatalog(root,
name) or XmlParser.loadCatalog(from, codec) (Foo → Foo.xml / <Foo>). Selector.xml loads this way.
XML codec
org.podval.xml.XmlCodec is a ZIO Schema Deriver that binds Scala records
to XML documents, not JSON-shaped trees. Methods are polymorphic over XmlAst
(ZIO XML, Scala XML, HTML). YAML in the publisher stays on Schema + YamlFormat. Collection <part> and entityLists decode with it; org.podval.store.Selector
does too, in the xml library.
Schema.derived only copies Modifier annotations (Modifier is
sealed), so binding hints are @Modifier.config(XmlCodec.Attribute, "") and friends — not
the zio-blocks @xmlAttribute that Schema.derived ignores.
Mapping:
- one case class = one element (name =
XmlCodec.Elementor the type name) @Modifier.config(XmlCodec.Attribute, "")/"xml:id"= attribute@Modifier.config(XmlCodec.Text, "")= character content of this element- unannotated field = child element (
XmlCodec.Element/Modifier.rename/ nested type name / field name) Seq[A]= unwrapped siblings (item name from the field or fromA; tagged/variant codecs supply all item names)- nested records keep attributes (no dummy child)
- sealed trait = element name per case
XmlCodec.derived("kind", XmlTag)binds a field from the element name (EntityKind.asRoot/asName/asList); encode uses that field. Nested tagged types need.instance(TypeId.of[Nested], Nested.codec)- leftover attributes/elements are errors unless a field is
XmlExtras(orXmlCodec.Extras) XmlNode.Elementis identity: copy the child through anyXmlAstRawXml/WithRawXmlare gone (Entity/EntityReferenceuseextras: XmlExtras)- booleans decode
true/false/yes/no/1/0; encodetrue/false - decode is
Either[XmlError, A]; pinencodewith a type ascription when more than oneXmlAstis in scope
This replaces zio-blocks XmlFormat for document types and is meant to replace
OpenTorah ElementTo/Parser/Unparser later. TEI harvest in the
publisher is not migrated yet.
Pipeline
Site coordinates. Pages scans the source tree and builds the page graph,
including synthetic pages (/posts, /tags, /errors, sitemap.xml, /graph.html when graph.enabled, …).
Per document:
- Dialect
Markup(md/adoc/html/tei/docbook) plusFrontMatter→Xml.Element Content.parseon the raw root (mutually exclusive; field onPageContentisdocbecausePage.contentis alreadyPageContent):store/collection→StoreContent(no dialectprocess)entityLists→EntityListsContent(no dialectprocess)TEI→DocumentContent(DocumentHeader+ authored tree)person/place/org→EntityContent(kind, role, display name + authored tree)- else →
MarkupContent(md / adoc / html / docbook)
- Dialect converters emit shared IR (leftover soup on
XxxMarkup:quoteblock,[!tip], TEIcit, DocBooksidebar, …). Store and entity-lists skip this. - HTML-shaped leftovers → IR in
HtmlIr.normalize(Aside,Quote,Strike,Figure,PdfEmbed,Video).HtmlMarkup.processis title + nest sections + that pass; Markdown, AsciiDoc, and HTML finish there. TEI and DocBook emit the same IR in their converters and do not run this pass. TEI<s>is a sentence, soStrike.normalizemust not see that tree. - Authored
PageContent.prepareAuthored: sections/ids, internal-link marks, wiki embed (images, audio, video, PDF only), footnote harvest Site.loadgathers backlinks (skipsa.transclude) andTransclusionEdges (silent)Content.markupBody: store isNone(directory listing); entity lists generated at render; authored selects XML,Transclusion.expand, appends footnotes (host + copies), resolves citations/links/tooltips, injects TOC.resolveLinksdoes not walkaside.transclusionor leftover transclude stubs.- Minima-inspired HTML → write (
textContentor copy assets).GraphJson(if enabled) is aSyntheticAssetin that loop; its body is built from the harvest in step 6, not a second XML walk.
.xml files are disambiguated by root element (TEI, DocBook article / book / …).
Page content kinds
PageHeader composes the HTML header from ancestor stores plus pieces on Content (store chrome, documentHeader table). Collector chrome is a path property
(this page is a store, or an ancestor is), not only this page’s type: markdown under a collection
still gets header.store-header. Site.load walks Content.xml for
backlinks (not transclude stubs) and transclusion edges; indexes use an empty root so generated
listing hrefs are display-only.
StoreContent.bind takes scanned pages, resolves xi:include/@href, sets
directory children, and reports NotInStore (not translation files). Empty includes keep
filesystem listing; a collection without includes still binds originals (not {base}-{xx}
translations) for prev/next. CollectionIndex is generated at render (StoreContent.markupBody), like EntityLists. EntityListsContent
buckets entity files site-wide (kind and role) and, when more than one list is non-empty, creates list
pages at /{prefix}/{n}.html (/name/{n}.html for a directory catalog).
SEO
Seo.head is the {% seo %} stand-in: derived <head> 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 @type is WebSite (home), BlogPosting (dated/post), or WebPage; the article itemtype matches.
Generator is this publisher (https://github.com/dubinsky/site-publisher), including Atom
<generator>. No images, Facebook, or webmaster proofs.
Page description is also the feed <summary> and the /posts list teaser (p.post-excerpt) when set. Not an auto-excerpt of the body;
site description is not a fallback there.
Analytics
_site_config.yml google-analytics is emitted only with --production (js.GoogleAnalytics, gtag). Several sites still have Universal
Analytics property ids (UA-…: dub.podval.org, alter-rebbe.org). UA is retired; gtag with
those ids is not a real replacement.
Follow-up (all sites, not alter-rebbe-only): replace UA-… with GA4 measurement ids,
then pass --production on generate for every published site so gtag actually runs. Do not
turn --production on until the ids are GA4. Named windows and scroll position are siteSettings.js (data-window-name). They do not load Analytics.
Color scheme
The gear menu offers System, Light, and Dark (input[name=color-scheme]). Nothing
saved, or System, follows prefers-color-scheme. Light and Dark are explicit. The value is
the localStorage key color-scheme. siteSettings.js runs in
<head> and sets html.color-scheme-dark when the resolved scheme is dark,
before paint. Dark token overrides live in tokens.css under @media screen,
so print and PDF keep the light palette. Folio color is read in print media. Brand gray stays. The
link blue is #5b93e6 on the dark ground: #1e69d8 is the same hue and is
3.5:1 there, under the 4.5:1 body-text bar. Highlight.js ships the default and dark stylesheets. The head script enables one (data-hljs-theme). The dark sheet’s
ground matches --code-background-color (#303030). Pele mounts code.language-mermaid and reads --pele-* variables that follow the site tokens,
so a scheme change does not re-render the diagram. The graph reads --graph-node-color,
--graph-label-color, and --graph-edge-color from the computed tokens. Pencil
underlines use --pencil-color.
Reader settings
One ReaderFeatures value is computed at the end of Site.load, after pages.load(), the backlink walk, and transclusion harvest. siteHeader and <html data-apparatus-kinds> both read it. The pass walks each prepared tree once: every
MarkupPage that has a source, including a synthetic page that absorbed {root}-years, {root}-index, {root}-collections, map, or multi-list
prose, plus inlineListProse, plus the raw store title, description, and body. Footnote
bodies are walked with that page. They are stripped from the tree and published again. The walk counts
choice, data-apparatus, Glossary.definitions, and bibliography
tips there, and not transclusion. A collection document also walks the published header cells:
abstract, author, addressee, transcriber, and translator. Store fragments and those header cells count
for choice and span[data-apparatus] only. A terms row is glossaryDefinitions (a list with no refs still counts), Glossary.definitions on a
footnote node, or an internal link bibliographyTip would tip, including one that sits
only in a note. cite:[key] does not. An expansions row is a choice outside
code, including one that sits only in a footnote body or in a published
collection-document header cell, and not one that remains only in encodingDesc or revisionDesc. A transclusion row is toPage on a full markup page, or the same
harvest predicate on synthetic content and inlineListProse. A facsimile row is Facsimile.needed on any full page. Each apparatus group is a span whose data-apparatus is add, del, or see. The same list, in
that order, is data-apparatus-kinds on <html>. The attribute is omitted
when empty. A row is on every page of the site, or on none.
Apparatus
There is no apparatus key in _site_config.yml, and <html>
does not carry data-apparatus-enabled. Which groups appear is Reader settings. The gear
adds a radio group only for a kind the site uses: apparatus-add, apparatus-del, or apparatus-see. Values are distinct, plain, and hidden. The label for distinct is Marked. That radio is
checked in the static HTML. These names are not checkbox keys ("1" / "0"). They follow the color-scheme radios.
siteSettings.js is static and runs in <head>. It reads data-apparatus-kinds on document.documentElement before paint. A stored plain or hidden for a kind that is not listed does not add html.apparatus-*-plain or html.apparatus-*-hidden. A missing stored value, or
distinct, removes both classes. Radios are wired on DOMContentLoaded.
AsciiDoc [.add]#…#, [.see]#…#, and [.del]#…# are a role on a
constrained #. Asciidoctor 2.0.26 then emits <span class="add">, not
<mark>: a role on the mark type makes the quoted text unquoted, and the HTML5
converter's default quote tags are empty. AsciiDocMarkup.cleanup sets data-apparatus to that role only on a span whose classes contain exactly one of
add, see, or del. The class stays. Two of those roles on one
span are not marked and not styled. A block [.add] is not a span. removeSpuriousDivs unwraps paragraph, ulist, olist,
openblock, and sectN, so that role class is dropped. convertQuote rebuilds blockquote.quote and does not copy the extra role. imageblock can keep an extra class on the figure. Only a span gets data-apparatus, so a leftover class is still not apparatus.
prose.css styles only [data-apparatus="add"], [data-apparatus="see"], and [data-apparatus="del"]. Not the TEI element add, not a bare .add, and not the HTML <del> that Strike emits for line-through. --add-color stays on the TEI element. Marked is
the absence of a mode class, so no-JS matches the sheet. The stored radio value remains distinct. Additions are [ ] from ::before / ::after. Sources are < > and font-size:
var(--small-font-size). Deletions are text-decoration: line-through. The brackets
are not text nodes, so copy and find-in-page see the words only. Plain removes the brackets and the
small size. A plain deletion drops the line-through. Plain sources also set font-size:
inherit. Hidden is display: none. The spans stay in the HTML. The rules are not
under @media screen, and print.css does not override them, so a browser
print follows the classes on <html>. Playwright PDF prints with empty localStorage, so the generated PDF stays Marked. The same is why dark mode does not apply to
PDF.
AsciiDoc [.add] is an editorial insertion, the same kind of words as TEI supplied. TEI add is a scribal insertion (place above, below, or in
the line) and is not that role. TEI del is the scribe's strikeout (rend="overstrike", danger red) and is not AsciiDoc [.del]. The names stay in each
markup. Nothing in the calendar paper uses [.del] or [.see].
Abbreviation expansions are a reading checkbox, not these three radios. The control is abbr-expand, default on. siteSettings.js adds html.abbr-expand-off
only when localStorage is "0", before paint. No class means the expansion
stays inline, including when script never runs. choice > expan is italic. The parentheses
from ::before / ::after stay roman. Off, expan is a hover tip,
an abbr without rend~=titlo gets a dotted underline, and a titlo keeps the
overline. print.css puts the italic expansion back, because a tip does not print. supplied stays square brackets. corr stays on --editorial-color.
Those parentheses are the same CSS pair, and they are not italic.
apparatusViewer on data-window-name is the named-window target for
entities. It is not this feature.
How to write the roles: README Apparatus. How to write abbr / expan: README TEI choice.
Messages
Interface strings live in Messages.xml next to the site package. Each message is a
Names list with English first and Russian second. Messages.text looks the
key up and calls Names.doFind. English is first, so a site with no lang, or
a language the catalog does not list, gets the English sentence. Selector display names already fall
back that way. The file uses the Name codec: the n attribute or the element
text, not both. Scala substitutes brace placeholders after the lookup. A brace left with no supplied
value throws. An extra supplied value is ignored.
Scripts do not read Messages.xml and do not branch on the site language. The page has
already chosen the sentence, and the script assigns that text with textContent or setAttribute. search.js replaces {n} only in the overflow sentence
stored on #site-search. graph.js replaces {n} and {e} only in the status pattern stored on #site-graph-status. The facsimile aside
reads data-missing and data-error from its status element. data-interface-lang is not a second copy of the sentences.
Markup
Supported: [[Markdown]], [[AsciiDoc]], HTML, [[TEI]], [[DocBook]].
Site CSS (including tei.css) and MathJax ship on every HTML page so a transcluded
fragment still has its dialect’s look and math. We might later load less by calculating which dialects
are actually present on the page. See [[##Transclusion]].
Front matter
Internal (in the markup file) or external (same name, .yaml / .yml). Both
present is an error.
Unknown front-matter keys stay on the value (extraKeys) and are not a parse error.
modified_time is read from that stash when the value is a date; a non-date leaves modifiedTime empty and keeps the key. The YAML codec spells record fields in kebab-case, so
this key cannot be a field (modified-time). FrontMatter.write is a debug
dump: it omits default false and empty lists and is not used to rewrite author files.
_site_config.yml rejects unknown keys, including keys nested under social
and graph.
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.
Standalone sidecar asset: true (same basename, .yml / .yaml)
reclassifies the markup file as an asset at scan (Pages.forName): byte copy, no dialect
process. The sidecar is not published. Internal asset: true is an error (the
file stays markup). A directory index so marked stays a DirectoryPage
(parent/children); write copies the file (no listing, no chrome).
[[Jekyll]] ignored Markdown without front matter; this publisher does not.
Page language
langDefault was Some("en") in front of the site language. MarkupPage.lang asked front matter, then that default, then site.config.lang. The
default was always present, so a Russian site still published html lang="en" whenever the
file itself was silent. Removing it lets the site language be the fallback the configuration already
named. The cascade that stores the code is in Alternates.
html lang is the document, not the interface. The interface is the site language:
dir and data-interface-lang on <html>. The existing [lang] rules set both direction and text-align, and alignment
inherits. direction on html[dir] alone would leave text-align:
right from <html lang="he" dir="ltr">. html[dir="ltr"] and html[dir="rtl"] set both properties. Their specificity is higher than [lang], so
the chrome stays with the interface even when the document language would flip it.
The copy is not in Xml2Html. That rewrite strips a reserved lang. TEI
<code lang="scala"> is the highlight language, and the rewrite already stores it as
tei-lang. Putting that value on HTML lang would announce a programming
language as a human language. A second Xml2Html.convert would rename an HTML lang back to tei-lang. The publisher copies only xml:lang, and only
after tei2Html.convert, so the converted node is not fed through the rewrite again.
A store title is wrapped after getChildren because that call drops the title element.
copyContentLang may have set lang on the element, and the children no longer
carry it. l.store-current also holds the selector word. A lang on the l would mark that word as the document language. The title children get their own wrapper. The
body is the same shape: getChildren of the converted body, then one wrapper.
A collection document has no h1.page-title. The heading line is l.store-current: the selector word (selectorDisplayName, already the interface
language) plus the file stem. titleStmt/title is not rendered there. hasBibliographicTitle only chooses whether that line is shown or the crumb replaces it. The
document language belongs on td.value, where the header table quotes the document, not on
the heading line.
Categories
A category is an authored page. Front matter categories holds wiki links to those
pages ([[Books]], [[Books|label]], or a bare name). Categories.resolve runs in Site.load after Pages.load has called
throwIfErrors. A miss is PageError.Unresolved and does not abort generation.
There is no synthetic category page and no LinkKind.Category: the target is an ordinary
title/path lookup. Membership is a separate edge, not a BackLink, because the property is
not an element with an href. The hub page keeps its body and appends the members, by
title. Header chips on the member use that edge. The site graph emits it as a link edge,
collapsed with a body link to the same page. ![[….base]] (an Obsidian Bases embed,
including a #view) is dropped and is not an unresolved transclusion. The member list
replaces that embed.
Page names and wiki links
[[Jekyll]] used front-matter titles and ignored file names; I needed an Obsidian plugin to copy names into titles. This publisher uses both file name and front-matter title for link resolution, so the title property is only needed when it differs from the file name.
Obsidian uses file names and ignores the front-matter title; a plugin would be needed for the reverse.
Wiki links ([[…]]), internal link resolution, backlinks. Front-matter permalink and aliases add Refresh Alias pages (/short.html → the real page). permalink must start with /; a
relative value is PageError.Permalink and is not installed. Pages.find also
treats those paths as prefixes: after exact path match, longest alias prefix wins and the remainder is
joined onto the real page’s directory (/short/child → child next to /aliased/index.html). That is the old collector alias/@n + alias/@to
rule used in alter-rebbe TEI (/lvia1868-3470/006). Remainder under a non-directory alias
is unresolved. Lookup does not recurse through find (the expanded path still starts with
the alias prefix). Path.fromHref treats a last-dot suffix as an extension unless it is
all digits, so ids like 255.2 stay one segment and /dubnov/255.2 can
prefix-resolve. Obsidian block ids (^id at the end of a paragraph, or a following line
after a list/table/quote/code fence) become id plus class="wiki-block" on
that element; [[note#^id]] resolves through WikiBlocks. Open questions about
Obsidian wiki links: case sensitivity, file name vs title vs document title, ambiguous names, line
wrapping, agglutination.
Transclusion
Page embed, not media. MarkdownWikiLink turns ![[…]] into a.wiki-link.transclude. WikiLink.embed still consumes image / audio / video / PDF
at prepareAuthored; remaining stubs are page transclusions. Expand is IR, so a
hand-written HTML stub of that shape also embeds. AsciiDoc include:: is Asciidoctor
source include at convert time; store xi:include/@href is a child page ref (never
XInclude). Neither is this.
Target is AuthoredContent after unwrap (link.page.real;
ChunkedMarkupPage.markupPage; directory index body only). Not stores, entity-list
catalogs, assets, PDF/facsimile/synthetics. Pages.resolve on a chunk has content =
None, so Link.fragment is always empty: fail-closed # re-resolves on
the owning PageContent after unwrap. Extra # wins (/P/Alpha.html#One is One, not Alpha). Missing # is
UnresolvedTransclusion (frozen stub, author text kept), never a silent whole-page copy —
that would look like a correct titled embed and T = Whole can false-cycle.
Region (markup/Transclusion.scala): Whole | Preamble | Section(id) | Block(id). From-region is never Whole
(preamble includes trailing nodes after the last top-level section). Cycle is non-termination on a
region stack, not page topo-sort: wouldReenter iff same page and T
== S or T contains S (irreflexive contains). S =
hostOf(anchor) on the full authored tree. Unchunked initial stack is empty; a
section chunk starts with that section. Child→parent is a loop stub (no outer aside body). A#X → B#Y →
A#Z is not a cycle when Z does not contain X. Depth cap is 32 hops (TransclusionLoop).
Expand after toc.select + selectedXml (chunks only copy
what they show). Cache is pre-nested source XML only. Nested hops return wrapped; prefix/hash-rewrite
the direct copy (skip inner aside.transclusion); then
wrap this hop. Do not mutate authored XML; TOC stays authored. Prefix transclusion-{n}-
on ids / xml:id / aria-describedby / aria-labelledby; in-copy
hashes use the live direct tree; permalinks and interpage hrefs in the copy point at the source published URL + original id. a.pb → facsimilePage(source).publishedPath + original id. Citeproc stubs in the copy use the host .bib; Citation.isPlaceholder is stripped. Footnotes: prefix
correlation ids on the copied tree and the body closure (prefixCorrelationTree on copied
nodes), merge, re-number; table k is emit-time on the expanded host tree.
Host resolveLinks skips aside.transclusion and leftover a.transclude.
Chrome is always boxed HTML (aside.transclusion, header is one
clickable source title, no extra icon). Default look is the box (left accent border). Settings Seamless transclusions (data-setting="transclusion-clean", same localStorage / <html> class as glossary-expand) hides header and border. Print
follows the class; Playwright PDFs have empty localStorage → boxed. p >
aside is invalid: child-first walk, replace or split the paragraph.
Backlinks: do not harvest transclude stubs (BackLink.apply returns
None). Reverse discovery is Embedded in from resolved TransclusionEdges on the unwrapped owning FullMarkupPage (skip
chunks/aliases/synthetics; dedup fromPage; page ref, no ![[…]] snippet).
Graph
Opt-in _site_config.yml graph.enabled (default off). When on, Pages.load adds GraphPage (/graph.html) and GraphJson
(/graph.json) with the other automatic pages, before resolveHeaderPages / installCollectionAliases, so header-pages:
[graph] resolves and a collection @alias="graph" is Duplicate. A
Worker empty-remainder rewrite would otherwise serve HTML for /graph.json. JSON is
computed at write, after Site.load has filled BackLinks,
category membership, and TransclusionEdges. No second XML walk. Cytoscape.js 3.34.2 loads
only on /graph.html (MarkupPage.extraLibraries). Author
lists graph in header-pages (no automatic icon). Click uses the owner’s
publishedPath and NamedWindows.targetAttr when named-windows is
on.
Vertices. Canonical authored FullMarkupPage with a source file, via
GraphOwner (not Page.real, which only unwraps Alias). Chunks,
facsimile viewers, and PDFs collapse to the owning document. Synthetics, assets, and pass-through
directory indexes are out. Node id / click href is publishedPath (collection aliases, daily remap). group is the first source path segment.
Source prefixes, not published URLs. exclude-path-prefixes match
sourcePath.path.startsWith(...). Posts and Obsidian dailies are rewritten at scan (Posts.path) to /YYYY/MM/DD/…. The vault folder (days/ from .obsidian/daily-notes.json) never appears on the public href. TEI collection
@alias rewrites /archive/lvia/1799/2/… to /lvia1799-2/….
Matching publishedPath against days or archive would drop
almost none of those trees. The notes-vault recipe exclude-path-prefixes: [days] hides
daily notes (Obsidian’s graph users filter -path:Daily/ for the same reason: a year of
date nodes is a hairball, not a map of ideas). Default is an empty list; README recommends [days] when that is the daily-notes folder. alter-rebbe.org stays off; if enabled, [archive] on source leaves name/ entities and notes.
Edges. Directed pairs in JSON: BackLink → kind: link
(wiki, HTML, TEI @ref, …); TransclusionEdge → kind: transclude.
include-transclusions defaults to true (omit the key, or set true). include-transclusions: false omits every transclude edge; wiki/HTML/TEI
links are unchanged. Dedup per directed pair per kind. Intrapage / same-owner
skipped. Unresolved links stay on the Errors page (no phantom nodes).
Undirected draw. JSON stays directed so a later arrows checkbox can restore A→B vs
B→A. Cytoscape will paint two lines if both records exist, even with arrows off.
Obsidian’s default is one undirected stroke. graph.js collapses reciprocal pairs of the
same kind to one element (data.id = sorted a~b:kind). link and transclude between the same pair stay two strokes.
Local CSS and empty CDN. graph.css is a site asset under /assets/css/. MarkupPage.toHtml always prefixes library.cdn onto
JSLibrary.stylesheet. Site is the only library with cdn = ""
(that is how /assets/css/style.css is linked). Putting /assets/css/graph.css
on Cytoscape would request https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.34.2/assets/css/graph.css (404). Companion
GraphCss is a JSLibrary with cdn = "" and that local path,
listed in extraLibraries next to Cytoscape. Cytoscape itself is ESM from cdnjs (jsDelivr
fallback), Mermaid-style inlineJs import, empty imports. The init script is
/assets/js/graph.js (copied like CSS), not inlined: HTML serialization XML-escapes < and & in <script> (a < b became a
< b, && became &&), which is a syntax error and
left an empty canvas. Pretty-print also wraps lines, so // comments in inline JS would
comment out the rest of the line.
Local neighborhood (not v1). Quartz / ole.dev / Obsidian Publish keep a local graph (this page plus N hops) as the daily tool; global is the overview. One /graph.json already supports a client BFS. Follow-up (“PR 4”): ?focus=/notes.html
on /graph.html, and/or a per-page overlay. Overlay must not fetch Cytoscape + the JSON on
every TEI page (lazy import, or skip when N is huge). v1 is the global page only.
No Playwright graph tests (JSON + HTML hook). How to enable: README Graph.
External links
Opt-in _site_config.yml check-links (default off). When on, generate
checks author-written http and https URLs and records failures as PageError.BrokenLink (#broken-link on /errors.html). Site.isInternalLink stays a classifier: no scheme is internal, same host is SelfLink, and a bad URI is internal so resolution can report Unresolved. It does
not fetch. Site.sameSiteHost is the shared host test.
The check runs on the element PageContent.renderAuthored returns for the full page,
after citeproc HTML is spliced in, and on the element resolveConverted returns. That
second tree is store title, abstract, and body, and document-header cells (PageHeader.resolvedFragment, CollectionIndex.convertedNodes). Chunks do not check
the body again. Feed and chunk headers re-render the same trees. A fetch is cached by URL
with the fragment removed, and each source path reports a broken URL once.
The walk reads a@href and media src / object@data (AssetRef). It does not descend into code or aside.transclusion. An
embed is checked on the source page. Same-host URLs are not fetched. SelfLink already
covers them, and config.url is the live site.
HEAD, then one GET (body discarded) on 403, 405, or 501. Redirects follow
HttpClient.Redirect.NORMAL. Status >= 400 and transport failures are broken.
A remaining 3xx is success. Connect timeout 5 seconds, request timeout 10 seconds. User-Agent is a desktop Chrome string so the result matches a reader. Java's default agent is
blocked by hosts that still serve browsers. Fetches are sequential.
Pages.load calls throwIfErrors() before any page is written. A broken
link is logged and listed. Generation still finishes, same as Unresolved and UnknownCitation.
Outside the walk: iframes, facsimile JPEGs, and footer chrome (social, license, holder link, feed,
sitemap, footer mailto:). How to enable: README External links.
Collection aliases (static hosting)
Plan for alter-rebbe.org on GitHub Pages with the domain on Cloudflare (proxied, orange cloud; SSL Full Strict after GitHub’s Enforce HTTPS is green).
Collector GET /rgada/003 is 200 at that URL. Preview host while www stays on
the collector: ng.alter-rebbe.org (opentorah/alter-rebbe.org Pages,
CNAME → opentorah.github.io).
Worker, not Transform Rules. Free Transform Rules are 10; site.xml
has 48 collection aliases (two patterns each: exact /rgada and /rgada/*). A
Worker does slash-delimited prefix replace and appends .html (GitHub Pages will not serve
/…/003 as 003.html without Jekyll pretty permalinks). Browser URL stays
short (rewrite, not 301). Route the Worker only at alias prefixes (ng.alter-rebbe.org/rgada*, …) so CSS/JS/images go straight to GitHub and do not count as
Worker requests.
Generate writes collection-aliases.json from the same aliasByPrefix map
(from / to segment arrays). Wrangler bakes that JSON into the Worker at
deploy (.github/actions/deploy-alias-worker); it does not fetch the table at request
time. Preview host ng.alter-rebbe.org.
Third mapping, inbound only (emitted hrefs stay the new shape): collector facsimile viewer /rgada/facsimile/029 → written …/029/facsimile.html (reorder facsimile after the document; then .html). /rgada/facsimile/029.html
is the same. {base}-{xx} translations share the original’s viewer (/rgada/facsimile/001-ru → …/001/facsimile.html). #p{n} fragments are
unchanged. Alias prefix + .html alone would look for …/facsimile/029.html
and 404. Pages.find / rewriteRequest / local serve() already do
this; the Worker must too.
Cloudflare does not auto longest-match. Slash-delimited patterns avoid lvia1799-2 vs lvia1799-2-2. Same map drives find, link shortening, Worker, and
local serve.
Pricing (Workers, 2026): Free: 100k Worker requests/day, 10 ms CPU, $0. Paid: $5/month includes 10M requests then $ 0.30/million; no egress. Prefix-only routes should stay on Free for this archive; Paid is headroom if bots hit the short URLs hard. Confirm Workers pricing.
Optional alias on the TEI store/collection root (<collection n="3140" alias="rgada">), harvested into StoreIndex. Not site
config and not front-matter permalink (that still writes a Refresh leaf). Duplicate alias values are PageError.Duplicate. Page.publishedPath is the
public href; serve() rewrites inbound short URLs to the written file. Cloudflare Worker
uses the generated table (prefix routes, rewrite not 301).
Local media refs are not page links. AssetRef rewrites img@src, video/audio/source@src, and object@data to the
published path (relative to the linking page, then exact Pages match). Wiki embeds (![[file]]) are marked, then a vault path (folder/file) is from site root and a
bare name falls back to a unique findByFileName. Missing files: PageError.MissingAsset and class unresolved-asset. No title-walk, backlinks, or
internal-link. YouTube/Vimeo iframes are skipped. #page= on PDF data is kept.
Entities
TEI files whose root is person, place, or org are EntityContent; Page.entityKind / entityRole / entityDisplayName come from the parsed root (not a walk of the processed tree). They are
authored TEI pages (no document title from the names). persName / placeName
/ orgName with a non-empty @ref become a with the original name
as class (LinkKind.Entity). Resolution is (kind, source file name without
extension) — not the wiki title walk, not a path. Duplicate ids of the same kind are PageError.Duplicate and do not resolve. Wiki [[id]] still title-walks to the file
name. A bare @ref looks like a citeproc key, so convertCite skips those
as. Page.title / Page.listTitle for an entity is the first name
element (<h1> and <title>). Backlinks on the entity page are the usual
internal-link harvest (persName@ref in documents), grouped by TEI collection
(pathHeaderHorizontal) when the source sits under one; not collector-style mentions (no
Имена: line, no compact doc-id list). The collapsed document row links to the first
mention outside teiHeader. Those anchors are not on the published page (the
document-header table is rebuilt without their ids). When every mention is in the header, the row
links to the document. Snippet text skips *-tip subtrees.
entityLists is EntityListsContent (same scan as store when
it is dir.xml beside dir/). Each listPerson / listPlace / listOrg (@n, optional @role, child title) is a bucket: kind from the element, members are all entity files on
the site whose entityKind and root @role match (None matches
None). Empty lists are dropped. Member lists are generated at render (EntityLists.generate) after Site.load harvests backlinks from the empty index
xml, so catalog → entity <a>s are display-only. Two or more non-empty
lists: catalog page is TOC of list pages (/name/{n}.html); members are not inlined. One
non-empty list: the catalog page is that list (no {n} subpage). A markup file at
that list path is prose above the inlined list: the catalog MarkupPage keeps a detached
PageSource, and {n}.html is still not published. Several entityLists files are independent catalogs. DirectoryPage does not dump ul.page-list of every file. A listPerson inside a normal TEI
document is not filled.
The catalog directory is an identity prefix (installPrefixAlias, Worker table): /name → /name/index.html, /name/{id} → /name/{id}.html.
No synthetic /name.html dump, no name→names inbound remap.
No names selector. List pages are direct children of the catalog in the store tree, so
Pages.find and local serve() resolve /jews. Each list page is
By("name") of its members, so /jews/alter-rebbe resolves with that hop
omitted. The Worker table stays the identity prefix above. Canonical hrefs stay /name/jews.html and /name/alter-rebbe.html.
On first parse of the source file (Content.parse, not a second harvest): persName / placeName / orgName with empty @ref in TEI
documents and in store title/abstract/body are PageError.NoRef; TEI unclear
is PageError.Unclear; an entity file whose name is not spaces-to-underscores of the first
name is PageError.MisnamedEntity. Entity-file name elements are the definition, not NoRef. Generated collection and name-list tables are not scanned. There are no /report pages. Each Errors-page kind is div.kind with id from the
case-object name (#no-ref, #unclear, #misnamed-entity). More
than one kind: a TOC of those fragments.
Place map
/map.html (leaf alias /map) is drawn when any place has a
parseable location/geo, including a site with no listPlace. Points are those
place files, not the members of a list. PlaceMap.render is called only from MapPage, at write, so case counts see harvested backlinks. PlaceMap.located is
the emit check and does not count cases. The coordinate is latitude, then longitude, separated by a
space. idno with type="wikidata" may name the same place and is not read by
the map. place > location and place > idno are hidden on the entity page.
The page heading is map.page (Map / «Карта»). The SVG <title> stays map.title. An optional map.md is prose above the figure; its h1 or
front-matter title replaces the page heading only. The page is not a header link and not a
directory-listing entry. The place list does not draw the map and does not link to it. The area of a
circle is the number of cases that name the place. A case is a collection whose parent store groups by
case or дело. Leaves of one case count once. A mention that is not under
such a collection counts as none. A place with a coordinate and no case is a ring. Places are dropped,
farthest from the median first, until the latitude span and the longitude span are each at most 15°. A
dropped place stays in the table and is marked outside the frame. Rivers and the coast are read from
_place-map-base.txt at the site root when that file exists. The publisher ships none.
README.adoc has the prompt for writing that file from Natural Earth. A blank line
separates polylines. # comments. A block whose first line is # coast is
coastline; any other block is a river. Each coordinate line is longitude, then latitude. The file is
ignored by the _* rule and is not published. The page is omitted when no place has a
coordinate.
A markup file at a list path is prose on that EntityListPage (takeProse
accepts only MarkupContent). An h1 replaces that page's <title> and l.store-current. The catalog TOC and StoreTree.nameList stay the list <head>. Pages.resolve keeps a
fragment when the target content is empty, or the page is a YearIndexPage or an EntityListPage, or EntityLists.kept for that catalog has length 1. That last
check is computed at resolve time. It is not stored: EntityListsContent is rebuilt when
the soft cache misses. A one-list catalog therefore keeps the file as inlineListProse on
the catalog MarkupPage, drops the scanned page immediately, and renders detached.content at write, above the inlined list. The copy is recordParseErrors =
false. The .doc test is the parse that records. The catalog <title> is not replaced.
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).
TEI store / collection as dir.xml beside dir/
is StoreContent (same as live alter-rebbe.org
collections / /archive/books). xi:include/@href is a page reference
resolved with Path.resolveFrom from the store file; StAX does not expand XInclude. StoreIndex is the parsed include/name/title/abstract/body/collection data inside
StoreContent (plus collection parts and pageType). Header
chrome is PageHeader.collectorPageHeader (header.store-header). Ancestors
are one nav.store-path of links separated by /, not a stack of <l> lines. This node stays <l class="store-current"> when it is a store or
collection, or a document with a real title. A real title is a nonempty extracted title: TEI titleStmt/title, or a nonempty markup or front-matter title. The file name, and Page.title's filename fallback, are not a title. Описание stays in the table and is not an
h1. A collection document with no real title omits l.store-current. Its
crumb (документ 071) is the last item of nav.store-path, not a link, with
aria-current="page". When the site has exactly one root store (StoreIndexes.isRootStore), that store is omitted from descendant path text (visibleAncestors, and the archive-path strings that drop that root). This does not rewrite
/collections or /years, and it does not depend on those names. A second root
puts the crumb back. The root page's own heading is unchanged, because it has no ancestors. StoreIndexes.collectionHeader still drops a root-store prefix from backlink headers whenever
one is an ancestor, including when the site has two roots. Print hides .store-path links
and separators. A path that contains .store-path-current still prints that crumb. A path
of only ancestor links is hidden. Collection documents (DocumentContent under a
collection) add table.document-header (Описание / Дата / Кто / Кому / Расшифровка /
Перевод from harvested teiHeader; teiHeader is omitted from the body). The
breadcrumb line is a div.store-path-bar: the path on the left, and on the right the TEI
link, the GitHub link, and a Cite button (see Stamped TEI). The citation is not shown on the page.
Entity refs in titles and table cells go through PageContent.resolveConverted. Then abstract/body and this store’s by/@selector
label. Selector labels are org.podval.store.Selector (Selector.xml in the
xml library; PageHeader.selectorDisplayName uses the site _site_config.yml
lang, default English: category → разряд when lang
is ru). The parent store’s by/@selector labels this node; under a collection with no by, documents use document; if the parent
directory name is a known selector (archive/), that is used (архив / archive). Store and entity-lists skip TeiMarkup.process. A store
body’s listing is still DirectoryPage (StoreContent.markupBody is None). A collection sets suppressDirectoryListing and markupBody is CollectionIndex.generate (table.collection-index, same
columns as the live rgada index).
Date cells use PageHeader.dateCell (see TEI dates). Страницы page numbers
link to pb ids (p{n}) in the text. part from="…" title rows
split originals; {base}-{xx} files (dash at length 3, xml:lang must match)
are translations: not rows, Язык links, [lang] on the original, prev/next skip them.
Documents under a store or collection (not only the index pages) use the same collector header. StoreContent.bind reports files under the indexed directory not named in the includes as PageError.NotInStore (translation files are not). Intermediate selector folders (book/, volume/, fund/) are hops in the href, not pages (/archive/books/book stays 404); Page.parent skips them so up from
Державин is /archive/books. Nested stores list their own includes. Collections without
includes still use filesystem listing of originals.
A TEI store whose source file sits in the site root (archive.xml) is a
root store. After StoreContent.bind, two synthetic pages are registered
(StoreIndexPage, generated at render like CollectionIndex so listing hrefs
are not backlinks):
{file}-collections.html— collectorIndex.Tree:div.tree-index, outer<em>is theby/@selectordisplay name (архив), nested stores recurse with their ownby, collections are leaves. Link text isname:orname: title.{file}-index.html— collectorIndex.Flat: one<li>per descendant collection,pathHeaderHorizontal(архив РГАДА, разряд VII, опись 2, дело 3140: title) plus the collection abstract.
The tree title is Messages tree.title (Tree / «Дерево»). The flat title
is Messages index.title (Collections / «Коллекции»). A file at either path,
or at {file}-years, is rendered above the generated list. Its h1 or
front-matter title replaces the page title. There is no /collections or
/years alias, including when the site has one root store. header-pages and
home stay site config (archive-collections, home:
/archive-index.html).
Collection index
Live collector rgada is table.collection-index, not a ul.page-list. Generated at render (CollectionIndex, same timing as EntityLists) so index XML stays empty and table
hrefs are not backlinks. Columns: Описание, Дата, Кто, Кому, Язык, Документ, Страницы, Расшифровка.
Date is PageHeader.dateCell (see TEI dates). Страницы page numbers are pb in
the text (#p{n}, pageType manuscript 000/000об or
book numbers). Footer lists pb@missing empty vs non-empty photos. note
place="end" in abstracts is footnote IR (TeiMarkup.finishFootnotes on the
assembled table / collector header, one id sequence), not inline at the reference.
<part from="000"> title rows split originals. A sibling {base}-{xx} is
not a row. The Язык cell lists the code of each other reading. The header link is the autonym.
Prev/next stays on originals. A site-root language directory is the other way to name a reading. See
Alternates. A +reg file is not a row either. See +reg. Store indexes stay lists.
Collector used a second stylesheet (wide.css, $content-width: 1800px) for
Collection pages. We put class="wide" on <html> when Content.wide (StoreContent.isCollection) and override --content-width on .page-content > .wrapper only (header/footer stay 800px).
Settings JS adds classes with classList, so it does not drop wide.
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 /.
Alternates
A page has another reading in exactly two ways.
The suffix {base}-{xx} sits in the same source directory, with the same extension.
splitLang is unchanged. The two letters are the code even when forDefaultName does not know them.
The directory is a site-root prefix. The first segment counts only when Language.forDefaultName returns a language: en, not English, and not
zz. The tail, with the same extension, is the other page. en/notes/about.md
is a reading of notes/about.md. col/en/001.xml is not one, because col is not a language code.
The original is the page that is neither a suffix translation nor under a language prefix. A
cluster is that original plus the other readings. A lone 001-ru.xml or a lone en/about.md has no original, so it is not a cluster: no header link and no hreflang.
html lang is one stored code. The cascade is text/@xml:lang, then the
suffix, then the directory, then front matter, then the site language, then en. Suffix
before directory is why ru/about-en.md is stored as en.
The signals on this file are front matter, text/@xml:lang, text/@lang,
the suffix, and the directory. The site language is not a signal. Two different signals are one Wrong language error. On a TEI root only, a missing text/@xml:lang is a signal
when a suffix or a language directory is present. That is why a -ru file with no text/@xml:lang is still missing != ru. Markdown and AsciiDoc have no such
attribute, so en/about.md with no front matter is not an error.
Two files with the same code (about-en.md and en/about.md) are an error
on both. One remains: the suffix sibling if there is one, otherwise the first published path. The head
then has one hreflang="en".
hreflang="x-default" is the URL to offer when none of the language-specific URLs fits.
That URL is the page the author wrote without a language marker: no suffix, and not under a language
directory. The suffixed file and the directory copy are the other readings, not the default. Canonical
stays the page itself. There is no redirect and no Accept-Language. A page with no other
reading emits no hreflang.
The collection Язык cell is a catalog column. It stays the code (he ru) so the column
stays narrow and matches the file suffix. The header is navigation between readings, so its text is
the autonym (русский): forDefaultName(code).names in that language's own
spec, or the raw code when the catalog has none. The original's code is that page's stored lang, which is already a code. A Russian site note with no front matter is therefore ru / русский, not Russian.
+reg
splitLang means one thing: a dash at length - 3 is a language code. A
regularized reading is not a language. doc+reg stays the whole stem with no language, and
so does doc-ru+reg, even though doc-ru is Russian. That keeps the reading
out of the translation row, the alternate cluster, and hreflang.
The facsimile rewrite wants the viewer, not the reading. It removes +reg first, then
runs the same splitLang. doc+reg becomes doc. doc-ru+reg becomes doc-ru, and that is a translation, so the viewer is doc, the same viewer doc-ru already uses. If splitLang saw the raw
stem it would keep doc-ru+reg whole, and the URL would look for a viewer that is not
registered. The Scala copy and rewrite.js do this in the same order.
The reading is another reading of the same document, not a second set of photographs. Facsimile.needed is false for it, so no second facsimile.html is registered.
Links and the worker URL resolve to the original's viewer.
Search stores the diplomatic text of the original. The regularized spelling is editorial. A record
for the reading would publish that spelling as a hit and add a second document the reader did not ask
to land on. SearchJson.recordOf returns nothing for a reading, and the index stays the
one diplomatic record.
Search dehyphenation
PlainText.diplomaticBody still returns one string. search.json stays
version 1. search.js is unchanged.
The stored text keeps the diplomatic spelling, including a U+002D hyphen before lb break="no". The same string also holds the joined spelling, letters and numbers only, with
that hyphen omitted. Борухо-<lb break="no"/>вичъ is stored as Борухо-вичъ
Боруховичъ. Punctuation stays on the diplomatic word, so Борухо-<lb
break="no"/>вичъ, is Борухо-вичъ, Боруховичъ. Consecutive break="no"
hyphens are one extra token: на-хо-дилось находилось.
The extra token is the next whitespace-separated word after the diplomatic word. It is not a list appended at the end of the document. A hit on the joined spelling then still has the hyphenated spelling inside the snippet window (40 characters before, 80 after).
The browser tokenizer is unchanged because it already splits on everything that is not a letter or a number. The hyphenated spelling and the joined spelling are two tokens in the same string. No new splitting rule is required.
An ordinary hyphen is not joined (что-то does not become чтото). A hyphen
before a normal lb is not joined. A maqaf (U+05BE) is not this hyphen and is
not joined. An authored hyphen earlier in the word is left alone, so Римскому-Кор-<lb
break="no"/>сакову adds Корсакову, not РимскомуКорсакову. Whitespace
the break introduces before the continuation is not part of the token. The continuation stops at the
next real whitespace, so Могилев-<lb break="no"/> скою губерніею adds Могилевскою and does not swallow губерніею.
TEI dates
Old Collector TeiToHtml.dateTooltip prepended <span
class="tooltip"><table>…</table></span> as the first child of every TEI <date> with @when, CSS *:hover > .tooltip. That is too broad next
to footnote/glossary/citation tips, so the publisher wraps instead: span.date-ref around
the <date> plus sibling span.date-tip (same hover/focus CSS as Tip, but not inlined under html.glossary-expand or print — the table is not a
parenthetical gloss).
TeiDate reads when, from, to, notBefore, and notAfter. endsTable columns match the ends present.
Headers are Date, From, To, Not before, and Not after. There is no empty cell. Year-only and month-only @when stay a closed
From/To interval (first day and last day). A one-sided @from or @notBefore
expands with last=false. A one-sided @to or @notAfter expands
with last=true. Expansion of a partial endpoint does not invent the other end. @when cannot combine with the other four. @from cannot combine with @notBefore. @to cannot combine with @notAfter. Each refusal is PageError.InvalidDate, and the element is left unchanged. @from plus @notAfter, and @notBefore plus @to, are legal. .. in
any temporal attribute is InvalidDate and the element is left unchanged. org.opentorah:opentorah-core only converts days (Julian / Gregorian
/ Jewish, Day.toLanguageString). Display language is Site.languageSpec. The Guidelines say W3C attribute values are Gregorian and @calendar applies to the text content. This publisher reads the numbers in the source
calendar. Site tei-default-calendar applies only when @calendar is omitted.
The only @calendar value that selects Julian is exactly #julian. Any other
@calendar value is Gregorian, even when the site default is julian. The corpus is not
being rewritten to @when-custom. None of the five attributes means no tooltip
(bibliography imprint <date>1994</date>).
Дата: dateCell returns the <date> when @when or any end
attribute is non-blank, so the hover can run. A dateless <date> unwraps to its
children and has no hover. End attributes keep the authored children only when @when is
absent. A non-blank @when replaces the children with the attribute string even if an end
attribute is also set. convertFragment adds the tip because the element is still there.
Body dates keep authored content. TeiDate.convert runs after the Xml2Html walk, in process's IR rewrite and in convertFragment's second transform. That walk
re-enters the tip span it creates. Xml2Html.convert on that visit renames class to tei-class, so .date-tip does not match. data-calendars on the inner <date> makes the re-entry a no-op and leaves the
one wrapper.
TEI <gap reason> uses the same tip family (span.gap-ref / span.gap-tip) after the Xml2Html pass so class is not rewritten to tei-class. No @reason → no wrap.
TEI apparatus
Empty gap with a non-empty @reason draws one […] from CSS
:empty. The old lost / illegible / @content
bracket rules are gone, because an empty lost gap would otherwise be […]
plus a trailing ]. A gap that still has text (the underscore runs) does not get a second
mark. The reason stays the existing tip (span.gap-ref / span.gap-tip,
above). Blank or missing @reason still does not wrap.
@hand="#id" points at handNote. TeiHand runs after TeiGap in the same post-Xml2Html pass, because the words are in the header and collection
documents then drop teiHeader. The tip is span.hand-ref / span.hand-tip with class, not tei-class. data-hand-tip
makes convertFragment's re-entry a no-op, same reason as data-gap-tip and
data-calendars.
data-medium is copied from handNote/@medium. A lone mark is box-shadow: inset 0 -1px. A nested hand clears that shadow and paints a 1px no-repeat gradient
whose bottom is 3px above the edge. A thicker inset (inset 0 -3px) is a wider band on the
same edge and would cover the outer line, because the descendant paints after the ancestor. del[rend=overstrike] stays the red line-through. That line is the deletion. The medium marks
are separate.
An unresolved or ill-formed @hand is PageError.UnresolvedHand once per
pointer. The element is not wrapped. It fails the build the same way NoRef does, unless
the site sets treatErrorsAsWarnings.
choice / abbr / expan have no converter. CSS parenthesizes
whichever child is second, so authored order is the reading order. Titlo is abbr/@rend=titlo as an overline, not a second element. The TEI writer clings abbr
and expan the way it clings sic and corr. An abbreviation in
the source is not supplied and not corr. reg joins the
off-state of expan and corr, parentheses included, and print puts it back.
reg is not a new element and is not italic.
TEI facsimiles
Collector site.xml <tei facsimilesUrl> is _site_config.yml facsimiles-url. When it is set, each DocumentContent with a non-missing pb gets a synthetic FacsimilePage
at P/facsimile.html (registered next to PdfPage; parent overridden like
chunks so /P/ is not a directory listing). Published path follows collection alias (/rgada/003/facsimile.html). JPEG src is {facsimiles-url}{TEI source directory}/{n}.jpg (the collection folder in the GCS tree, not
publishedPath); pb@facs wins. Missing pbs are omitted from the
scroller.
TeiMarkup emits a.pb with id="p{n}" and the FA solid images icon (Regular is Pro and would be blank); it does not know the site URL. PageContent fills href to the viewer fragment and target="facsimile"
when a viewer exists. Collection Страницы numbers stay on the transcription #p{n}. {base}-{xx} translations share the original's viewer. Viewer photos link
back target="text". Not collector facets, not /collection/facsimile/P as the
emitted URL, not a 1060×1586 resize: both box. html.facsimile fills
the window; .facsimile-scroller is the inner scrollport (100dvh under the
header; footer hidden). Inbound old /alias/facsimile/P still resolves (see Collection
aliases).
Stamped TEI
Collector served GET /alias/P.xml as the TEI with publicationStmt, sourceDesc, langUsage, and calendarDesc filled in. The publisher now
does the same job for each TEI document (DocumentContent, so documentHeader
is present), as a generated file beside the HTML. The git file stays the source. HTML stays the
reading copy.
TeiDocumentPage is registered in Pages.add next to PdfPage,
at path.withExtension("xml"). It is written with the ordinary pages. TeiStamp re-reads the source (PageSource.readRaw) and does not use the processed
tree, which has already dropped teiHeader from a collection document's body. It does not
rewrite the git file. The writer is TeiXmlWriterConfig, so the XML declaration is
present.
Header order is the stamped fileDesc, then the other header children (encodingDesc and the like), then profileDesc, then revisionDesc.
fileDesc drops an existing publicationStmt and sourceDesc and
inserts the stamped pair after titleStmt. profileDesc replaces langUsage and calendarDesc and keeps abstract, creation, and correspDesc. New elements use the TEI root's namespace, so a later
writer does not emit xmlns="".
publicationStmt is publisher/ptr@target set to config.url as
stored, plus availability@status=free and licence@target (British spelling)
from the nested license (name, link, and a p for
holder when set). date@when is the build's HEAD commit time, the site zone
via Site.toOffsetDateTime, as YYYY-MM-DD. It is omitted when the source
directory is not in a git checkout. sourceDesc is one paragraph of the same path labels
as the citation (the single root omitted, then this document), plus a second paragraph of facsimiles-url when that is set. It is not the old <p>Facsimile</p>
placeholder and not an invented msDesc. langUsage is that document's xml:lang, not the site lang. calendarDesc is tei-default-calendar when set: calendar@xml:id and prose such as "Julian
calendar" for julian. revisionDesc says the published file is generated and
the file in git is the source, and includes the HEAD SHA when one exists. A stamp does not make the
file vanilla TEI. The corpus still has project markup (l inside p, pb/@missing, a name @ref that is a filename).
The XML page is not in the sitemap. DirectoryPage.filesystemChildren and CollectionIndex.originalsUnder drop TeiDocumentPage, so the stamped file is not a
listing row. translationsOf is gone. A {base}-{xx} page is dropped by isTranslation, and a +reg page by isReading. Neither check sees the
stamped XML page. Search and the graph stay on FullMarkupPage. Local serve guesses application/xml from the .xml name. Alias lookup finds the XML page by its
extension before it can return the HTML page.
On a TEI document the breadcrumb line (div.store-path-bar, or the same bar around
page-path on a standalone page) holds the TEI link, the GitHub link, and a Cite button on
the right. The title tooltips are "Published TEI", "Source file on GitHub", and "Copy
citation". The same GitHub tooltip is on the page-meta link. The citation is not
rendered. button.citation-copy carries it in data-citation. assets/js/citation.js (on the document, its chunks, and its facsimile) copies that attribute.
The text is the archive path (the same visible ancestors, then this document), Seo.canonical, the transcriber names (Расшифровка), and the license name plus
holder. Parts join with . and a final period. There is no access date. When HEAD exists,
the text then says Git hash and the full SHA, with a period. When origin is
GitHub, it then gives the file blob URL, the same URL as the GitHub link, with no further period. That
is not the commit URL. Seo.canonical is site.uri plus the published path,
the same string as <link rel="canonical">. Site.uri is URI(config.url). A url with no scheme is not rewritten, so a schemeless site URL
stays schemeless in the citation, the canonical link, and the sitemap. Facsimile and chunk headers are
built from the document page, so the citation URL is the document.
The GitHub link is on every authored page that has a source file: TEI documents, notes, entity
records, stores, and collections. On a TEI document, and on a store or collection, it sits on the
right of the breadcrumb line and is not repeated in page-meta. On a note or entity it
sits in page-meta. A chunk points at the whole file. The URL is the blob on the branch
tip (/blob/), not /edit/ and not a pinned SHA. Git walks up
from the source directory (FileRepositoryBuilder.findGitDir, including a worktree .git file), so a project under docs/ is prefixed on the path. It reads origin only and does not call readEnvironment, so a temp-dir test is not attached
to this repo via GIT_DIR. git@github.com:owner/repo.git, ssh://git@github.com/owner/repo.git, and https://user:token@github.com/owner/repo.git become https://github.com/owner/name. Userinfo is dropped. The host must be github.com
or www.github.com. A missing origin, or any other host, emits no link. The
branch is HEAD's branch. A detached HEAD uses GITHUB_REF_NAME when GITHUB_REF is refs/heads/…, otherwise origin/HEAD. Each path segment
is encoded, and slashes stay, so a branch name can contain a slash. There is no repository key in _site_config.yml.
Facsimile aside
The companion sits on the owning full transcription. The viewer page, named windows, and inbound
/alias/facsimile/P URLs stay.
IR: html class facsimile-aside (one class string, not html.facsimile). aside.facsimile-aside is a sibling of .wrapper.
data-facsimile-src is written by Facsimile.resolveLink from the same imageUrl as the viewer, and only when the page is not chunked. The primary img
has no src until the pane is shown.
facsimile.css stays viewer-only. The aside has its own sheet.
Script split: the off-class facsimile-aside-off is in siteSettings.js
(defaultOn, before paint). The milestone walker is the classic script /assets/js/facsimile-aside.js, loaded only on the owning full transcription.
1312px is the media query after the minimum row of an 800px text column plus wrapper padding plus gap plus 420px, with the scan at most 40rem, and between 1312 and 1528 only the aside shrinks.
The active scan is the last owning pb above the reading line (header bottom plus
0.75rem). Before the first milestone, the first scan. Prefetch only the next JPEG. The previous scan
stays until the next one has loaded.
Chunks get no aside and no script. A transcluded copy does not get the pane.
What the reader sees: site-publisher README [#facsimiles]. Full-viewer targets: ### Named windows.
Named windows
Opt-in _site_config.yml named-windows (default off). When on, every page
sets window.name and internal links target the destination viewer: hierarchyViewer (stores, collections, notes, home), apparatusViewer (entities,
entity lists), textViewer (collection documents), facsimileViewer (FacsimilePage). Name + scroll restore live in siteSettings.js (data-window-name); no analytics. Off: no helper attribute, no target on
wiki/header except the existing facsimile/text photo links. alter-rebbe.org turns it
on.
Posts come from _posts/, _drafts/, and the Obsidian daily-notes folder
(from .obsidian). A YYYY-MM-DD-title file name rewrites the page path to
/YYYY/MM/DD/title. post: true keeps the file path and adds a Refresh alias
at /YYYY/MM/DD/{file name} (post-title overrides the slug). date is required for that alias. An absolute permalink /YYYY/MM/DD/any-name is also a post, so the source file can have any name. Page.isPost is the path date, post: true, or that permalink. Page.date is the path date, else front-matter date, else the date in that
permalink. /posts lists real pages that are posts and skips Alias Refresh
files, so a post appears once. A permalink that does not start with / is
PageError.Permalink and is not installed. aliases may still be relative to
the page directory or absolute. _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 class heading on the title node
(HTML hN nested after convert; TEI tei-head; DocBook db-title).
Converters stamp both when they mark a section (Section.markHeaded / nestSections). Section.normalize and Toc find .heading
(not Markup.isSectionHeader); permalinks are added unless an anchor child is
already there. 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 (PageContent.title, same as HTML h1 / DocBook db-title): TEI titleStmt/title from process (tei-title
after Xml2Html; @type="main" if several); store / collection / entityLists child title from Content.parse
(no process). Not body head, not bibl/cit titles,
not entity names. Authored titles are 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} on a Markdown ul/ol (FlexMark leaves the IAL
on the last li) and a top-level [TOC] paragraph become div.toc-placeholder. insertToc replaces the first element with Toc.PlaceholderClass. Not in lists, quotes, code, or a resolved [TOC]: link. HTML
may author the class directly.
DocBook
Same shape as TEI: .xml files, root-element disambiguation, Xml2Html("db") then dialect converters, no HtmlIr.normalize. Prefix db (title → db-title). Claimed roots: article, book, chapter, appendix, part, set, preface, refentry, topic — not section. Nested section / sect1–sect5 / simplesect / chapter / appendix / preface rename to div then Section.mark; a claimed root is not renamed (so a chapter file stays <chapter>). 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 gathers every body, strips inner bodies from parent
nodes (inner stubs stay), then strips bodies from the tree. PageContent.renderAuthored remaps after transclusion; appendReferenced wraps
table-local lists then appends document div.footnotes as the last child of the selected
tree. Stubs become numbered <a>s.
One Footnote plus FootnoteScope: Document (arabic,
chunk/page end), Table(k) (per-table lowercase Latin letters), Nested(parentId) (one-level letters local to the parent body). Combining decides kind only. Emit-time k is assigned in document order on the expanded tree being
written, among layout tables that still have a Table-kind id there. A host table and a transcluded
copy in one HTML get _table_1_fn_a vs _table_2_fn_a. k may
differ between /P.html and a chunk, same as arabic remapping.
Tables. A call site is Table when its nearest authored ancestor is a
layout table (XmlElement.Table, not collection-index / document-header, not inside a *-tip). Innermost table wins. The wrapper is div.table-with-notes > table + div.footnotes.table-footnotes (not
tfoot). Letters restart at a per table (letterLabel: a…z, aa, ab). Markdown still defines those bodies at
file end; the published page moves them. AsciiDoc footnote:[…] is written at the call
site; the published page still lists those notes under the table. Do not table-scope markers found in
a parent note's nodes.
Chrome. Collector CollectionIndex.generate and PageHeader.collectorPageHeader use TeiMarkup.finishFootnotes → Footnote.finish(..., localTables = false), which forces Document on every tree site so those fragments stay one arabic series. Class exclusion already skips collection-index / document-header; localTables = false is the
policy for any other table in a finish fragment. Inner-only ids in chrome may still be
Nested.
Option N. A stub whose only sites are inside one parent body's nodes
is Nested(parentId) (parent correlation id after any transclusion prefix). The inner list
is span.footnotes.nested-footnotes from body(nested), never stored in Footnote.nodes. Hover on the outer marker uses nodes only (attachTips =
false inside tips, including in Footnote.finish), so the tooltip shows inner
letter markers and not the inner list. Depth greater than 1 is PageError.FootnoteNesting
(promote onto the outermost parent's inner series). Cycles are FootnoteCycle: flatten
those ids to Document and emit the bodies in the document list (no invented tree markers). Same id in
running text and a note is reuse, not nesting.
resolveLink(combined, emitted) never uses Map.apply: missing from
combined is UnknownFootnote plus unresolved class; in combined but not emitted leaves the
IR stub (no error); emitted hit becomes the numbered <a> / optional tip. Do not pass
the harvest map as emitted.
Transclusion expandHop copies the closure of bodies reachable from
copied links (footnotes.get, not apply), prefixes a copy of each body's
nodes with prefixCorrelationTree (descendants under p / l / hi too), and never writes prefixed nodes back into toContent.footnotes.
Converters pass a leftover-container predicate to Footnote.unwrapLeftovers (Markdown
div.footnotes, AsciiDoc div#footnotes); IR bodies are lifted, the wrapper
dropped. Published div.footnotes is only the list appendReferenced adds. TEI
still converts only place="end"; place="foot" and unplaced note
are unchanged. @n and DocBook footnote/@label are an optional display marker
when non-blank. The converter copies the value onto the link and body stubs as footnote-label. Footnote.remapped copies it, so chunk renumbering and
transclusion keep the marker. The call-site link and the backlink show it. Series position, scope, and
fragment ids stay on the generated series (_footnote_1, _table_1_fn_a, _footnote_1_n_a). A supplied marker does not skip or reshuffle later notes. The same marker
may appear on more than one note. A blank value is absent.
Errors (all in PageError.all; kebab-case Kind.id): UnknownFootnote; OrphanFootnote (body with no tree site and no id in any
harvested nodes); FootnoteScopeConflict (two tables, or table plus running
text, or two nested parents; fallback Document); FootnoteNesting; FootnoteCycle.
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 <input>, 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 prose.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 <b>(1)</b>, 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 <p> 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: <aside class="aside">, optional div.aside-title. No data-type. CSS styles only these classes. HTML that is already IR is left alone.
AsciiDoc leftovers (div.sidebarblock; div.content already unwrapped as
spurious; optional div.title) convert in AsciiDocMarkup.cleanup. Bare HTML
<aside> (Markdown HTML blocks and .html pages) gets class="aside" in HtmlIr.normalize. No Markdown/Obsidian/TEI source syntax.
Quotes
IR: <blockquote class="quote">, 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.cleanup — quoteblock is not unwrapped as a spurious wrapper, or
title and attribution would become siblings of the quote. Markdown CommonMark > and bare
HTML <blockquote> 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. @who/ @source are not turned into attribution text. Standalone bibl is not a quote.
Strikethrough
IR is HTML <del> (user-agent line-through; no extra class). FlexMark ~~ emits it once StrikethroughExtension is on. AsciiDoc leftover span/mark.line-through and HTML <s> become <del>
in HtmlIr.normalize. TEI <del> is already the element. Not harvested.
Figures
IR: <figure class="figure">, optional figcaption.figure-caption. Not
harvested. HTML that is already IR is left alone. Inline images stay <img>.
AsciiDoc leftovers (div.imageblock; div.content already unwrapped;
optional div.title after the image) convert in AsciiDocMarkup.cleanup.
Markdown/HTML: a <p> whose only child is <img> (or a lone linked <img>) becomes a figure in HtmlIr.normalize; title on the image
is the caption and is removed from the <img>. TEI: <graphic url>
becomes <img src> in the first pass; <figure> with <head>/<figDesc> converts in the second pass so IR class is
not prefixed. Wiki ![[image]] embeds stay <img> (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 <object type="application/pdf" data="…">
(inner fallback <a>) 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 <object type="application/pdf"> 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 <iframe> / <embed>.
Video
IR: local file is <video class="video" controls> with an inner fallback <a>; YouTube/Vimeo is <iframe class="video-embed"> (src kept).
Not harvested. Print CSS hides both.
AsciiDoc leftovers (div.videoblock; optional div.title; inner video or player iframe) convert in AsciiDocMarkup.cleanup. A title
becomes Figure IR around the player. Bare HTML <video> gets class / controls / fallback link in HtmlIr.normalize; a YouTube/Vimeo iframe gets class="video-embed" (other iframes are left alone). Wiki ![[file.mp4]] (webm / ogv / m4v) becomes the local IR in WikiLink.embed. No JS player.
Glossary
Harvested from markup-neutral HTML: class="glossary" on the list, class="glossary-item" with an id on each term. Links to those ids get definition
tooltips (span.glossary-ref / span.glossary-tip), same Tip as
footnotes. Print and html.glossary-expand inline the definition in parentheses.
Term id is the id on <dt> if present, otherwise the term text with
spaces turned into hyphens (Alter Rebbe → Alter-Rebbe).
Bibliography
Two kinds, usable together on one page. Ids do not collide: citeproc entries are #bibl-{key}; native entries keep the authored id.
External. Dialect syntax → Citation IR (span.citation /
span.citation-item with data-key, optional data-locator, data-mode; empty div.bibliography placeholder). Then a per-document BibTeX file plus citeproc-java (CSL). No site-level bibliography file or style;
both bibliography and csl are required on the document’s front matter.
Locale is page lang, else site lang, else en-US. .bib files are ignored at scan (*.bib in Ignore.internal) so they
are not published; citeproc still reads them from the source tree. Un-ignore in _site_ignore to copy one to the site. Cited-only list: Bibliography.resolve fills
an empty placeholder, or appends if there is none; it does not replace a native list. Unknown keys →
span.unresolved-citation and a page error (not reported on chunks). Resolved in-text
cites become a.citation linking to #bibl-{key} on the matching csl-entry (first key if several).
AsciiDoc: Java extensions (cite, citenp, bibliography::[]);
no Ruby gem. The inline-macro regexp allows an empty target so cite:[key] matches; CiteMacro.parseTarget joins positional attributes (not just 1).
Markdown: Pandoc citation syntax scanned after FlexMark; no extra extension. code and
pre are not scanned, so @key there stays literal. A cite created inside them
would be invisible to lookup, which stops at code, and the characters would be gone.
TEI: ref/ptr @cRef (optional @n locator) → the
same Citation stubs. Empty div type="bibliography" is the placeholder. A
bare @target that is a bib key and is not a native listBibl id is
also a stub.
Internal. Authored list harvested like glossary (BibliographyItem IR:
class="bibliography-item" with id; harvest/tips only). Dialects convert
native lists (TeiMarkup listBibl/bibl, DocBookMarkup bibliography/ biblioentry, AsciiDoc [bibliography] / [[[id]]] empty-anchor hoist). Links to those ids get a.citation and a citation-tip of the entry. Glossary tip wins if the same id is
also a glossary term.
AsciiDoc: [bibliography] (div.ulist.bibliography) converts before the
ulist wrapper is unwrapped, so the class is not lost. [[[id]]] empty anchors
are hoisted onto li.bibliography-item; <<id>> is an ordinary xref.
TEI: in-document listBibl / bibl / biblStruct (not in teiHeader) become class="bibliography" / bibliography-item with
xml:id copied to id. ref/ptr @target="#id" stay internal links. Empty pointers get @n or the id as text. A
bare @target that matches a listBibl id is rewritten to #id.
cit with a quote stays Quote IR; cit that is only a pointer is
not a blockquote. Authored listBibl is kept (not cited-only, not auto-appended).
Markup-independent. Front matter pdf: true adds a PdfPage at P.pdf (Chromium print of P.html, written last). Site-header format icons on the
HTML (and chunks) link to it; the header is display: none in print.
Native XML pretty-print
--pretty-print and prettyPrintSite rewrite authored TEI and DocBook with
that dialect's XmlWriterConfig. The walk uses Ignore and parseXmlDocument. It does not call Pages.load or Markup.process.
process renames elements toward HTML, and dropIncludes deletes xi:include.
The writer suppresses an xmlns that is already bound to the same URI. It does not
invent xmlns="" for a name with no URI, so a constructed Atom entry and
sitemap url stay in the parent namespace. selfCloseEmpty on the TEI and
DocBook configs writes an empty element as <e/>.
XmlWriterConfig.plus is removed. A set union cannot follow Xml2Html and
the dialect renames: TEI p becomes tei-p, and DocBook para
becomes p. Published pages stay on HtmlXmlWriterConfig. A post-rename
overlay for the TEI names that remain in a published page is not this feature.
Prose pretty-print
Markdown and AsciiDoc are reflowed by splicing into the original bytes. Flexmark offsets and Asciidoctor line matches select the prose. The parsers do not print.
Formatter.render reprints the tree. It has no sentence mode, a leading --- without a YAML extension becomes a setext heading, and a wrap can put a block marker at
column 0. An AsciiDoc reprint is worse: Document.getSource() drops trailing spaces, there
is no inline source map, and a successful include:: is reported on the parent line.
Paiges indents XML. Prose only needs a column budget.
On AsciidoctorJ 3.0.1 the parse copy escapes include:: and ifdef:: lines.
A span is written only when the parsed lines still match the original file. SafeMode.SECURE alone rewrites an unescaped include to link:. The wiki extension
is only on the prose parser, so HTML rendering still uses MarkdownWikiLink.
--sentence-per-line defaults to true. --sentence-per-line=false only
wraps. The file extensions are Markup.extensions (md, markdown,
adoc, asciidoc). There is no second list.
TEI
Each TEI document is published as generated XML beside the HTML. The header is stamped from site
config and the build's HEAD commit. The git file stays the source. See Stamped TEI. A url
with no scheme is not rewritten on the canonical URL.