Weave


1 Overview

In most Literate Programming systems, weaving is the act of generating a human-readable document. This usually means writing a PDF or an HTML page.

For now, Lilac's main goal is to generate HTML pages. There are several reasons:

  1. HTML is easier to consume on the web than PDFs.

  2. PDFs are obsessed with page numbers because they are designed to be printed on real physical paper; we don't care about page numbers at all because you don't have to print web content in order to consume it.

  3. We want to make our HTML pages somewhat interactive, and interactivity is easier to accomplish in HTML.

Because we generate HTML, we also have to decorate it with CSS. Both concerns are addressed in this doc. There is also a sprinkling of JavaScript (via ClojureScript) in Frontend for some interactivity.

The architecture for weaving looks like this:

  1. Convert the data:Object (found inside a data:Archive, which is just a JSON file) into HTML.

  2. Generate CSS separately.

  3. Add some JS for interactivity.

One main advantage of this model is that other "viewers" can be created, as long as they understand the same JSON. While we would prefer to use something more "typed" like Protobufs, Transit, or EDN, we choose JSON for its universality. Also, none of these alternative formats support both Haskell and ClojureScript well.

2 HTML foundations

2.1 Lilac.Weave module

This module is responsible for generating the HTML from a data:Archive.

module:Lilac.Weave
🎯 src/Lilac/Weave.hs
module Lilac.Weave
  ( isGtocAncestor,
    prepareCitations,
    renderAsText,
    weaveObject,
  )
where

import Citeproc
  ( ItemId (unItemId),
    Reference (referenceId),
    Result (resultBibliography, resultCitations),
  )
import Citeproc qualified as CP
import Citeproc.CslJson
  ( CslJson
      ( CslBaseline,
        CslBold,
        CslConcat,
        CslDiv,
        CslEmpty,
        CslItalic,
        CslLink,
        CslNoCase,
        CslNoDecoration,
        CslNormal,
        CslQuoted,
        CslSmallCaps,
        CslSub,
        CslSup,
        CslText,
        CslUnderline
      ),
    parseCslJson,
  )
import Data.Aeson (encode)
import Data.Char (isAsciiLower, isAsciiUpper, isDigit)
import Data.IntMap.Strict qualified as IntMap
import Data.List (groupBy, partition)
import Data.Map.Strict qualified as M
import Data.Text qualified as T
import Data.Text.Lazy qualified as TL
import Data.Text.Lazy.Encoding qualified as TLE
import Data.Tree
  ( Forest,
    Tree (Node),
  )
import Lilac.Compile (gciToName, makeRelativeToObjectPath, parseCitationStyle)
import Lilac.Parse
  ( compressProse,
    compressStyledText,
    isNumericText,
    maskToStyles,
    styleMaskFrom,
    tableHorizontalLine,
    unparseLink,
    unparseMathFragment,
  )
import Lilac.Types
  ( Archive
      ( ancestry,
        backlinks,
        bibs,
        csls,
        glossary,
        gtoc,
        objects,
        offsets,
        symbols
      ),
    BToken (BTokenLinkTarget, BTokenPadding, BTokenString),
    Block (body, meta, parentOid),
    Cell
      ( CBlock,
        CCommand,
        CFigure,
        CFootnote,
        CGlossaryTerm,
        CHeading,
        CListItem,
        CMathFragment,
        CParagraph,
        CTable
      ),
    Citation (cites, noteNumber, prefix, styleVariation, suffix),
    Cite (id, locator, prefix, suffix),
    Command (PrintBibliography, PrintGlossary),
    Figure (caption, link, meta),
    Footnote (body, name),
    GCI (GCI, unGCI),
    GPath,
    GTOC (unGTOC),
    GlossaryTerm (definition, term),
    Heading (Heading, body, level, meta, section),
    HtmlHead
      ( injection,
        onlyLocalImports
      ),
    LCI (LCI, unLCI),
    Link (Link, name, target),
    LinkOid (LinkOid, name, oid),
    LinkTarget
      ( LinkTargetFilePath,
        LinkTargetFootnote,
        LinkTargetLineLabel,
        LinkTargetOid,
        LinkTargetURI
      ),
    ListMarker
      ( LItemEnd,
        LItemStart,
        LOrderedEnd,
        LOrderedStart,
        LUnorderedEnd,
        LUnorderedStart
      ),
    MathFragment (body, meta),
    Object (orgDoc),
    OrgDoc
      ( bibPaths,
        cells,
        citations,
        cslPath,
        footnoteRefs,
        glossary,
        lineLabels,
        meta,
        oid
      ),
    PToken
      ( PTokenCitation,
        PTokenDivEnd,
        PTokenDivStart,
        PTokenLink,
        PTokenMathFragment,
        PTokenStyledText
      ),
    ProjectConf
      ( bibliography,
        citationStyle,
        homepage,
        htmlHead,
        name,
        version
      ),
    Prose (Prose, unProse),
    Style
      ( Bold,
        Code,
        Italic,
        Smallcaps,
        Subscript,
        Superscript,
        Underline,
        Verbatim
      ),
    StyledText (StyledText, body, style),
    Table (caption, grid, meta),
    WeaveConf
      ( archive,
        citeprocResult,
        originObjectPath,
        projectConf,
        rootRelativePrefix
      ),
    decodeGPath,
    emptyGPath,
    encodeGPath,
    gciToObjCell,
    gciToObjPath,
    getBlock,
    getNamedCells,
    getProseContents,
    getSectionNumber,
    getTangled,
    getTangledBlocks,
    markingChars,
    markingCharsInvisible,
    ptext,
    stext,
    stripLinks,
    stripStyles,
  )
import Lucid
  ( Attributes,
    HtmlT,
    a_,
    alt_,
    async_,
    body_,
    br_,
    button_,
    charset_,
    class_,
    code_,
    col_,
    colgroup_,
    crossorigin_,
    data_,
    dd_,
    div_,
    doctypehtml_,
    dt_,
    figcaption_,
    figure_,
    footer_,
    h1_,
    h2_,
    h3_,
    h4_,
    h5_,
    h6_,
    head_,
    height_,
    hr_,
    href_,
    id_,
    img_,
    li_,
    link_,
    main_,
    meta_,
    nav_,
    ol_,
    p_,
    pre_,
    rel_,
    renderText,
    script_,
    span_,
    src_,
    sup_,
    table_,
    target_,
    tbody_,
    td_,
    th_,
    thead_,
    title_,
    toHtml,
    toHtmlRaw,
    tr_,
    type_,
    ul_,
    width_,
  )
import Lucid.Base (hoistHtmlT)
import Network.URI
  ( uriAuthority,
    uriPath,
    uriScheme,
  )
import Network.URI qualified as N
import Relude
  ( Bool (False, True),
    Char,
    Either (Right),
    FilePath,
    IO,
    Identity,
    Int,
    Map,
    Maybe (Just, Nothing),
    ReaderT,
    String,
    Text,
    Type,
    abs,
    any,
    ask,
    catMaybes,
    comparing,
    concat,
    concatMap,
    drop,
    dropWhile,
    elem,
    filter,
    flip,
    foldMap',
    foldl',
    foldlM,
    forM_,
    fromMaybe,
    fst,
    id,
    inits,
    isJust,
    isPrefixOf,
    length,
    listToMaybe,
    map,
    mapMaybe,
    max,
    maxBound,
    maybe,
    maybeAt,
    maybeToList,
    mempty,
    negate,
    not,
    notElem,
    null,
    on,
    otherwise,
    pure,
    repeat,
    reverse,
    runReaderT,
    sequence_,
    show,
    snd,
    sort,
    sortBy,
    sortOn,
    swap,
    take,
    takeWhile,
    when,
    zip,
    (!!?),
    ($),
    (&&),
    (+),
    (-),
    (.),
    (/=),
    (<),
    (<$>),
    (<=),
    (<>),
    (=<<),
    (==),
    (>),
    (||),
  )
import Relude.Extra.Map (insert, keys, lookup, lookupDefault, toPairs)
import Skylighting qualified as Sky
import System.IO (utf8)
import System.OsPath qualified as P
import Text.Blaze.Html.Renderer.Text (renderHtml)

type:HtmlWeaver

f:weaveObject
f:sortFootnoteCells
f:weaveCell
f:toSafeHtmlId
f:weaveDocMeta
f:weaveHeading
f:getLinkAnchor
f:lciAttrs
f:wrapWith
f:wrapWithDiv
f:weaveProse
f:weavePToken
f:isExternalLink
f:weaveStyledText
f:weaveListMarker
f:weaveBlock
f:weaveImportantMetadata
f:weaveInlineBlockControls
f:weaveBlockBody
f:getBlockContentsWithoutLinks
f:weaveBlockAncestry
f:weaveParentBlock
f:getLinkToGCI
f:weaveBlockMeta
f:colorizeText
f:spliceLinks
f:weaveLink
f:weaveLinkTarget
f:weaveLinkTargetLinkOid
f:weaveLinkDisplayName
f:getExternalRelPath
f:showBrokenLink
f:weaveFigure
f:weaveMathFragmentStandalone
f:weaveMathFragmentInline
f:weaveTable
f:weaveTableCaption
f:weaveTableColumnGroups
f:getTableCellsFromRow
f:getFirstColumnCells
f:tableCellLacks
f:getMaxRow
f:getMaxCol
f:proseHas
f:weaveTableRows
f:weaveTableHead
f:weaveTableBody
f:getHeaderRowIndex
f:getRootRelativePath
f:weaveFootnote
f:weaveFootnoteBacklink
f:weaveGlossaryTerm
f:renderAsText
f:proseToCslJsonText
f:proseToMaybeCslJsonText
f:citationToCiteprocCitation
f:citeToCiteprocCitationItems
f:cslJsonTextToProse
f:prepareCitations
f:weaveCitation
f:weaveBibliography
f:weaveGlossary
f:weaveCollectedGlossaryTerm
f:getRootRelativeOrgDocCellLink
f:getRootRelativeOrgDocLink
f:toNormalizedText
f:weaveAllTocs
f:weaveToc
f:weaveTocHeading
f:weaveGtoc
f:weaveGtocTree
f:weaveGtocForest
f:isGtocAncestor
f:genHeadingAncestry
f:weaveHeadingAncestry
f:weaveNavbar
f:weaveBreadcrumbs
f:weaveFooter
f:weaveMain
f:weavePageMetrics
f:weaveTangledBlocksList
f:weaveBacklinks
f:weaveBacklinksForExternalObj
f:weaveBacklink
f:weaveNamedCellsIndex

2.2 HtmlWeaver monad

During the weaving process, we want to access various other settings that may not be apparent at the data:Cell level. To capture these values in one place, we use a data:WeaveConf. Instead of passing this around to all functions, we put it inside a ReaderT so that it is available for all weave-related functions that are using the type:HtmlWeaver. This is analogous to data:OrgParserState which is available to all parsing functions in type:OrgParser.

data:WeaveConf
type WeaveConf :: Type
data WeaveConf = WeaveConf
  { projectConf :: ProjectConf,
    archive :: Archive,
    originObjectPath :: GPath, -- 1
    outDir :: GPath,
    rootRelativePrefix :: GPath,
    citeprocResult :: CP.Result Prose
  }

The originObjectPath 1 is used to check whether a given data:LinkOid refers to a cell that exists in the current object being woven in f:weaveObject (so we know whether to refer to the cell with just #... as the HTML id attribute, or whether as path/to/external/object.html#... because the cell belongs to another object).

The type:HtmlWeaver is a monad. We stack ReaderT on top of Lucid's HtmlT to create the monadic environment where we can read from data:WeaveConf whenever we want.

type:HtmlWeaver
type HtmlWeaver :: Type -> Type
type HtmlWeaver a = HtmlT (ReaderT WeaveConf Identity) a

Note that the inner monad is the ReaderT. This way we don't have to call lift every single time we want to use a Lucid combinator like head_ or body_.

Now we just need a starting point for using HtmlWeaver. For weaving, the final thing we want to generate is a Text value. Lucid gives us renderTextT, which can run inside a monad. This means we can use that to generate Text from type:HtmlWeaver; this is what f:renderAsText does.

f:renderAsText takes a WeaveConf and an HtmlWeaver a action, and converts it to a Text type. It's there because we cannot use any of the default rendering functions that Lucid gives us (because they operate on Lucid's HtmlT, not the HtmlWeaver we use).

f:renderAsText
renderAsText :: WeaveConf -> HtmlWeaver a -> Text
renderAsText weaveConf weaver =
  TL.toStrict
    . renderText
    $ hoistHtmlT (flip runReaderT weaveConf) weaver

The hoistHtmlT function is a convenience helper provided by Lucid, which is equivalent to hoist from Control.Monad.Morph. It applies a function to the inner monad of HtmlT (recall that type:HtmlWeaver is just a HtmlT whose inner monad is a ReaderT). So in this case the inner monad is ReaderT WeaveConf Identity, and running runReaderT will result in Identity () (where () is the a in the type signature for f:renderAsText). So then the call to hoistHtmlT gives us HtmlT Identity a, which is what Lucid's renderText expects.

3 CSS foundations

3.1 Lilac.Weave.Css module

module:Lilac.Weave.Css
🎯 src/Lilac/Weave/Css.hs
module Lilac.Weave.Css (genCss) where

import Clay
  ( Color,
    Css,
    Size,
    a,
    absolute,
    alignSide,
    auto,
    backgroundColor,
    before,
    block,
    body,
    bold,
    border,
    borderBottom,
    borderBottomLeftRadius,
    borderBottomRightRadius,
    borderBottomWidth,
    borderBox,
    borderCollapse,
    borderLeft,
    borderRadius,
    borderRight,
    borderStyle,
    borderTop,
    borderTopLeftRadius,
    borderTopRightRadius,
    bottom,
    boxSizing,
    button,
    center,
    code,
    collapse,
    color,
    content,
    dd,
    display,
    displayNone,
    displayTable,
    div,
    dl,
    dt,
    em,
    empty,
    figcaption,
    figure,
    fitContent,
    fixed,
    float,
    floatLeft,
    fontFamily,
    fontSize,
    fontStyle,
    fontVariant,
    fontWeight,
    footer,
    fr,
    grid,
    gridTemplateColumns,
    h1,
    h2,
    h3,
    h4,
    h5,
    h6,
    height,
    hidden,
    hover,
    hr,
    html,
    inline,
    inlineBlock,
    italic,
    left,
    li,
    lineHeight,
    listStyleType,
    main_,
    margin,
    marginBottom,
    marginLeft,
    marginRight,
    marginTop,
    maxHeight,
    maxWidth,
    minHeight,
    monospace,
    nav,
    none,
    normal,
    ol,
    opacity,
    overflow,
    overflowX,
    overflowY,
    p,
    padding,
    paddingLeft,
    paddingRight,
    pct,
    position,
    pre,
    pretty,
    pt,
    px,
    relative,
    rem,
    renderWith,
    rgb,
    sansSerif,
    serif,
    sideRight,
    smallCaps,
    solid,
    span,
    sticky,
    stringContent,
    sup,
    table,
    tbody,
    td,
    textAlign,
    textDecoration,
    th,
    thead,
    top,
    tr,
    ul,
    underline,
    unitless,
    vAlignSub,
    vAlignSuper,
    verticalAlign,
    vh,
    visibility,
    visible,
    whiteSpace,
    width,
    (#),
    (&),
    (**),
    (-:),
    (?),
    (|>),
  )
import Clay.Text qualified as ClayText
import Data.Bits
  ( shiftR,
    (.&.),
  )
import Data.Text qualified as T
import Data.Text.Lazy qualified as TL
import Relude
  ( Bool (True),
    Int,
    Maybe (Just),
    String,
    Text,
    Type,
    Void,
    concatMap,
    forM_,
    fromMaybe,
    otherwise,
    pure,
    zip,
    ($),
    (.),
    (<),
    (<>),
    (>),
  )
import Relude.Extra.Map (insert)
import Skylighting qualified as Sky
import Text.Megaparsec qualified as MP
import Text.Megaparsec.Char (char)
import Text.Megaparsec.Char.Lexer (hexadecimal)

f:genCss
f:rawCss
f:kateLilac
f:defaultCss
f:roundedRectangleLink
f:yx
f:ev
f:colorFrom
f:hexColorParser
Colors
type:ColorParser
f:fontSerif
f:fontSans
f:fontMono
f:headingPrefix

3.2 CSS generator

f:genCss is the main top-level function which generates the CSS. There are two main parts: one is the Syntax highlighting theme 2 used in Code blocks, and the other is the rest of Lilac (f:defaultCss) which uses the Clay CSS preprocessor. These two parts are concatenated together in f:genCss, so that Lilac's settings take precedence if there are any conflicts with the Skylighting parts.

f:genCss
genCss :: Text
genCss =
  T.unlines
    [ T.pack $ Sky.styleToCss kateLilac, -- 2
      TL.toStrict $ renderWith pretty [] defaultCss,
      rawCss
    ]

f:defaultCss is the central place where we specify all of the rest of the CSS rules.

f:rawCss is any raw CSS we want to append, for any CSS areas that are not well supported by Clay.

f:rawCss
rawCss :: Text
rawCss =
  """
  /* rawCss */
  css:counter-suffix-parenthesis
  css:navbar-link-target-adjustment
  css:mobile-tearing-fix
  """

3.3 Sizing

We prefer to use rem (root element) over em (element), because rem doesn't compound over nested elements like em does.

3.3.1 Helpers

f:yx is a horizontal/vertical size helper. It accepts a function and two sizes for the horizontal and vertical parts. E.g., instead of calling

padding (px 6) (px 10) (px 6) (px 10)

you can do

yx padding (px 6) (px 10)

to save some keystrokes and decrease the chance of typos.

f:yx
yx ::
  (Size sz -> Size sz -> Size sz -> Size sz -> Css) ->
  Size sz ->
  Size sz ->
  Css
yx f x y = f x y x y

f:ev is like f:yx, but uses the same size for everything.

f:ev
ev :: (Size sz -> Size sz -> Size sz -> Size sz -> Css) -> Size sz -> Css
ev f x = f x x x x

3.4 Fonts

We use fonts from the open source "Source" family by Adobe: Source Serif, Source Sans, and Source Code Pro:

font-serif
Source Serif 4
font-sans
Source Sans 3
font-mono
Source Code Pro

These font names are reused exactly in text:source-fonts.css, which is why we parameterize them here as their own code blocks. This way we can guarantee that these stay in sync (regardless of whether we pull in the fonts from Google web fonts or from the vendored artifacts).

f:fontSerif
fontSerif :: Css
fontSerif = do
  fontFamily ["font-serif"] [serif]
f:fontSans
fontSans :: Css
fontSans = do
  fontFamily ["font-sans"] [sansSerif]
f:fontMono
fontMono :: Css
fontMono = do
  fontFamily ["font-mono"] [monospace]
  fontSize (em 0.9)

The verbatim and code classes use a smaller 0.9 em font size, because otherwise they look too big (because typically monospaced font tends to look bigger than non-monospaced font).

These fonts are loaded into the HTML page by Google Fonts.

3.5 Colors

Clay uses its own internal Color type, which can only be constructed from integer values. However we want to talk about colors using strings because using something like #22ffff can allow Emacs to colorize it in the buffer, aiding visualization.

f:colorFrom converts a Text into a Color, defaulting to #ff0000 (red) if parsing fails.

f:colorFrom
colorFrom :: Text -> Color
colorFrom input = fromMaybe (rgb 255 0 0) $ MP.parseMaybe hexColorParser input

This depends on f:hexColorParser which uses Text.Megaparsec.Char.Lexer.hexadecimal and checks the numeric bounds before retrieving the red, green, and blue values from the number using bit masks.

f:hexColorParser
hexColorParser :: ColorParser Color
hexColorParser = do
  _ <- char '#'
  n <- hexadecimal
  let c
        | n > 0xffffff = rgb 255 255 255
        | n < 0 = rgb 0 0 0
        | otherwise = rgb rr gg bb
        where
          rr = 0xff .&. shiftR n 16
          gg = 0xff .&. shiftR n 8
          bb = 0xff .&. n
  pure c

The ColorParser is a type synonym for a "standard" Megaparsec type defined in the documentation.

type:ColorParser
type ColorParser :: Type -> Type
type ColorParser = MP.Parsec Void Text

Now for the actual colors. Currently there is only a light theme.

Colors
colorBg :: Color
colorBg = colorFrom "#ffffff"

colorText :: Color
colorText = colorFrom "#000000"

colorCodeFg :: Color
colorCodeFg = colorFrom "#1f1c1b"

colorCodeBg :: Color
colorCodeBg = colorFrom "#f7f7f7"

colorCodeBorder :: Color
colorCodeBorder = colorFrom "#cccccc"

colorBreadcrumbSeparator :: Color
colorBreadcrumbSeparator = colorFrom "#888888"

colorNowebLinkFg :: Color
colorNowebLinkFg = colorFrom "#2161ac"

colorNowebLinkFgTxt :: Text
colorNowebLinkFgTxt = "#2161ac"

colorNowebLinkBg :: Color
colorNowebLinkBg = colorFrom "#d8f6ff"

colorNowebLinkVisitedFg :: Color
colorNowebLinkVisitedFg = colorFrom "#7d3c98"

colorNowebLinkVisitedBg :: Color
colorNowebLinkVisitedBg = colorFrom "#f8e9ff"

colorTocActive :: Color
colorTocActive = colorFrom "#eafaff"

colorTocActiveAncestor :: Color
colorTocActiveAncestor = colorFrom "#ededed"

colorActiveBg :: Color
colorActiveBg = colorFrom "#f1ffef"

colorActiveBorder :: Color
colorActiveBorder = colorFrom "#008000"

4 Object

The top-level weaving function is f:weaveObject, which weaves a data:Object value.

The f:weaveObject function sets up the main HTML bits (like the title, head, style sheets, etc), before finally delegating to various helper functions.

f:weaveObject
weaveObject :: Object -> HtmlWeaver ()
weaveObject object = doctypehtml_ $ do
  weaveConf <- ask
  HTML head
  body_ $ do
    let lciCellPairs = sortFootnoteCells object
    weaveNavbar
    weaveMain object.orgDoc.meta lciCellPairs
    weaveAllTocs lciCellPairs
    weaveHeadingAncestry lciCellPairs
    ClojureScript frontend script

f:sortFootnoteCells sorts footnote definition cells by the order in which they are referenced 3. This way, users don't have to keep them in order in the doc (users can freely change which ones are referenced first, without having to manually reorder the footnote definitions). The ordering matters because that's the order the cells are woven.

f:sortFootnoteCells
sortFootnoteCells :: Object -> [(LCI, Cell)]
sortFootnoteCells object =
  beforeFootnotes
    <> sortedFootnotes
    <> afterFootnotes
  where
    lciCellPairs = zip (map LCI [0 :: Int ..]) object.orgDoc.cells
    beforeFootnotes = takeWhile (not . isFootnote) lciCellPairs
    afterFootnotes =
      dropWhile isFootnote
        $ dropWhile (not . isFootnote) lciCellPairs
    sortedFootnotes =
      sortOn (\(_, cell) -> getFootnoteNumber cell)
        $ takeWhile isFootnote
        $ dropWhile (not . isFootnote) lciCellPairs
    getFootnoteNumber cell -- 3
      | (CFootnote footnote) <- cell,
        Just (n, _) <- lookup footnote.name object.orgDoc.footnoteRefs =
          n
      | otherwise = maxBound :: Int
    isFootnote :: (LCI, Cell) -> Bool
    isFootnote (_, cell) = case cell of
      CFootnote {} -> True
      _ -> False

4.1 Document metadata

f:weaveDocMeta weaves document metadata such as the title, author, and date.

f:weaveDocMeta
weaveDocMeta :: M.Map Text Text -> HtmlWeaver ()
weaveDocMeta m = do
  case lookup "title" m of
    Nothing -> pure ()
    Just title -> do
      h1_ [id_ "top"] $ toHtml title -- 4
      hr_ [class_ "title-underline"]
  f "author" $ p_ [class_ "doc-meta-author"]
  f "date" $ p_ [class_ "doc-meta-date"]
  where
    f k element = case lookup k m of
      Nothing -> pure ()
      Just x -> element $ toHtml x

For CSS, the title gets a special black circle U+25cf symbol to make it stand out 29, and an underline css:h1 (title) underline. The css:h1 (title) underline changes the horizontal rule to look a bit subdued (without the default black color).

css:h1 (title) underline
hr ? do
  border (px 0) solid colorCodeBorder
  borderBottom (px 1) solid colorCodeBorder
  ".title-underline" & do
    marginBottom (rem 1)

The document author and date also get some styling to set it apart from other text in css:Document metadata.

css:Document metadata
".doc-meta-author" & do
  fontStyle italic
  ev margin (px 2)
".doc-meta-date" & do
  fontStyle italic
  ev margin (px 2)

4.2 Main content and cells

The most important helper for f:weaveObject is probably f:weaveMain because it then calls f:weaveCell to weave all of the cells.

f:weaveMain
weaveMain :: Map Text Text -> [(LCI, Cell)] -> HtmlWeaver ()
weaveMain docMeta lciCellPairs = do
  main_ [] $ do
    weaveDocMeta docMeta
    forM_ lciCellPairs weaveCell
    let isHeading = \case
          CHeading {} -> True
          _ -> False
    when (any isHeading $ map snd lciCellPairs)
      $ toHtmlRaw @Text "</div>" -- 5
    weavePageMetrics lciCellPairs

The closing </div> tag is for closing up any heading container <div> elements that are created in 24.

Weaving cells just means delegating to each of the weavers that know how to handle each cell type. That's what f:weaveCell does.

f:weaveCell
weaveCell :: (LCI, Cell) -> HtmlWeaver ()
weaveCell (lci, cell) = case cell of
  CHeading heading -> weaveHeading lci heading
  CListItem listMarkers -> sequence_ $ map weaveListMarker listMarkers
  CParagraph prose -> p_ [] $ wrapWith lci $ weaveProse prose
  CBlock block
    | Just "index" <- lookup "name" block.meta,
      Just "true" <- lookup "hidden" block.meta ->
        pure () -- 6
    | otherwise -> weaveBlock lci block
  CMathFragment mathFragment -> weaveMathFragmentStandalone lci mathFragment
  CFigure figure -> weaveFigure lci figure
  CTable table -> weaveTable lci table
  CFootnote footnote -> weaveFootnote lci footnote
  CGlossaryTerm glossaryTerm -> weaveGlossaryTerm lci glossaryTerm
  CCommand cmd -> case cmd of
    PrintBibliography -> weaveBibliography lci
    PrintGlossary isGlobal -> weaveGlossary isGlobal

6 hides the block by not weaving it, if the code block is a magic "index" block which has been requested to be hidden, because this is low-level information which is already widely apparent by looking at the "Site contents" in the left sidebar 105. Index blocks are hidden by default, unless the user specifies otherwise.

4.3 HTML head

The HTML head (<head>) has some "imports" for the HTML page, including CSS and JS. It also sets the title of the page 7.

HTML head
head_ $ do
  meta_ [charset_ "utf-8"]
  title_ -- 7
    . toHtml -- 8
    . fromMaybe "Untitled"
    $ lookup "title" object.orgDoc.meta
  Load CSS
  load-favicon
  let onlyLocalImports'
        | Just htmlHead <- weaveConf.projectConf.htmlHead =
            htmlHead.onlyLocalImports
        | otherwise = False
  Google Fonts
  MathJax customization
  MathJax script
  custom-html-head

The use of toHtml 8 is to help Haskell infer the correct type. This is because Lucid will think that regular strings are of type Html () (this is how Lucid automatically escapes strings to be HTML-safe). The title_ function expects an Html () type, so we have to manually convert the Text we get out of lookup with toHtml.

4.3.1 CSS styles

Load CSS loads the top-level CSS we need, including the default CSS that we generate f:lilacWeave19.

Load CSS
cssPath <- getRootRelativePath "css/default.css" -- 9
link_ [rel_ "stylesheet", href_ cssPath]

f:getRootRelativePath 9 gets the relative path to the CSS file (relative from the output folder).

f:getRootRelativePath
getRootRelativePath :: Text -> HtmlWeaver Text
getRootRelativePath path = do
  weaveConf <- ask
  let rootRelRaw =
        T.dropAround (== '/')
          . T.pack
          . decodeGPath
          $ weaveConf.rootRelativePrefix
      rootRelPrefix
        | rootRelRaw == "" = "/"
        | otherwise = "/" <> rootRelRaw <> "/"
  pure $ rootRelPrefix <> path

4.3.2 Favicon

The favicon path is hardcoded to be at the root. It's up to the user whether they want to have one or not (and if they want one, it has to be favicon.png).

load-favicon
link_ [rel_ "shortcut icon", type_ "image/png", href_ "/favicon.png"]

4.3.3 Fonts

We use the Source family of fonts (Source Code Pro, Source Sans, Source Serif).

Google Fonts
let fontsLink =
      T.intercalate
        ""
        $ ["https://fonts.googleapis.com/css2?display=swap"]
        <> map (\(name, weights) -> name <> weights) fonts
    fonts :: [(Text, Text)]
    fonts =
      [ ( "&family=Source+Code+Pro:ital",
          ",wght@0,200..900;1,200..900"
        ),
        ( "&family=Source+Sans+3:ital",
          ",wght@0,200..900;1,200..900"
        ),
        ( "&family=Source+Serif+4:ital,opsz",
          ",wght@0,8..60,200..900;1,8..60,200..900"
        )
      ]
    defaultGoogleFonts :: HtmlWeaver ()
    defaultGoogleFonts = do
      link_ [rel_ "preconnect", href_ "https://fonts.googleapis.com"]
      link_
        [ rel_ "preconnect",
          href_ "https://fonts.gstatic.com",
          crossorigin_ "anonymous"
        ]
      link_ [rel_ "stylesheet", href_ fontsLink]
when (not onlyLocalImports') $ defaultGoogleFonts -- 10

We only pull in these Google fonts over the network if the user has not specified that they want local imports 10. The user is expected to supply these fonts themselves (host it themselves from the same domain where they are serving the woven HTML) if they request only local imports. See HTML <head> customization for the configuration, and Custom HTML head for how this is used during weaving.

4.3.4 MathJax

We use MathJax to render mathematical expressions (what we encode as the data:MathFragment). The MathJax JS import is based on the official instructions.

MathJax script
let defaultMathJaxImport :: HtmlWeaver ()
    defaultMathJaxImport =
      script_
        [ id_ "MathJax-script",
          async_ "",
          src_ "https://cdn.jsdelivr.net/npm/mathjax@4/tex-mml-chtml.js"
        ]
        ("" :: Text)
when (not onlyLocalImports') $ defaultMathJaxImport -- 11

We only import MathJax over the CDN 11 if the user does not request to only have local imports, because the MathJax JS import pulls in JS over a CDN).

MathJax is configurable, so we customize it in MathJax customization. Similarly, we disable this if the user requested local imports, with the understanding that they will provide their own configuration for MathJax. In practice, the user must also provide additional settings to tell MathJax not to use a CDN for importing fonts (because MathJax by default tries to pull fonts from a CDN).

MathJax customization
-- 12
let defaultMathJaxSettings :: Text
    defaultMathJaxSettings =
      """
      Default MathJax settings
      """
when (not onlyLocalImports') $ script_ [] defaultMathJaxSettings

Default MathJax settings 12 is required to be defined before the script is imported.

Default MathJax settings
window.MathJax = {
  tex: {
      tags: 'ams' // 13
  },
  options: {
      ignoreHtmlClass: 'verbatim|code' // 14
  }
};

13 turns on equation numbering.

It may sound a bit surprising to let MathJax perform the numbering when we already have this numbering information on hand from the f:mathFragmentNumberedParser. The thing that MathJax solves for us is the automatic placement of the numbers themselves on the pages, so that we don't have to do that part. The parsers we use already conform to the behavior that MathJax expects when numbering equations (namely, that \begin{equation} and \[ start numbered and unnumbered equations), so the way we number equations and the way MathJax numbers equations is the same. In other words, we can let MathJax number equations for us and those automatically generated equation numbers should line up with what we have.

14 prohibits MathJax from acting on certain HTML element classes, namely verbatim and code styles from f:weaveStyledText.

4.3.5 Custom HTML head

If there is any custom HTML that the user wants to inject into the HTML <head>, we should inject it as is.

custom-html-head
-- 15
if
  | Just htmlHead <- weaveConf.projectConf.htmlHead,
    Just injectMe <- htmlHead.injection ->
      toHtmlRaw @Text injectMe
  | otherwise -> pure ()

The comment line at 15 is to suppress Ormolu's suggestion to delete an empty line there (the blank line gets added because of f:padConsecutiveNowebLinks).

4.4 ClojureScript frontend

The ClojureScript frontend generates a single JS file called lilac.js (see Optimized production build), which we import into our HTML page in ClojureScript frontend script.

ClojureScript frontend script
jsPath <- getRootRelativePath "js/lilac.js" -- 16
script_
  [ id_ "lilac-fe-script",
    src_ jsPath
  ]
  ("" :: Text)

16 gets the path to the JS script we compile (from Build configuration file).

4.5 CSS page layout

css:Layout styles the major parts of the object as a single HTML page.

css:Layout
html ? do
  ev margin $ px 0
  ev padding $ px 0
  backgroundColor colorBg
  color colorText
  fontSerif
body ? do
  minHeight (vh 100)
  display grid
  gridTemplateColumns [px 300, px 800]
  "grid-template-rows" -: "auto 1fr"
  "grid-template-areas"
    -: """
       "navbar navbar"
       "tocs   main"
       """
  yx margin (px 0) auto
  overflowY auto
css:navbar
css:tocs
css:page-toc
css:site-toc
css:main-content
css:page-metrics
css:Footer -- 17
p # ".widget-title" -- 18
  ? do
    fontWeight bold
    marginBottom $ px 10
    borderBottom (px 1) solid colorCodeBorder
div ? do
  -- 19
  ev margin $ px 0

The <body> uses a CSS grid layout to create four distinct areas:

  1. Navbar
    🔗 Contains the filesystem path of the object, as well as a link to the homepage of the project data:ProjectConf2.
  2. Table of contents
    🔗 Holds both the site contents and page contents, which give a macro and micro overview of the current document. See Table of contents (TOC).
  3. Main
    🔗 Holds all of the content of the document. The list of cells in the document are rendered one after another in here, top to bottom.
  4. Page metrics
    🔗 Holds various widgets which aid in navigation. The widgets are:
    1. Tangled files
      🔗 List of tangled files in the document.
    2. Named cells
      🔗 List of named cells in the current document.

    The page metrics also holds a footer 17. See f:weaveFooter and css:Footer.

Widgets are small and require a much smaller heading; therefore they use a <p> element instead of something like <h6>. These widget titles are configured at 18.

The <div> element is used in many places to section off areas of content; to avoid repetition, we reset its margins to be 0 19 here for all <div> elements.

The main content's padding and such is defined in css:main-content.

css:main-content
main_ ? do
  "grid-area" -: "main"
  marginLeft (px 17)
  height auto
  overflowY visible
  padding (px 0) (px 20) (px 0) (px 20)
  paddingLeft (px 50)
  "position" -: "relative"
  "top" -: "css:navbar-height"

4.5.1 Mobile tearing fix

On mobile, we need to stop the browser from being able to "bounce" the page when the user gestures (drags) the page with their finger. This is because the <main> element will get dragged around while the sidebar and breadcrumbs at the top will remain fixed.

css:mobile-tearing-fix
html {
  overscroll-behavior: none;
}

By disabling the overscroll behavior completely, the <main> element will stay in position even if the user tries to drag it away.

5 Identifying cells in HTML

Unlike the world of parsing, tangling, and compiling, we cannot use a data:LinkOid to identify cells in HTML. Instead we have to use the familiar hash syntax like #foo to refer to HTML elements (where #foo refers to an element with an id of foo). There are two types of id values we assign to cells to identify them in HTML: Cell link anchors and Numeric cell indexes.

5.2 Numeric cell indexes

Every cell has a local cell index. We can use this information to give every cell a unique ID. This ID is not human friendly, but it's good for assigning every cell a numeric ID so that we can refer to them easily from code (we also assign all of these a class of cell-idx so that we can select them all at once; see f:lciAttrs).

Originally we added f:lciAttrs (with the unique cell index as the id) to be used for f:genHeadingAncestry for detecting when certain cells are visible in the viewport, but then realized that we actually don't need it there. To clarify, we don't need to attach a unique ID to every cell and then add observers to all of them; instead we can just group non-heading cells underneath each heading together into a <div> to check for the intersection of that overall <div> with the viewport.

f:lciAttrs
lciAttrs :: LCI -> [Attributes]
lciAttrs lci = [id_ ("c-" <> T.show lci.unLCI), class_ "cell-idx"]

Although these cell index based id attributes are not used, we still encode it in f:lciAttrs because eventually we want to add keyboard-driven navigation, and for that we'll need to highlight the "current" cell, identified by its unique ID.

Both f:wrapWith and f:wrapWithDiv wrap some other HTML content with f:lciAttrs assigned to either a <span> or <div>, respectively.

f:wrapWith
wrapWith :: LCI -> HtmlWeaver () -> HtmlWeaver ()
wrapWith lci wrapMe = do
  span_ (lciAttrs lci) $ do
    wrapMe

We use f:wrapWith for wrapping inline content. For weaving block content, we use f:wrapWithDiv.

f:wrapWithDiv
wrapWithDiv :: LCI -> HtmlWeaver () -> HtmlWeaver ()
wrapWithDiv lci wrapMe = do
  div_ (lciAttrs lci) $ do
    wrapMe

6 Headings

Headings might sound simple to weave, but because they are made up of arbitrary text, it's fairly involved. There's also the business of creating unique link anchors for each heading.

f:weaveHeading
weaveHeading :: LCI -> Heading -> HtmlWeaver ()
weaveHeading lci heading = do
  when (heading.section /= [1]) $ toHtmlRaw @Text "</div>" -- 23
  let containerId = "heading-container-" <> linkAnchor
  toHtmlRaw
    $ "<div id=\""
    <> containerId
    <> "\" class=\"heading-container\">" -- 24
  hN [id_ linkAnchor] $ do
    wrapWith lci $ do
      a_ [href_ $ "#" <> linkAnchor] $ do
        toHtml $ getSectionNumber heading -- 25
        toHtmlRaw ("&ensp;" :: Text)
        weaveProse heading.body -- 26
  where
    hN = case heading.level of
      1 -> h2_ -- 27
      2 -> h3_
      3 -> h4_
      4 -> h5_
      _ -> h6_
    linkAnchor = getLinkAnchor Nothing $ CHeading heading

6.1 Containers

The heading creates and closes a heading-container-... <div> element, to essentially group all non-heading cells (up to the next heading) together as one entity. This is needed for Active headings TOC indicator. Specifically, grouping up cells like this lets us easily track which parts are being viewed (and we only have to keep track of these <div> elements, instead of tracking each cell individually). 23 closes the <div> elements opened up by 24 from previously woven headings; for the very last heading, we close it from 5.

6.2 HTML elements start at <h2>

A well-formed document should only have one <h1> HTML element (for the title of the document). We use h1_ when weaving the title of the document (if any), at 4. This is why we start from h2_ for headings 27.

6.4 Rendered text

As for rendering the heading for the user to read, we again use the section number 25 and body 26. The section number is the same as when generation the link anchor, by relying on f:getSectionNumber. The body part is different because we can do much better than just grabbing the text alone; instead we can weave each of the data:PToken values in the body, with all of the rich information encoded within those values. The main workhorse for this is f:weaveProse which can weave a newtype:Prose value; this in turn delegates most of the work to f:weavePToken.

f:weaveProse
weaveProse :: Prose -> HtmlWeaver ()
weaveProse prose = sequence_ $ map weavePToken prose.unProse

Each data:PToken value has its own weaver in f:weavePToken.

f:weavePToken
weavePToken :: PToken -> HtmlWeaver ()
weavePToken = \case
  PTokenStyledText styledText -> weaveStyledText styledText
  PTokenLink link -> case link.target of
    LinkTargetFootnote {} -> do
      sup_ [class_ "footnote-link"] $ do
        weaveLink False link
    LinkTargetOid {}
    LinkTargetLineLabel {}
    LinkTargetFilePath {}
    LinkTargetURI {} -> weaveLink False link
  PTokenMathFragment mathFragment -> weaveMathFragmentInline mathFragment
  PTokenCitation n -> weaveCitation n
  PTokenDivStart t -> toHtmlRaw ("<div class=\"csl-" <> t <> "\">" :: Text)
  PTokenDivEnd -> toHtmlRaw ("</div>" :: Text)

6.5 CSS

The main differentiation points between heading elements are their font sizes, and any usage of decorative symbols with f:headingPrefix. Stylistically, the main visual elements of headings are their use of sans-serif font (f:fontSans).

Thanks to Haskell we can define some repetitive things in css:Headings via a forM_ loop 28, instead of copy-pasting the same configuration across all headings.

css:Headings
forM_ (zip [(1 :: Int) ..] [h1, h2, h3, h4, h5, h6]) -- 28
  $ \(n, hN) -> do
    hN ? do
      case n of
        1 -> do
          fontSize (pt 36)
          headingPrefix "●" (px 67) (rem $ -2.8) (px $ -3) -- 29
        2 -> do
          fontSize (pt 26)
          headingPrefix "symbol:h2" (px 44) (rem $ -2.5) (px $ -3) -- 30
        3 -> do
          fontSize (pt 22)
          headingPrefix "symbol:h3" (px 39) (rem $ -2.25) (px $ -1) -- 31
        4 -> do
          fontSize (pt 18)
        5 -> do
          fontSize (pt 16)
        6 -> do
          fontSize (pt 14)
        _ -> pure ()
      position relative
      fontSans
      marginTop (px 0)
      marginBottom (px 0)
      a ? do
        color $ colorFrom "#000000"
        ":visited" & do
          color colorText
      border (px 1) solid colorBg
      yx padding 0 (px 5)
      marginLeft (px (-7))
      maxWidth fitContent
      ".active" & do
        border (px 1) solid colorActiveBorder
        backgroundColor colorActiveBg
        ev borderRadius (px 5)

We use f:headingPrefix as shown in the table below.

Table 1. Heading prefixes.
HTML elementHeading prefixPrefix nameUnicode codepoint
<h1> (Page title)BLACK CIRCLEU+25cf
<h2>BLACK SQUAREU+25a0
<h3>WHITE CIRCLEU+25cb

This prefixed text is not selectable (via text selection) by the user in the browser, so it's purely for decorative visual cues. We use separately named code blocks for the <h2> and <h3> symbols because they're also reused in css:page-toc 103 104. This makes it easier to quickly scan the TOC for the top-level headings.

symbol:h2
symbol:h3
f:headingPrefix
headingPrefix :: Text -> Size sz -> Size sz -> Size sz -> Css
headingPrefix prefix fSize spacing fromTop =
  before & do
    content $ stringContent prefix
    marginLeft spacing
    position absolute
    top fromTop
    left (px 0)
    fontSize fSize
    fontFamily ["Arial"] [sansSerif]
    lineHeight $ unitless 1

7 Styled text

f:weaveStyledText
weaveStyledText :: StyledText -> HtmlWeaver ()
weaveStyledText styledText = do
  weaveConf <- ask
  let b = toHtmlRaw $ T.intercalate "<br>" parts
      parts =
        map
          (\x -> renderAsText weaveConf $ toHtml x)
          $ T.splitOn "\n" styledText.body
  if styles == []
    then b
    else span_ [class_ $ T.unwords styles] b
  where
    styles = map (T.toLower . T.show) $ maskToStyles styledText.style

ti:0300-weave-styled-text checks for basic styles, but also whether HTML-sensitive characters get escaped (even inside verbatim and code styles)

ti:0300-weave-styled-text
🎯 test/Lilac/WeaveSpec/0300-weave-styled-text
regular *bold* /italic/ _underline_ =verbatim= ~code~
=<>=
~<>~
t:0300-weave-styled-text
weaveExpect
  "0300-weave-styled-text"
  [ "<p><span id=\"c-0\" class=\"cell-idx\">regular ",
    "<span class=\"bold\">bold</span> ",
    "<span class=\"italic\">italic</span> ",
    "<span class=\"underline\">underline</span> ",
    "<span class=\"verbatim\">verbatim</span> ",
    "<span class=\"code\">code</span> ",
    "<span class=\"verbatim\">&lt;&gt;</span> ",
    "<span class=\"code\">&lt;&gt;</span>",
    "</span></p>"
  ]

7.1 CSS

The different classes in css:StyledTextStyles line up with the different styles defined in data:Style.

css:StyledTextStyles
".bold" & do
  fontWeight bold
".italic" & do
  fontStyle italic
".underline" & do
  textDecoration underline
".verbatim" & do
  fontMono
  whiteSpace ClayText.pre
".code" & do
  fontMono
  whiteSpace ClayText.pre
  backgroundColor colorCodeBg
  border (px 1) solid colorCodeBorder
  ev borderRadius (px 4)
  yx padding 0 (em 0.2)
".smallcaps" & do
  fontVariant smallCaps
".subscript" & do
  verticalAlign vAlignSub
".superscript" & do
  verticalAlign vAlignSuper

8.1 CSS

By default, browsers underline all links. We only underline them when the mouse hovers over them.

9 List markers

The blaze-html library we use expects to take care of the starting and closing tags for us automatically when we use its HTML tag functions. However, for list items we cannot use these functions (H.ol, H.ul, H.li) because they require our existing data structure to be nested as well (like a tree).

But the cells we have are flat, and so we cannot use those functions. On the other hand, we already know exactly where to start and close the various tags, so it's actually very easy to insert the tags manually. This is what we do in f:weaveListMarker, which is used by f:weaveCell.

f:weaveListMarker
weaveListMarker :: ListMarker -> HtmlWeaver ()
weaveListMarker =
  toHtmlRaw . \case
    LOrderedStart -> "<ol>" :: Text
    LOrderedEnd -> "</ol>"
    LUnorderedStart -> "<ul>"
    LUnorderedEnd -> "</ul>"
    LItemStart -> "<li>"
    LItemEnd -> "</li>"

There isn't much to test here, because these markers are so simple.

ti:0300-weave-lists
🎯 test/Lilac/WeaveSpec/0300-weave-lists
  1) a

       - b

  2) c
t:0300-weave-lists
weaveExpect
  "0300-weave-lists"
  $ map
    (T.intercalate "" . map T.strip . T.lines)
    [ """
      <ol>
        <li>
          <p>
            <span id="c-1" class="cell-idx">
              a
            </span>
          </p>
          <ul>
            <li>
              <p>
                <span id="c-3" class="cell-idx">
                  b
                </span>
              </p>
            </li>
          </ul>
        </li>
        <li>
          <p>
            <span id="c-5" class="cell-idx">
              c
            </span>
          </p>
        </li>
      </ol>
      """
    ]

9.1 CSS

css:List items
css:List item minimum height

9.1.1 Ordered list items

Ordered list items get a right parenthesis, just like what we require when they are written in the Org file (search for "parenthesis" in Detailed rules).

css:counter-suffix-parenthesis
@counter-style parenthesis {
  system: extends decimal;
  suffix: ") ";
}

main ol {
  list-style-type: parenthesis;
}

9.1.2 List item indentation

We want to make indentation match (roughly) what we see in the raw Org syntax. Thankfully, there isn't really anything to do here; the defaults from the browser (at least in Firefox) line up with the indentation style in the raw Org syntax already --- namely, the list item is always indented more than the preceding paragraph.

9.1.3 Empty list items

If a list item is empty (has no content), the browser will collapse them onto a single line. To prevent this, we set a minimum height for empty list items.

css:List item minimum height
main_ ? do
  ol ? do
    li ? do
      empty & do
        minHeight (rem 2.2)

10 Code blocks

Code blocks (also just called blocks) have two major parts: the code (with any Noweb links inside them), which we call the body, along with any metadata (such as the name of the block and the tangle path).

f:weaveBlock
weaveBlock :: LCI -> Block -> HtmlWeaver ()
weaveBlock lci block = do
  wrapWithDiv lci $ do
    div_ [class_ "code-block-container", id_ linkAnchor] $ do
      div_ [class_ "code-block-controls"] $ do
        -- 43
        button_
          [ class_ "toggle-code-block-meta-raw",
            data_ "action" "toggle-code-block-meta-raw"
          ]
          $ pure ()
        a_ [href_ $ "#" <> linkAnchor] $ do
          toHtmlRaw (HTML link symbol :: String)
      weaveImportantMetadata block -- 44
      let (rawBorder, bodyBorder)
            | Nothing <- lookup "name" block.meta,
              Nothing <- lookup "tangle" block.meta =
                case lookup "collapsed" block.meta of
                  Just "true" -> (" top-rounded", " anonymous bottom-rounded")
                  _ -> (" top-rounded", " anonymous fully-rounded") -- 45
            | otherwise = ("", "")
      div_ [class_ $ "code-block-meta-raw hidden" <> rawBorder] $ do
        -- 46
        sequence_ . map weaveBlockMeta $ M.toList block.meta
      weaveInlineBlockControls block -- 47
      weaveBlockBody block bodyBorder
  where
    linkAnchor = getLinkAnchor Nothing $ CBlock block

There are several additional things worth mentioning, all having to do with how we display each block:

We will cover all of these points in the subsections that follow.

10.1 Self-linked anchor symbol

We want users to link to code blocks; that is, code blocks should be linkable just like Headings. We could use the name of the block for this (much like headings where the heading itself is a clickable link to itself), however this wouldn't work for anonymous blocks which have no name. This is why we use the link anchor symbol "🔗" with Unicode code point U+1F517 for code blocks and glossary terms (see f:weaveGlossaryTerm). We display this link symbol whenever the mouse hovers over the block.

10.2 Metadata

To recap, metadata are key/value pairs that describe some characteristics about the block, such as the type of block, programming language, and so on. These are all metadata defined by the user (in manual fashion in the Org mode file). We also have some automatically generated metadata (ancestry).

10.2.1 Name and tangle path

Because the name and tangle path are of such importance, they are always displayed (if they exist for the block) via f:weaveImportantMetadata 44.

f:weaveImportantMetadata
weaveImportantMetadata :: Block -> HtmlWeaver ()
weaveImportantMetadata block
  | Nothing <- lookup "name" block.meta,
    Nothing <- lookup "tangle" block.meta =
      pure ()
  | otherwise = do
      div_ [class_ "code-block-meta"] $ do
        if
          | Just name <- lookup "name" block.meta,
            Just path <- lookup "tangle" block.meta -> do
              code_ $ toHtml name
              br_ []
              code_ $ do
                span_ [class_ "bold"] $ do
                  toHtmlRaw @Text "&#x1F3AF; " -- 48
                  toHtml path
          | Just name <- lookup "name" block.meta -> do
              code_ $ toHtml name
          | Just path <- lookup "tangle" block.meta -> do
              code_ $ do
                span_ [class_ "bold"] $ do
                  toHtmlRaw @Text "&#x1F3AF; "
                  toHtml path
        weaveBlockAncestry block

f:weaveImportantMetadata weaves nothing if both the name and tangle paths are missing. 48 encodes a "direct hit" or "bullseye" emoji (🎯) and is what we use to prefix the tangle path, if any.

10.2.2 Hidden (raw) metadata

The user may also be interested in seeing all of the metadata, but because this is uncommon, the full metadata 46 is hidden behind a button press 43. See Toggle code block metadata to see how we make the button interactive.

f:weaveBlockMeta just weaves a single key and value pair of the metadata; it is called serially for all such pairs at 46.

f:weaveBlockMeta
weaveBlockMeta :: (Text, Text) -> HtmlWeaver ()
weaveBlockMeta (k, v) = do
  div_ [class_ "block-meta-key-value"] $ do
    span_ [class_ "block-meta-key"] $ do
      toHtml k
    span_ [class_ "block-meta-separator"] $ do
      toHtmlRaw @Text " = "
    span_ [class_ "block-meta-value"] $ do
      toHtml v

10.2.3 Ancestry

The block ancestry answers the question "what other blocks refer to this block?". This is handled by f:weaveBlockAncestry which looks at a block and sees if it's a dependency of any other block, by using the ancestry field in data:Archive. If other blocks do depend on this block, we link back to those blocks, which we call "parents", with f:weaveParentBlock. If the parent block itself does not have any parents, we call it a "root" block.

f:weaveBlockAncestry
weaveBlockAncestry :: Block -> HtmlWeaver ()
weaveBlockAncestry block = case lookup "name" block.meta of
  Nothing -> pure () -- 49
  Just name -> do
    weaveConf <- ask
    let archive = weaveConf.archive
        selfLinkOid = LinkOid block.parentOid name
        parentGCIs = do
          selfGCI <- lookup selfLinkOid archive.symbols
          lookup selfGCI archive.ancestry
        parents = mapMaybe f $ fromMaybe [] parentGCIs
        f :: GCI -> Maybe (GCI, [GCI], GPath, Block, Prose)
        f parentGCI = do
          grandParentGCIs <-
            pure . fromMaybe [] $ lookup parentGCI archive.ancestry
          (_, cell) <- gciToObjCell archive parentGCI
          parentBlock <-
            case cell of
              CBlock b -> Just b
              _ -> Nothing
          (offset, path) <- IntMap.lookupLE parentGCI.unGCI archive.offsets
          prose <- gciToName archive offset parentGCI
          pure (parentGCI, grandParentGCIs, path, parentBlock, prose)
        (roots, nonRoots) =
          partition (\(_, ancestors, _, _, _) -> null ancestors) parents
        getName :: (GCI, [GCI], GPath, Block, Prose) -> Text
        getName (_, _, _, b, _) =
          T.toLower . fromMaybe "" $ lookup "name" b.meta
        roots' = sortOn getName roots
        nonRoots' = sortOn getName nonRoots
    forM_ roots' weaveParentBlock
    forM_ nonRoots' weaveParentBlock

If a block is anonymous (does not have a name) (at 49), then it cannot have any parents because no block would be able to name it (via a Noweb reference) as a child.

f:weaveParentBlock makes two distinctions: whether a block belongs to the current document being woven, and whether that block itself has any parents of its own.

f:weaveParentBlock
weaveParentBlock ::
  (GCI, [GCI], GPath, Block, Prose) ->
  HtmlWeaver ()
weaveParentBlock (gci, ancestors, objPath, _, _) = do
  weaveConf <- ask
  case getLinkToGCI weaveConf gci of
    Nothing -> pure ()
    Just (_externalOrgDocName, linkAnchor, _) ->
      if objPath == weaveConf.originObjectPath
        then do
          a_ [href_ $ "#" <> linkAnchor, class_ "parent-block-arrow"] $ do
            if null ancestors
              then toHtml @Text " ⇑"
              else toHtml @Text " ↑"
        else do
          a_ [href_ linkAnchor, class_ "parent-block-arrow"] $ do
            if null ancestors
              then toHtml @Text " ⇪"
              else toHtml @Text " ⇧"

10.3 Body toggle (expand/collapse) button

This button is only rendered if the user sets the collapsed metadata value to true, like this:

#+header: :collapsed true
#+begin_src haskell
...
#+end_src

This makes the code block body area hidden by default, by making f:weaveBlockBody start out in "collapsed" form 53. f:weaveInlineBlockControls weaves the toggle-code-block-body button to allow the showing (and hiding) of the code block body.

f:weaveInlineBlockControls
weaveInlineBlockControls :: Block -> HtmlWeaver ()
weaveInlineBlockControls block
  | Just "true" <- lookup "collapsed" block.meta = do
      let border
            | Nothing <- lookup "name" block.meta,
              Nothing <- lookup "tangle" block.meta =
                " fully-rounded"
            | otherwise = " bottom-rounded"
      div_ [class_ $ "code-block-controls-inline" <> border] $ do
        -- 50
        button_
          [ class_ "toggle-code-block-body",
            data_ "action" "toggle-code-block-body"
          ]
          $ pure ()
  | otherwise = pure ()

There are a few toggled CSS classes. The classes have different effects, as described below:

css:Block controls inline
".code-block-controls-inline" & do
  yx padding (px 4) (px 8)
  border (px 1) solid colorCodeBorder
  fontSans
  button # ".toggle-code-block-body" ? do
    "::before" & do
      "content" -: "\"Expand contents\"" -- 51
    "border" -: "0"
    "cursor" -: "pointer"
    ev borderRadius (px 2)
    border (px 1) solid colorCodeBorder
  button # ".toggle-code-block-body.action-collapse" ? do
    "::before" & do
      "content" -: "\"Collapse contents\"" -- 52
".active" ** ".code-block-controls-inline" ? do
  border (px 1) solid colorActiveBorder

10.4 Body

Finally we come to weaving the body of the code block. The body requires some additional work for supporting syntax highlighting (see f:colorizeText).

f:weaveBlockBody
weaveBlockBody :: Block -> Text -> HtmlWeaver ()
weaveBlockBody block bodyBorder = do
  let maybeCollapsed
        | Just "true" <-
            lookup "collapsed" block.meta =
            " collapsed collapsible" -- 53
        | otherwise = ""
  pre_ [class_ $ "code-block-body" <> bodyBorder <> maybeCollapsed] $ do
    let bodyText = getBlockContentsWithoutLinks block.body
    case lookup "__src_language" block.meta of -- 54
      Just lang -> do
        weaveConf <- ask
        let colorizedBody = renderAsText weaveConf $ colorizeText lang bodyText
            getLink = \case
              BTokenLinkTarget lt ->
                let styledName = case lt of
                      LinkTargetOid (LinkOid _oid n) ->
                        [ StyledText
                            { body = n,
                              style = styleMaskFrom []
                            }
                        ]
                      _ -> []
                 in Just
                      $ Link
                        { target = lt,
                          name = styledName
                        }
              BTokenString {}
              BTokenPadding {} -> Nothing
            links = catMaybes $ map getLink block.body
        toHtmlRaw =<< spliceLinks colorizedBody links
      Nothing -> code_ $ toHtml bodyText

Unfortunately the syntax highlighting is not as simple as just feeding the body text to a highlighter and displaying the results without modification, because we have links embedded inside the body, namely the Noweb links (LinkTargetOid) and line labels (LinkTargetLineLabel). And we want to convert these links into HTML links, and so we cannot weave in the links before colorization; we cannot put them in naively after colorization because then we'd have to re-parse the text we get back and try to see where the links are.

So instead we do some preprocessing of the body text before we feed it to the colorization function. First we put the links aside, and replace each place we found the link with the NULL byte in f:getBlockContentsWithoutLinks. Because the NULL bytes are invisible to the syntax highlighting process, this works well enough. And then when we get back the colorized string from f:colorizeText, we go to each NULL byte and splice in the corresponding link (weaving it as needed). See f:spliceLinks for this last bit.

That's the high-level overview; now let's look at the details.

The first thing we do is to get the contents of the block with f:getBlockContentsWithoutLinks. This function is similar to f:getBlockContents from Lilac.Parse, but does not encode the link information directly into the text. Instead, links are replaced with the \NUL character. This is because the text is fed into f:colorizeText, and we don't want the links to be dealt with in any special way yet. The BTokenPadding token is ignored at 55 because BTokenPadding is only meant to be used for tangling.

f:colorizeText
colorizeText :: Text -> Text -> HtmlWeaver ()
colorizeText lang txt
  | Just syntax <- Sky.lookupSyntax targetLang Sky.defaultSyntaxMap,
    Just lines <-
      foldMap' Just
        $ Sky.tokenize defaultTokeniserConfig syntax txt =
      toHtmlRaw
        . Text.Blaze.Html.Renderer.Text.renderHtml
        $ Sky.formatHtmlInline Sky.defaultFormatOpts lines
  | otherwise = code_ $ toHtmlRaw txt
  where
    targetLang
      | lang `elem` ["emacs-lisp", "elisp"] = "lisp" -- 56
      | lang `elem` ["gitconfig"] = "ini" -- 57
      | otherwise = lang
    defaultTokeniserConfig = Sky.TokenizerConfig Sky.defaultSyntaxMap False

56 is there because Kate (as of 2026) does not recognize Emacs Lisp (a pity). So we have to make do with a fallback to Common Lisp, which Skylighting recognizes as "lisp".

57 maps Git configuration to the INI file format syntax.

Block colorization relies on the skylighting syntax highlighting library. This library ignores the \NUL character, which is what we want. Once we have the colorized text, we splice in the links with spliceLinks, which relies on f:weaveLink for constructing HTML for each link. The splicing works by splitting on the \NUL character and manually "zipping" up the colorized substrings with the woven link HTML fragments.

Syntax highlighting is only considered if the block has a source (programming) language specified for it 54.

10.4.1 Syntax highlighting theme

We use a modified variation of the kate theme from Skylighting (see f:colorizeText) which we call f:kateLilac We make some tweaks to make some colors easier on the eyes. In particular, the ExtensionTok by default is too bright, so we darken it a bit here 58.

f:kateLilac
kateLilac :: Sky.Style
kateLilac =
  Sky.kate
    { Sky.tokenStyles =
        insert
          Sky.ExtensionTok -- 58
          ( Sky.defStyle
              { Sky.tokenColor = Just (Sky.RGB 0 0x60 0xa4),
                Sky.tokenBold = True
              }
          )
          Sky.kate.tokenStyles
    }

10.5 CSS

Code blocks use a 2-column layout, with the first column being the .code-block-controls and the second column being all the rest.

css:Block grid layout
".code-block-container" & do
  display grid
  gridTemplateColumns [px 50, fr 1]
  margin (px 16) 0 (px 16) (px (-50))
  position relative
  fontSize (rem 0.9)
  a ? do
    display inlineBlock
    yx margin (px 2) 0
forM_
  [ ".code-block-meta",
    ".code-block-meta-raw",
    ".code-block-controls-inline",
    ".code-block-body"
  ]
  $ \child -> do
    ".code-block-container" |> child ? do
      "grid-column" -: "2"

Initially, the .code-block-controls in the first column are hidden. On hovering over it or the overall container we display it.

css:Block controls
".code-block-controls" & do
  padding (px 2) (px 5) 0 0
  width (px 50)
  textAlign $ alignSide sideRight
  boxSizing borderBox
  top (px 0)
  bottom (px 0)
  position absolute
  "grid-column" -: "1"
  "grid-row" -: "1"
  visibility hidden -- 59
  opacity 0 -- 60
  css:Block controls buttons
(".code-block-container" # hover) |> ".code-block-controls" ? do
  visibility visible
  opacity 1

We need to use both visibility 59 and opacity 60 to hide/unhide the controls. Visibility hides the element without changing the layout, while opacity hides it visually.

css:Block controls buttons
a # hover ? do
  textDecoration none
button # ".toggle-code-block-meta-raw" ? do
  "::before" & do
    "content" -: "\"M\""
  marginRight $ px 5
  "border" -: "0"
  "cursor" -: "pointer"
  ev borderRadius (px 2)
  border (px 1) solid colorCodeBorder

The rest of the block has three parts (these are evident from f:weaveBlock):

  1. .code-block-meta
    🔗 The name or tangle path. These attributes are so common (and important) that we always display them where possible.
  2. .code-block-meta-raw
    🔗 Other metadata such as the programming language syntax of the code block. These attributes are typically not as interesting, and so require the user to press a button (labeled M) in the .code-block-controls container to reveal them.
  3. .code-block-body
    🔗 Main body of the block, which contains the actual code that have been fenced by #+begin_... and #+end_....
css:Block meta
".code-block-meta" & do
  yx padding 0 (px 8)
  border (px 1) solid colorCodeBorder
  borderBottomWidth 0
  borderTopLeftRadius (px 5) (px 5)
  borderTopRightRadius (px 5) (px 5)
  fontSans
  a ? do
    ".parent-block-arrow" & do
      marginLeft (em 0.25)
".active" ** ".code-block-meta" ? do
  fontWeight bold
  border (px 1) solid colorActiveBorder
  borderBottomWidth 0
css:Block meta raw
div # ".code-block-meta-raw" ? do
  yx padding 0 (px 8)
  fontMono
  border (px 1) solid colorCodeBorder
  borderBottomWidth 0
  ".hidden" & do
    display displayNone
".active" ** ".code-block-meta-raw" ? do
  border (px 1) solid colorActiveBorder
  borderBottomWidth 0
css:Block body
pre ? do
  ev margin $ px 0
code ? do
  ev margin $ px 0
  a ? do
    roundedRectangleLink
    span ? do
      color colorNowebLinkFg
".sourceCode" & do
  ev margin $ px 0
".code-block-body" & do
  overflowX visible
  fontMono
  fontSize (rem 0.9)
  backgroundColor colorCodeBg
  color colorCodeFg
  borderBottomLeftRadius (px 5) (px 5)
  borderBottomRightRadius (px 5) (px 5)
  border (px 1) solid colorCodeBorder
  yx padding (px 2) (px 8)
".active" ** ".code-block-body" ? do
  border (px 1) solid colorActiveBorder
  backgroundColor colorActiveBg
-- 61
".collapsed" ? do
  display displayNone
css:Block line labels

Active line labels get a special highlight with a manicule (hand pointing with index finger) symbol.

css:Block line labels
".code-block-body" ** ".sourceCode" ** ".active" ? do
  border (px 1) solid colorActiveBorder
  backgroundColor colorActiveBg
  "::after" & do
    "content" -: "\" 👈\""

The css:Block rounding is to handle cases where we may or may not need to round off the corners of the css:Block body and css:Block meta raw. See 45.

css:Block rounding
".fully-rounded" & do
  ev borderRadius (px 5)
".top-rounded" & do
  borderTopLeftRadius (px 5) (px 5)
  borderTopRightRadius (px 5) (px 5)
-- 62
".bottom-rounded" & do
  borderBottomLeftRadius (px 5) (px 5)
  borderBottomRightRadius (px 5) (px 5)
-- 63
".hide-border-bottom" & do
  borderBottom (px 0) solid colorCodeBorder
".active" ** ".hide-border-bottom" ? do
  borderBottom (px 0) solid colorCodeBorder

11 Figures

Figures need to link to the target image file they are trying to display. We also display any associated metadata.

f:weaveFigure
weaveFigure :: LCI -> Figure -> HtmlWeaver ()
weaveFigure lci figure = do
  wovenLinkTarget <- weaveLinkTarget False figure.link.target
  let captionPrefix =
        "Figure "
          <> lookupDefault "figure-number-error" "__figure_number" figure.meta
  figure_ [id_ linkAnchor] $ do
    figcaption_ [] $ do
      wrapWith lci $ do
        a_
          [ href_ $ "#" <> linkAnchor,
            class_ "self-link",
            class_ "caption-prefix"
          ]
          $ toHtml captionPrefix
        toHtml $ case figure.caption.unProse of
          [] -> "." :: Text
          _ -> ". " :: Text
        weaveProse figure.caption
    img_
      $ [ id_ linkAnchor,
          src_ wovenLinkTarget,
          class_ "figure-image"
        ]
      <> ( catMaybes
             $ map
               (\(attr, metaKey) -> attr <$> lookup metaKey figure.meta)
               [ (width_, "width"),
                 (height_, "height"),
                 (alt_, "caption"),
                 (title_, "name")
               ]
         )
  where
    linkAnchor = getLinkAnchor Nothing $ CFigure figure

11.1 CSS

css:Figures
".figure-image" & do
  display block
  yx margin (px 0) auto
figcaption ? do
  yx margin (px 10) auto
  yx padding 0 (px 5)
  textAlign center
  fontSans
  maxWidth (pct 80)
  display displayTable
  border (px 1) solid colorBg
  ev borderRadius (px 5)
".caption-prefix" & do
  fontWeight bold
css:active-figures

Active figure captions are highlighted, just like for headings. Tables and figures use the same figcaption HTML element for the caption, so we only need to specify the CSS in one place, for both figures and Tables.

css:active-figures
figure # ".active" ? do
  figcaption ? do
    border (px 1) solid colorActiveBorder
    backgroundColor colorActiveBg
    ev borderRadius (px 5)

12 Math fragments

Math fragments come in two types: standalone and inline. Standalone fragments get linked to themselves, much like how we weave headings in f:weaveHeading.

f:weaveMathFragmentStandalone
weaveMathFragmentStandalone :: LCI -> MathFragment -> HtmlWeaver ()
weaveMathFragmentStandalone lci mathFragment = do
  let (begMarker, endMarker)
        | Just _ <- lookup "__equation_number" mathFragment.meta =
            ("\\begin{equation}", "\\end{equation}")
        | otherwise = ("\\[", "\\]")
  p_ [id_ linkAnchor] $ do
    wrapWith lci $ do
      a_ [href_ $ "#" <> linkAnchor, class_ "self-link"] $ do
        begMarker <> toHtml mathFragment.body <> endMarker
  where
    linkAnchor = getLinkAnchor Nothing $ CMathFragment mathFragment

The inline one is much simpler because it doesn't have to worry about being identified (and hence linkable) by an id HTML attribute.

f:weaveMathFragmentInline
weaveMathFragmentInline :: MathFragment -> HtmlWeaver ()
weaveMathFragmentInline mathFragment =
  "\\(" <> toHtml mathFragment.body <> "\\)"

13 Tables

Tables are similar to figures, because they have some content (a table instead of an image), and also have a caption. In this sense they are pretty simple. On the other hand, tables are necessarily verbose because of several reasons:

The starting point is f:weaveTable where we group together the table with its caption inside a <figure> element 64. We also weave the column groups (f:weaveTableColumnGroups) and rows (f:weaveTableRows).

f:weaveTable
weaveTable :: LCI -> Table -> HtmlWeaver ()
weaveTable lci table = do
  -- 64
  figure_ [id_ linkAnchor] $ do
    weaveTableCaption lci table linkAnchor
    -- 65
    div_ [class_ "table-wrapper"] $ do
      table_ [] $ do
        weaveTableColumnGroups table
        weaveTableRows table
  where
    linkAnchor = getLinkAnchor Nothing $ CTable table

The table-wrapper 65 is there to hide a tiny gap when the sticky table header 77 takes effect (when the user scrolls down a long table). Instead of adding a border to the <table> element, we add one to the table-wrapper div instead.

13.1 Caption

f:weaveTableCaption weaves just the caption portion of the table.

f:weaveTableCaption
weaveTableCaption :: LCI -> Table -> Text -> HtmlWeaver ()
weaveTableCaption lci table linkAnchor = do
  figcaption_ [] $ do
    wrapWith lci $ do
      a_
        [ href_ $ "#" <> linkAnchor,
          class_ "self-link",
          class_ "caption-prefix"
        ]
        $ do
          let captionPrefix =
                "Table "
                  <> lookupDefault
                    "table-number-error"
                    "__table_number"
                    table.meta
          toHtml captionPrefix
      toHtml $ case table.caption.unProse of
        [] -> "." :: Text
        _ -> ". " :: Text
      weaveProse table.caption

13.2 Column groups

In the world of HTML tables, column groups let you apply certain properties to entire columns. For us, we use column groups to set vertical line styles, if the user specified a < character to mark the start of a column group. The < character is only read from a row containing the "do not export" marking character /. See tbl:Marking characters for an example of how this is used in practice.

f:weaveTableColumnGroups
weaveTableColumnGroups :: Table -> HtmlWeaver ()
weaveTableColumnGroups table = do
  forM_ (zip [1 :: Int ..] getColgroups) $ \(groupIdx, numCols) -> do
    colgroup_ [] $ do
      -- 66
      forM_ [1 .. numCols] $ \colNum -> do
        let attrs
              | colNum == 1 && groupIdx > 1 =
                  [class_ "table-vertical-thick-line-colgroup"]
              | colNum > 1 =
                  [class_ "table-vertical-thin-line-col"]
              | otherwise = []
        col_ attrs
  where
    getColgroups :: [Int] -- 67
    getColgroups =
      maybe
        [getMaxCol table]
        ( reverse -- 68
            . foldl' getColgroupSizes [0]
            . getTableCellsFromRow table
        )
        getColgroupsRow
    getColgroupsRow =
      foldl' findSlash Nothing $ getFirstColumnCells table -- 69
      where
        findSlash :: Maybe Int -> ((Int, Int), Prose) -> Maybe Int
        findSlash acc ((row, _col), prose)
          | isJust acc = acc
          | proseHasNonExportMarker prose = Just row
          | otherwise = acc
        proseHasNonExportMarker :: Prose -> Bool
        proseHasNonExportMarker = proseHas "/"
    getColgroupSizes :: [Int] -> Prose -> [Int] -- 70
    getColgroupSizes ns prose = case ns of
      [] -> []
      [0] -> [1] -- 71
      acc@(latest : rest)
        | proseHasColgroupStartMarker prose -> 1 : acc
        | otherwise -> latest + 1 : rest
      where
        proseHasColgroupStartMarker :: Prose -> Bool
        proseHasColgroupStartMarker = proseHas "<" -- 72

We start with getColgroups 67, which ultimately returns a list of column group sizes (each integer being how many columns are in that group). By default we assume that we only have 1 column group (which encompasses all columns in the table, via f:getMaxCol). However, if we can detect a special row which has a slash (/) in the first column and which has column group start markers (<) then we use that to generate the list of column group sizes.

f:getMaxCol
getMaxCol :: Table -> Int
getMaxCol table = foldl' max 0 . map snd $ keys table.grid

f:proseHas checks whether a table cell's prose has a particular piece of text.

f:proseHas
proseHas :: Text -> Prose -> Bool
proseHas prefix prose = case prose.unProse of
  [] -> False
  (firstPToken : _) -> case firstPToken of
    PTokenStyledText styledText ->
      (maskToStyles styledText.style == [])
        && T.isPrefixOf prefix styledText.body
    PTokenLink {}
    PTokenMathFragment {}
    PTokenCitation {}
    PTokenDivStart {}
    PTokenDivEnd {} -> False

We get the row with the slash in it via getColgroupsRow 69, which looks at all the cells in the very first column with f:getFirstColumnCells.

f:getFirstColumnCells
getFirstColumnCells :: Table -> [((Int, Int), Prose)]
getFirstColumnCells table =
  M.toList
    $ M.filterWithKey (\(_row, col) _ -> col == 1) table.grid

This row is then fed into the helper function 68 which uses f:getTableCellsFromRow to get the (visible) cells from the row we're interested in (it drops the cell in the first column if it has a making character in it).

f:getTableCellsFromRow
getTableCellsFromRow :: Table -> Int -> [Prose]
getTableCellsFromRow table n =
  hideMarkingCharInFirstColumn
    . M.toList
    $ M.filterWithKey (\(row, _col) _ -> row == n) table.grid
  where
    hideMarkingCharInFirstColumn :: [((Int, Int), Prose)] -> [Prose]
    hideMarkingCharInFirstColumn = \case
      [] -> []
      (((_row, _col), prose) : cells)
        | prose == tableHorizontalLine -> [Prose []]
        | markingCharExistsInFirstColumn -> map snd cells
        | otherwise -> prose : map snd cells
    markingCharExistsInFirstColumn =
      any (not . tableCellLacks markingChars) $ getFirstColumnCells table -- 73

f:tableCellLacks just checks whether the column does not have any of the given marking characters. In 73, we use it to detect special marking characters like the slash / or column group start marker <.

f:tableCellLacks
tableCellLacks :: [Char] -> ((Int, Int), Prose) -> Bool
tableCellLacks chars (_, prose) = case prose.unProse of
  [] -> True
  [PTokenStyledText styledText] ->
    notElem styledText.body $ map T.singleton chars
  _ -> True

Back to f:weaveTableColumnGroups, getColgroupSizes 70 simply looks for the < character to see if we should create a new column group; otherwise it simply increments the column group size. We start out with [0] as the initial term and immediately compute it as [1] 71 so that we can skip the first column (because the < is optional for the very first column group).

We draw both thick and thin vertical lines, depending on the situation. Thin lines separate columns, and thick lines separate column groups. We use the border on the left of a table cell to color in the vertical line 79 80, which means we'll have to color in the first column of a column group to mark it as a thick vertical line. We use 1-based indexing because it feels slightly more intuitive when we have variables like numCols 66 to encode the total number of columns.

13.3 Rows

The first row is the called the table "head" (for <thead>) and the rest are the "body" (for <tbody>).

f:weaveTableRows
weaveTableRows :: Table -> HtmlWeaver ()
weaveTableRows table = do
  let columnNames = getTableCellsFromRow table $ getHeaderRowIndex table
  weaveTableHead columnNames
  weaveTableBody table columnNames

The head row contains the names of each column.

f:weaveTableHead
weaveTableHead :: [Prose] -> HtmlWeaver ()
weaveTableHead columnNames = do
  thead_ [] $ do
    tr_ [] $ do
      forM_ columnNames $ \prose -> do
        th_ [] $ weaveProse prose

The table body is more complicated, because of a few things:

f:weaveTableBody
weaveTableBody :: Table -> [Prose] -> HtmlWeaver ()
weaveTableBody table columnNames = do
  tbody_ [] $ do
    forM_ (getRows visibleRows) $ \row ->
      if row == [Prose []] -- 74
        then do
          tr_ [class_ "table-horizontal-line"] $ do
            forM_ columnNames $ \_ -> do
              td_ [] $ toHtml @Text ""
        else do
          tr_ [class_ "table-body-row"] $ do
            forM_ row $ \prose -> do
              let tdAttrs =
                    if mostlyNumeric prose -- 75
                      then [class_ "table-cell-numeric"]
                      else []
              td_ tdAttrs $ weaveProse prose
  where
    getRows rowNums = map (getTableCellsFromRow table) rowNums
    isVisible :: ((Int, Int), Prose) -> Bool -- 76
    isVisible = tableCellLacks markingCharsInvisible
    visibleRows =
      filter
        (`notElem` invisibleRows)
        [(getHeaderRowIndex table + 1) .. getMaxRow table]
    invisibleRows = foldl' f [] $ getFirstColumnCells table
      where
        f acc tableCell@((row, _col), _)
          | isVisible tableCell = acc
          | otherwise = row : acc
    mostlyNumeric :: Prose -> Bool
    mostlyNumeric prose =
      let rawText = T.concat $ map extractText prose.unProse
          extractText = \case
            PTokenStyledText styledText -> styledText.body
            _ -> ""
       in isNumericText rawText

We get all visible rows by looking at all rows (up to the maximum (bottommost) row with f:getMaxRow), and checking with isVisible 76.

f:getMaxRow
getMaxRow :: Table -> Int
getMaxRow table = foldl' max 0 . map fst $ keys table.grid

f:getHeaderRowIndex gets the index of the header row by searching for the first row which lacks f:markingChars in the first column; it's not simply 1 (using 1-based indexing) because there could be invisible rows just above the header.

f:getHeaderRowIndex
getHeaderRowIndex :: Table -> Int
getHeaderRowIndex table =
  fromMaybe 1 . foldl' f Nothing $ getFirstColumnCells table
  where
    f acc@(Just _) _ = acc
    f acc tableCell@((row, _col), _)
      | tableCellLacks markingChars tableCell = Just row
      | otherwise = acc

Below is a table with some special marking characters on the very first column. This demonstrates some of the complexities around markingCharacters.

Table 2. Marking characters should be absent if you're reading this from HTML.
Header AHeader BHeader CHeader D
abcd
abcd
foo
bar
baz

13.4 CSS

css:Tables
".table-wrapper" & do
  border (px 2) solid colorCodeBorder
  yx margin (px 0) auto
  width fitContent
table ? do
  fontSans
  borderCollapse collapse
  th ? do
    position sticky -- 77
    top (px $ -1) -- 78
    backgroundColor colorCodeBg
    yx padding (px 4) (px 8)
  td ? do
    yx padding (px 4) (px 8)
    ".table-cell-numeric" & do
      textAlign $ alignSide sideRight
  thead ? do
    backgroundColor colorCodeBg
  tbody ? do
    tr ? do
      ".table-horizontal-line" & do
        borderTop (px 3) solid colorCodeBorder
        ev padding (px 0)
        td ? do
          backgroundColor colorCodeBorder
          yx padding (px 0) (px 0)
      ".table-body-row" & do
        borderTop (px 2) solid colorCodeBorder
".table-vertical-thick-line-colgroup" & do
  borderLeft (px 4) solid colorCodeBorder -- 79
".table-vertical-thin-line-col" & do
  -- 80
  borderLeft (px 2) solid colorCodeBorder

78 sets the starting position of the header cell (<th>) to be -1, not 0, because -1 makes it hide a gap when scrolling down (due to the position being sticky). This is hacky, but there doesn't seem to be an alternative way to do this in native CSS.

14 Footnotes

Footnote definitions (data:Footnote) are just paragraphs, with a leading number.

f:weaveFootnote
weaveFootnote :: LCI -> Footnote -> HtmlWeaver ()
weaveFootnote lci footnote = do
  weaveConf <- ask
  if
    | Just originObj <-
        lookup weaveConf.originObjectPath weaveConf.archive.objects,
      Just (footnoteNumber, referrerLCI) <-
        lookup footnote.name originObj.orgDoc.footnoteRefs -> do
        p_
          [ class_ "footnote-definition",
            id_ ("footnote-" <> footnote.name)
          ]
          $ do
            wrapWith lci $ do
              span_
                [class_ "footnote-number"]
                . toHtml
                $ T.show footnoteNumber
                <> ". "
              weaveProse footnote.body
              weaveFootnoteBacklink referrerLCI -- 81
    | otherwise -> pure ()

At 81 we try to add a link back to the place (referrerLCI) where we first made a reference to this footnote definition. The f:weaveFootnoteBacklink function tries to find the cell index in the current document, and if it is found, links to that cell.

14.1 CSS

Footnote links are superscript numerals, which is why we use sup in css:Footnotes below.

css:Footnotes
sup ? do
  ".footnote-link" & do
    a ? do
      borderStyle none
      ev borderRadius (px 0)
      yx padding 0 (px 0)
      yx margin (px 0) 0
      fontStyle normal
      ".internal-link" & do
        backgroundColor $ colorFrom "#ffffff"
        ":hover" & do
          borderStyle none
          textDecoration underline
        ":visited" & do
          ":hover" & do
            borderStyle none
            textDecoration underline
p # ".footnote-definition" ? do
  border (px 1) solid colorBg
  ev borderRadius (px 5)
  marginLeft (px (-7))
  yx padding 0 (px 5)
  ".active" & do
    border (px 1) solid colorActiveBorder
    backgroundColor colorActiveBg
    ev borderRadius (px 5)
  a # ".footnote-backlink" ? do
    display inlineBlock

15 Glossary terms

Description lists, aka glossary terms are similar to footnote definitions in that they are mostly composed of paragraphs. So f:weaveProse again does most of the work.

f:weaveGlossaryTerm
weaveGlossaryTerm :: LCI -> GlossaryTerm -> HtmlWeaver ()
weaveGlossaryTerm lci glossaryTerm = do
  weaveConf <- ask
  let dlStart :: HtmlWeaver ()
      dlStart = toHtmlRaw @Text "<dl>"
      dlEnd :: HtmlWeaver ()
      dlEnd = toHtmlRaw @Text "</dl>"
      isGlossaryTerm lci' -- 82
        | Just originObj <-
            lookup weaveConf.originObjectPath weaveConf.archive.objects,
          Just internalCell <- originObj.orgDoc.cells !!? lci'.unLCI,
          CGlossaryTerm _ <- internalCell =
            True
        | otherwise = False
      isFirstGlossaryTerm = not . isGlossaryTerm . LCI $ lci.unLCI - 1
      isLastGlossaryTerm = not . isGlossaryTerm . LCI $ lci.unLCI + 1
  when (isFirstGlossaryTerm) dlStart -- 83
  wrapWithDiv lci $ do
    div_ [id_ linkAnchor, class_ "glossary-term"] $ do
      let maybeInnerVerticalMargin
            | isFirstGlossaryTerm = []
            | otherwise = [class_ "inner-dt"]
      dt_ maybeInnerVerticalMargin $ do
        weaveProse glossaryTerm.term
      dd_ $ do
        a_ [href_ $ "#" <> linkAnchor, class_ "glossary-term-anchor"] $ do
          toHtmlRaw @Text HTML link symbol
        weaveProse glossaryTerm.definition
  when (isLastGlossaryTerm) dlEnd -- 84
  where
    linkAnchor = getLinkAnchor (Just lci) $ CGlossaryTerm glossaryTerm

We don't wrap everything inside a <dl> tag because it may be the case that many more glossary terms exist after this (current) cell being woven. In that case, we want to close off the <dl> only at the very end. That's what 83 and 84 are for. The isGlossaryTerm helper 82 checks if the given cell index points to a glossary term cell inside the current object being woven.

The dlStart and dlEnd bits don't really mean much if we have glossary terms inside list items, because each list item marker in between each item will break up the continuity of glossary terms. So we'll end up with multiple singleton description lists (each with just one glossary term in it) instead of just one description list tag with multiple terms. This is not the most elegant result, but it is what it is.

For example, look at the HTML for the following:

Contrast this with the following which share the same <dl> tag:

c
🔗 x
d
🔗 y

15.1 Weaving all glossary terms

A user may want to have a combine list of all glossary terms defined in the document, as discussed in Printing glossaries.

f:weaveGlossary
weaveGlossary :: Bool -> HtmlWeaver ()
weaveGlossary isGlobal = do
  weaveConf <- ask
  if
    | Just originObj <-
        lookup weaveConf.originObjectPath weaveConf.archive.objects -> do
        let terms :: [(LCI, GPath)]
            terms
              | isGlobal =
                  map (\((_term, path), lci) -> (lci, path))
                    $ sortBy
                      ( comparing (toNormalizedText . CP.fromText . fst . fst)
                          <> comparing (snd . fst)
                      )
                    $ M.toList weaveConf.archive.glossary
              | otherwise =
                  map (\(_term, lci) -> (lci, weaveConf.originObjectPath))
                    $ sortBy
                      (comparing (toNormalizedText . CP.fromText . fst)) -- 85
                    $ M.toList originObj.orgDoc.glossary
            terms' = zip (True : repeat False) terms
        when (terms' /= []) $ toHtmlRaw @Text "<dl>"
        forM_ terms' weaveCollectedGlossaryTerm
        when (terms' /= []) $ toHtmlRaw @Text "</dl>"
    | otherwise -> pure ()

Sorting of glossary terms means sorting what's visible for the human user. So for links, it means only using the display name, not the target of the link which may have a long (random) UUID. This means using a custom comparing function for the prose 85, to remove all links so that we only use the display text of links. We can do this with f:stripLinks, just like we did for f:getLinkAnchor, in f:toNormalizedText.

f:toNormalizedText
toNormalizedText :: Prose -> Text
toNormalizedText prose =
  T.toLower
    . CP.toText @Prose
    . stripStyles
    $ stripLinks prose

f:toNormalizedText also puts everything in lowercase, because otherwise capital letters take precedence over lowercase ones, which can be confusing. It also calls f:stripStyles because otherwise those (useless) style characters like * and / will interfere with the sorting.

In f:weaveCollectedGlossaryTerm, because we only have the cell index, we first pull out the glossary cell to find the CGlossaryTerm we want to weave. We also insert a link back to the place where the glossary term was originally defined, to give the user additional context.

f:weaveCollectedGlossaryTerm
weaveCollectedGlossaryTerm :: (Bool, (LCI, GPath)) -> HtmlWeaver ()
weaveCollectedGlossaryTerm (isFirstTerm, (lci, objPath)) = do
  weaveConf <- ask
  if
    | True <- objPath == weaveConf.originObjectPath, -- 86
      Just originObj <-
        lookup weaveConf.originObjectPath weaveConf.archive.objects,
      Just internalCell <- originObj.orgDoc.cells !!? lci.unLCI,
      CGlossaryTerm glossaryTerm <- internalCell -> do
        let linkAnchor = getLinkAnchor (Just lci) internalCell
        dt_ maybeInnerVerticalMargin $ do
          weaveProse glossaryTerm.term
        dd_ $ do
          a_ [href_ $ "#" <> linkAnchor] $ do
            toHtml @Text "↑"
          weaveProse glossaryTerm.definition
    | True <- objPath /= weaveConf.originObjectPath, -- 87
      Just extObj <- lookup objPath weaveConf.archive.objects,
      Just externalCell <- extObj.orgDoc.cells !!? lci.unLCI,
      CGlossaryTerm glossaryTerm <- externalCell,
      Just (externalLinkAnchor, externalOrgDocName) <-
        getRootRelativeOrgDocCellLink
          weaveConf
          objPath
          lci
          externalCell -> do
        dt_ maybeInnerVerticalMargin $ do
          weaveProse glossaryTerm.term
        dd_ $ do
          a_ [href_ externalLinkAnchor] $ do
            span_ [class_ "glossary-external-source"] $ do
              weaveProse
                $ Prose
                  [ PTokenStyledText
                      StyledText
                        { body = "[" <> externalOrgDocName <> "]", -- 88
                          style = styleMaskFrom []
                        }
                  ]
          weaveProse glossaryTerm.definition
    | otherwise -> toHtml @Text "error: glossary term processing error"
  where
    maybeInnerVerticalMargin =
      if isFirstTerm
        then []
        else [class_ "inner-dt"]

The branch of code at 86 weaves internal terms (terms that are defined in the current document). As such they have no need to disambiguate multiple identical terms (these are not allowed within a single document).

In contrast, 87 weaves external terms (terms that are defined in another document). These may need disambiguation, so we weave the externalOrgDocName 88. Getting the externalOrgDocName (and the link to it) is handled by f:getRootRelativeOrgDocCellLink.

f:getRootRelativeOrgDocCellLink itself relies on f:getRootRelativeOrgDocLink, because that's what gets us a link to the external Org document.

f:getLinkToGCI is a basic building block for linking out to cells (whether internal or external) based on their global cell index. It returns a tuple of (linkText, externalOrgDocName), where linkText is what we can put in the href (of the form #foo for internal cells and path/to/doc.html#foo for external cells). The externalOrgDocName is blank for internal cells.

f:getLinkToGCI
getLinkToGCI :: WeaveConf -> GCI -> Maybe (Text, Text, Prose)
getLinkToGCI weaveConf gci
  | Just (offset, gpath) <-
      IntMap.lookupLE gci.unGCI weaveConf.archive.offsets,
    Just (_, cell) <- gciToObjCell weaveConf.archive gci,
    Just cellName <- gciToName weaveConf.archive offset gci =
      let lci = LCI $ gci.unGCI - offset
       in if gpath == weaveConf.originObjectPath
            then do
              let linkAnchor = getLinkAnchor (Just lci) cell
              Just ("", linkAnchor, cellName)
            else do
              (linkAnchor, docName) <-
                getRootRelativeOrgDocCellLink weaveConf gpath lci cell
              Just (docName, linkAnchor, cellName)
  | otherwise = Nothing

15.2 CSS

For glossary terms, we want to place the definition (<dd>) on the same line as the term (<dt>). Because this is not the default behavior (by default the two are placed each on their own lines) we place a newline character (the string \a in CSS) after a <dd> at 89.

css:Glossary terms
dl ? do
  marginTop $ rem 1
dt ? do
  display inlineBlock
  fontWeight bold
  fontSans
  ".inner-dt" & do
    marginTop $ rem 1
dd ? do
  display inline
  marginLeft (rem 0.5)
  "::after" & do
    "content" -: "\"\\a\"" -- 89
    whiteSpace ClayText.pre
  a # ".glossary-term-anchor" ? do
    fontSize (em 0.7)
    ":hover" & do
      textDecoration none
div # ".glossary-term" ? do
  border (px 1) solid colorBg
  yx padding (px 0) (px 5)
  ".active" & do
    border (px 1) solid colorActiveBorder
    backgroundColor colorActiveBg
    ev borderRadius (px 5)
span # ".glossary-external-source" ? do
  fontSans

See this StackOverflow post for more discussion around styling description lists this way.

16 Citations

For weaving citations, we use a 4-step process:

  1. Convert the newtype:Prose contents of data:Citation into CslJson Text, while preserving the information of non-text things like links and math fragments by embedding them as CslLink for things that CslJson Text does not support.

  2. Call into Citeproc.citeproc to get the transformed CslJson Text back out.

  3. Convert back the CslJson Text into newtype:Prose.

  4. Weave the newtype:Prose as usual.

We can't simply convert newtype:Prose into HTML first before calling Citeproc.citeproc because then that would result in things like <a href=...> being converted into &lt;a href=...&gt; (entity conversion of the given text).

For the first step, we need a function to convert newtype:Prose into CslJson Text. For that we have f:proseToCslJsonText. Apart from using CslLink to encode links 90, this function uses the CslDiv constructor to encode information that cannot be represented natively in CslJson such as math fragments 91 and certain text styles 92.

f:proseToCslJsonText
proseToCslJsonText :: Prose -> CslJson Text
proseToCslJsonText prose = foldl' f CslEmpty prose.unProse
  where
    f :: CslJson Text -> PToken -> CslJson Text
    f acc pToken = case pToken of
      PTokenStyledText styledText ->
        let styles = maskToStyles styledText.style
            body = parseCslJson mempty styledText.body
         in acc <> applyStyles body styles
      PTokenLink link ->
        -- 90
        let encoded = CslLink ("LILAC_LINK\NUL" <> unparseLink link) CslEmpty
         in acc <> encoded
      PTokenMathFragment mathFragment ->
        let encoded =
              -- 91
              CslDiv
                ("LILAC_MATH_FRAGMENT\NUL" <> unparseMathFragment mathFragment)
                CslEmpty
         in acc <> encoded
      PTokenCitation {}
      PTokenDivStart {}
      PTokenDivEnd -> acc
    applyStyles :: CslJson Text -> [Style] -> CslJson Text
    applyStyles cslBody styles = foldl' applyStyle cslBody styles
    applyStyle :: CslJson Text -> Style -> CslJson Text
    applyStyle acc = \case
      Bold -> CslBold acc
      Italic -> CslItalic acc
      Underline -> CslUnderline acc
      -- 92
      Verbatim -> CslDiv "LILAC_STYLE_VERBATIM\NUL" acc
      Code -> CslDiv "LILAC_STYLE_CODE\NUL" acc
      Smallcaps -> CslSmallCaps acc
      Subscript -> CslSub acc
      Superscript -> CslSup acc

f:proseToMaybeCslJsonText is a wrapper around f:proseToCslJsonText to convert it easily into a Maybe (CslJson Text). This is useful because the Citeproc library uses this type for constructing its citation objects.

f:proseToMaybeCslJsonText
proseToMaybeCslJsonText :: Prose -> Maybe (CslJson Text)
proseToMaybeCslJsonText prose =
  case proseToCslJsonText prose of
    CslEmpty -> Nothing
    cslJson -> Just cslJson

For calling Citeproc.citeproc, we have to convert our internal data:Citation into Citeproc's own citation type.

f:citationToCiteprocCitation
citationToCiteprocCitation :: Citation -> CP.Citation (CslJson Text)
citationToCiteprocCitation citation =
  CP.Citation
    { CP.citationId = Nothing,
      CP.citationNoteNumber = citation.noteNumber,
      CP.citationPrefix = proseToMaybeCslJsonText citation.prefix,
      CP.citationSuffix = proseToMaybeCslJsonText citation.suffix,
      CP.citationItems =
        concatMap (citeToCiteprocCitationItems sv) citation.cites,
      CP.citationResetPosition = False
    }
  where
    (sv, _) = citation.styleVariation -- 93

The individual data:Cite items are converted into Citeproc's CitationItem type. Orgmode has fancy styling capabilities for individual citation items (this is sv1 in f:citeToCiteprocCitationItems below), but we only support the t and na style variants (we don't even look at the second element in the citation.styleVariation tuple 93). This is because we rely on CSL to do nearly all of the styling for us. CSL itself allows for just a handful of variations, namely:

See ti:0400-citeproc-style-variations for examples. We achieve the semantic meaning of t (aka text) and na (aka noauthor) by using combinations of the above. The na maps 1:1 to author-only so that's an easy one. However the t style needs to inject the author name (as regular text) just before the cite. In order to achieve this, we create two citations, using a combination of suppress-author (CP.SuppressAuthor) and author-only (CP.AuthorOnly) to get what we want. Because we end up creating two citations in place of one, the result type of f:citeToCiteprocCitationItems is a list.

f:citeToCiteprocCitationItems
citeToCiteprocCitationItems ::
  Maybe Text ->
  Cite ->
  [CP.CitationItem (CslJson Text)]
citeToCiteprocCitationItems sv cite =
  let citationItem =
        CP.CitationItem
          { CP.citationItemId = CP.ItemId cite.id,
            CP.citationItemLabel = term,
            CP.citationItemLocator = coord,
            CP.citationItemType = CP.NormalCite,
            CP.citationItemPrefix = proseToMaybeCslJsonText cite.prefix,
            CP.citationItemSuffix = proseToMaybeCslJsonText cite.suffix,
            CP.citationItemData = Nothing
          }
   in if
        | Just "t" <- sv ->
            [ citationItem
                { CP.citationItemType = CP.AuthorOnly,
                  CP.citationItemSuffix = Nothing
                },
              citationItem
                { CP.citationItemType = CP.SuppressAuthor,
                  CP.citationItemPrefix = Nothing
                }
            ]
        | Just "na" <- sv ->
            [citationItem {CP.citationItemType = CP.SuppressAuthor}]
        | otherwise -> [citationItem]
  where
    (term, coord) = case cite.locator of
      Just (t, c) -> (Just t, Just c)
      Nothing -> (Nothing, Nothing)

The last piece is the conversion of CslJson Text back into newtype:Prose. We have to recursively traverse the nodes in the CslJson Text tree.

f:cslJsonTextToProse
cslJsonTextToProse :: CslJson Text -> Prose
cslJsonTextToProse cslJsonText = compressProse . Prose $ f [] cslJsonText
  where
    f :: [Style] -> CslJson Text -> [PToken]
    f s = \case
      CslEmpty -> []
      CslText t ->
        [ PTokenStyledText
            StyledText {body = t, style = styleMaskFrom s}
        ]
      CslConcat a CslEmpty -> f s a
      CslConcat (CslConcat a b) c -> f s (CslConcat a (CslConcat b c))
      CslConcat a b -> f s a <> f s b
      CslQuoted a -> f s a
      CslItalic a -> f (Italic : s) a
      CslNormal a -> f [] a
      CslBold a -> f (Bold : s) a
      CslUnderline a -> f (Underline : s) a
      CslNoDecoration a -> f (filter (/= Underline) s) a
      CslSmallCaps a -> f (Smallcaps : s) a
      CslBaseline a -> f (filter (`notElem` [Subscript, Superscript]) s) a
      CslSup a -> f (Superscript : s) a
      CslSub a -> f (Subscript : s) a
      CslNoCase a -> f s a -- 94
      CslDiv t a
        | t == "LILAC_STYLE_CODE\NUL" -> f (Code : s) a
        | t == "LILAC_STYLE_VERBATIM\NUL" -> f (Verbatim : s) a
        | "LILAC_MATH_FRAGMENT\NUL" `T.isPrefixOf` t ->
            (CP.fromText (T.drop 20 t) :: Prose).unProse
        | otherwise -> [PTokenDivStart t] <> f s a <> [PTokenDivEnd]
      CslLink t a
        | "LILAC_LINK\NUL" `T.isPrefixOf` t ->
            (CP.fromText (T.drop 11 t) :: Prose).unProse
        | "#ref-" `T.isPrefixOf` t ->
            let uri =
                  N.URI
                    { N.uriScheme = "",
                      N.uriAuthority = Nothing,
                      N.uriPath = T.unpack t,
                      N.uriQuery = "",
                      N.uriFragment = ""
                    }
                target = LinkTargetURI uri
                name = compressStyledText . getStyledText $ f s a
             in [PTokenLink Link {target = target, name = name}]
        | otherwise -> case (N.parseURI $ T.unpack t) of
            Nothing -> []
            Just uri ->
              let target = LinkTargetURI uri
                  name = compressStyledText . getStyledText $ f s a
               in [PTokenLink Link {target = target, name = name}]
    getStyledText [] = []
    getStyledText (pToken : rest) = case pToken of
      PTokenStyledText styledText -> styledText : getStyledText rest
      PTokenLink {}
      PTokenMathFragment {}
      PTokenCitation {}
      PTokenDivStart {}
      PTokenDivEnd {} -> []

The CslNoCase is fine to ignore 94 because it's most likely used by Citeproc to handle case-changing transformations, and is probably not meant to be used directly by consumers.

The CslDiv is used to wrap bibliographical entries inside printed bibliography for formatting purposes. We ignore them for now, except for those LILAC_STYLE... bits that we've encoded in f:proseToCslJsonText.

Now we have all of the pieces needed to process citations. The idea is to process all citations found inside an Org document at once, just before the weaving begins in f:weaveObject, by calling Citeproc.citeproc which will get us a Result a which includes the formatted citations as well as the formatted bibliography. This part is handled by f:prepareCitations. Then during the weaving process we refer to these formatted citations (of type Prose), before finally converting them to HTML again.

f:prepareCitations
prepareCitations ::
  ProjectConf ->
  GPath ->
  Object ->
  Archive ->
  IO (CP.Result Prose)
prepareCitations projectConf objGPath object archive = do
  bibPathsRelative <- makeRelativeToObjectPath objGPath object.orgDoc.bibPaths
  cslPathRelative <-
    listToMaybe
      <$> ( makeRelativeToObjectPath objGPath
              $ maybeToList object.orgDoc.cslPath
          )
  let finalCslPath
        | Just _ <- cslPathRelative = cslPathRelative
        | Just _ <- projectConf.citationStyle = projectConf.citationStyle
        | otherwise = Nothing
      bibRefsFromOrgDoc =
        filter neededByCitations
          . concat
          . catMaybes
          . map (\bp -> lookup bp archive.bibs)
          $ projectConf.bibliography
          <> bibPathsRelative
      CP.Result citations bibliography warnings =
        f bibRefsFromOrgDoc finalCslPath
      resultWithProse =
        CP.Result
          { CP.resultCitations = map cslJsonTextToProse citations,
            CP.resultBibliography =
              map
                (\(k, cslJsonText) -> (k, cslJsonTextToProse cslJsonText))
                bibliography,
            CP.resultWarnings = warnings
          }
  pure resultWithProse
  where
    citeprocOptions =
      CP.CiteprocOptions
        { linkCitations = True,
          linkBibliography = True
        }
    blankResult :: CP.Result (CslJson Text)
    blankResult =
      CP.Result
        { CP.resultCitations = [],
          CP.resultBibliography = [],
          CP.resultWarnings = ["BLANK CITATION RESULT"]
        }
    f bibRefs finalCslPath
      | Just cslPath' <- finalCslPath,
        Just rawXML <- lookup cslPath' archive.csls,
        Right citeprocStyle <- parseCitationStyle rawXML =
          CP.citeproc
            citeprocOptions
            citeprocStyle
            Nothing
            bibRefs
            (map citationToCiteprocCitation object.orgDoc.citations)
      | otherwise = blankResult
    neededByCitations bibRef =
      let citeIds =
            concatMap
              (\c -> map (\cite -> cite.id) c.cites)
              object.orgDoc.citations
       in bibRef.referenceId.unItemId `elem` citeIds

The neededByCitations helper filters out the bibliographical entries so that we only print the bibliography that are actually used by the citations. See this upstream issue.

Now we can weave data:Citation values into the HTML, with f:weaveCitation.

f:weaveCitation
weaveCitation :: Int -> HtmlWeaver ()
weaveCitation n = do
  weaveConf <- ask
  let result = weaveConf.citeprocResult
      citations = result.resultCitations
  case maybeAt n citations of
    Just prose -> span_ [class_ "citation"] $ weaveProse prose
    Nothing -> pure ()

f:weaveBibliography weaves the #+print_bibliography part of the doc.

f:weaveBibliography
weaveBibliography :: LCI -> HtmlWeaver ()
weaveBibliography lci = do
  weaveConf <- ask
  let result = weaveConf.citeprocResult
      bibliography = result.resultBibliography
  div_ [class_ "bibliography"] $ do
    wrapWithDiv lci $ do
      forM_ bibliography $ \(key, prose) -> do
        let linkAnchor = "ref-" <> key
        div_ [class_ "bib-ref", id_ linkAnchor] $ do
          weaveProse prose

The #ref-... style is based on what Citeproc uses; for now we choose to use it as is. If we wanted to modify it, we would need to tweak how we handle the CslLink constructor in f:cslJsonTextToProse.

16.1 Tests

For tests around citation processing, we need a CSL style file (XML) and some bibliographical entries. For the CSL style files, we can refer to the styles we cloned as a submodule under csl-styles.

Here is a sample bibliography, taken from https://zbib.org and converted from BibTeX format into CSL JSON with Pandoc via pandoc citations.bib -t csljson -o citations.json.

ti:Sample CSL JSON bibliography
🎯 test/Lilac/WeaveSpec/bib.json
[
  {
    "ISBN": "9780937073803 9780937073810",
    "author": [
      {
        "family": "Knuth",
        "given": "Donald Ervin"
      }
    ],
    "collection-number": "no. 27",
    "collection-title": "CSLI lecture notes",
    "id": "a",
    "issued": {
      "date-parts": [
        [
          1992
        ]
      ]
    },
    "keyword": "Computer programming",
    "publisher": "Center for the Study of Language; Information",
    "publisher-place": "Stanford, Calif.",
    "title": "Literate programming",
    "type": "book"
  },
  {
    "DOI": "10.1145/362929.362947",
    "ISSN": "0001-0782, 1557-7317",
    "URL": "https://dl.acm.org/doi/10.1145/362929.362947",
    "accessed": {
      "date-parts": [
        [
          2025,
          8,
          30
        ]
      ]
    },
    "author": [
      {
        "family": "Dijkstra",
        "given": "Edsger W."
      }
    ],
    "container-title": "Communications of the ACM",
    "id": "b",
    "issue": "3",
    "issued": {
      "date-parts": [
        [
          1968,
          3
        ]
      ]
    },
    "page": "147-148",
    "title": "Letters to the editor: Go to statement considered harmful",
    "title-short": "Letters to the editor",
    "type": "article-journal",
    "volume": "11"
  },
  {
    "DOI": "10.1112/plms/s2-42.1.230",
    "ISSN": "00246115",
    "URL": "http://doi.wiley.com/10.1112/plms/s2-42.1.230",
    "accessed": {
      "date-parts": [
        [
          2025,
          8,
          30
        ]
      ]
    },
    "author": [
      {
        "family": "Turing",
        "given": "A. M."
      }
    ],
    "container-title": "Proceedings of the London Mathematical Society",
    "id": "c",
    "issue": "1",
    "issued": {
      "date-parts": [
        [
          1937
        ]
      ]
    },
    "page": "230-265",
    "title": "On Computable Numbers, with an Application to the Entscheidungsproblem",
    "type": "article-journal",
    "volume": "s2-42"
  },
  {
    "DOI": "10.1002/j.1538-7305.1948.tb01338.x",
    "ISSN": "00058580",
    "URL": "https://ieeexplore.ieee.org/document/6773024",
    "accessed": {
      "date-parts": [
        [
          2025,
          8,
          30
        ]
      ]
    },
    "author": [
      {
        "family": "Shannon",
        "given": "C. E."
      }
    ],
    "container-title": "Bell System Technical Journal",
    "id": "d",
    "issue": "3",
    "issued": {
      "date-parts": [
        [
          1948,
          7
        ]
      ]
    },
    "page": "379-423",
    "title": "A Mathematical Theory of Communication",
    "type": "article-journal",
    "volume": "27"
  }
]

Check what the result of calling Citeproc.citeproc looks like, after converting back to newtype:Prose using IEEE style.

ti:0400-citeproc-basic
🎯 test/Lilac/WeaveSpec/0400-citeproc-basic
#+bibliography: bib.json
#+cite_export: csl ../../../csl-styles/ieee.csl

[cite:@a;@b;@c;@d]
t:0400-citeproc-basic
expectCiteprocResult
  "0400-citeproc-basic"
  $ Right
    CP.Result
      { resultCitations =
          Expected citations for 0400-citeproc-basic,
        resultBibliography =
          Expected bibliography for 0400-citeproc-basic,
        resultWarnings = []
      }
Expected citations for 0400-citeproc-basic
[ Prose
    { unProse =
        [ PTokenLink
            Link
              { name = [stext "[1]" []],
                target =
                  LinkTargetURI
                    N.URI
                      { N.uriScheme = "",
                        N.uriAuthority = Nothing,
                        N.uriPath = "#ref-a",
                        N.uriQuery = "",
                        N.uriFragment = ""
                      }
              },
          ptext ", " [],
          PTokenLink
            Link
              { name = [stext "[2]" []],
                target =
                  LinkTargetURI
                    N.URI
                      { N.uriScheme = "",
                        N.uriAuthority = Nothing,
                        N.uriPath = "#ref-b",
                        N.uriQuery = "",
                        N.uriFragment = ""
                      }
              },
          ptext ", " [],
          PTokenLink
            Link
              { name = [stext "[3]" []],
                target =
                  LinkTargetURI
                    N.URI
                      { N.uriScheme = "",
                        N.uriAuthority = Nothing,
                        N.uriPath = "#ref-c",
                        N.uriQuery = "",
                        N.uriFragment = ""
                      }
              },
          ptext ", " [],
          PTokenLink
            Link
              { name = [stext "[4]" []],
                target =
                  LinkTargetURI
                    N.URI
                      { N.uriScheme = "",
                        N.uriAuthority = Nothing,
                        N.uriPath = "#ref-d",
                        N.uriQuery = "",
                        N.uriFragment = ""
                      }
              }
        ]
    }
]
Expected bibliography for 0400-citeproc-basic
[ ( "a",
    Prose
      { unProse =
          [ PTokenDivStart "left-margin",
            ptext "[1]" [],
            PTokenDivEnd,
            PTokenDivStart "right-inline",
            ptext "D. E. Knuth, " [],
            ptext "Literate programming" [Italic],
            ptext ". in CSLI lecture notes, no. no. 27. Stanford, Calif.: Center for the Study of Language; Information, 1992." [],
            PTokenDivEnd
          ]
      }
  ),
  ( "b",
    Prose
      { unProse =
          [ PTokenDivStart "left-margin",
            ptext "[2]" [],
            PTokenDivEnd,
            PTokenDivStart "right-inline",
            ptext "E. W. Dijkstra, \8220Letters to the editor: Go to statement considered harmful,\8221 " [],
            ptext "Communications of the ACM" [Italic],
            ptext ", vol. 11, no. 3, pp. 147\8211\&148, Mar. 1968, doi: " [],
            PTokenLink
              Link
                { name = [stext "10.1145/362929.362947" []],
                  target =
                    LinkTargetURI
                      N.URI
                        { N.uriScheme = "https:",
                          N.uriAuthority =
                            Just
                              N.URIAuth
                                { N.uriUserInfo = "",
                                  N.uriRegName = "doi.org",
                                  N.uriPort = ""
                                },
                          N.uriPath = "/10.1145/362929.362947",
                          N.uriQuery = "",
                          N.uriFragment = ""
                        }
                },
            ptext "." [],
            PTokenDivEnd
          ]
      }
  ),
  ( "c",
    Prose
      { unProse =
          [ PTokenDivStart "left-margin",
            ptext "[3]" [],
            PTokenDivEnd,
            PTokenDivStart "right-inline",
            ptext "A. M. Turing, \8220On Computable Numbers, with an Application to the Entscheidungsproblem,\8221 " [],
            ptext "Proceedings of the London Mathematical Society" [Italic],
            ptext ", vol. s2\8211\&42, no. 1, pp. 230\8211\&265, 1937, doi: " [],
            PTokenLink
              Link
                { name = [stext "10.1112/plms/s2-42.1.230" []],
                  target =
                    LinkTargetURI
                      N.URI
                        { N.uriScheme = "https:",
                          N.uriAuthority =
                            Just
                              N.URIAuth
                                { N.uriUserInfo = "",
                                  N.uriRegName = "doi.org",
                                  N.uriPort = ""
                                },
                          N.uriPath = "/10.1112/plms/s2-42.1.230",
                          N.uriQuery = "",
                          N.uriFragment = ""
                        }
                },
            ptext "." [],
            PTokenDivEnd
          ]
      }
  ),
  ( "d",
    Prose
      { unProse =
          [ PTokenDivStart "left-margin",
            ptext "[4]" [],
            PTokenDivEnd,
            PTokenDivStart "right-inline",
            ptext "C. E. Shannon, \8220A Mathematical Theory of Communication,\8221 " [],
            ptext "Bell System Technical Journal" [Italic],
            ptext ", vol. 27, no. 3, pp. 379\8211\&423, Jul. 1948, doi: " [],
            PTokenLink
              Link
                { name =
                    [ StyledText {body = "10.1002/j.1538-7305.1948.tb01338.x", style = styleMaskFrom []}
                    ],
                  target =
                    LinkTargetURI
                      N.URI
                        { N.uriScheme = "https:",
                          N.uriAuthority =
                            Just
                              N.URIAuth
                                { N.uriUserInfo = "",
                                  N.uriRegName = "doi.org",
                                  N.uriPort = ""
                                },
                          N.uriPath = "/10.1002/j.1538-7305.1948.tb01338.x",
                          N.uriQuery = "",
                          N.uriFragment = ""
                        }
                },
            ptext "." [],
            PTokenDivEnd
          ]
      }
  )
]

Check usage of superscripts.

ti:0400-citeproc-superscript
🎯 test/Lilac/WeaveSpec/0400-citeproc-superscript
#+bibliography: bib.json
#+cite_export: csl ../../../csl-styles/nlm-citation-sequence-superscript.csl

[cite:@a]
t:0400-citeproc-superscript
expectCiteprocResult
  "0400-citeproc-superscript"
  $ Right
    CP.Result
      { resultCitations =
          Expected citations for 0400-citeproc-superscript,
        resultBibliography =
          Expected bibliography for 0400-citeproc-superscript,
        resultWarnings = []
      }
Expected citations for 0400-citeproc-superscript
[ Prose
    { unProse =
        [ PTokenLink
            Link
              { name = [stext "1" [Superscript]],
                target =
                  LinkTargetURI
                    N.URI
                      { N.uriScheme = "",
                        N.uriAuthority = Nothing,
                        N.uriPath = "#ref-a",
                        N.uriQuery = "",
                        N.uriFragment = ""
                      }
              }
        ]
    }
]
Expected bibliography for 0400-citeproc-superscript
[ ( "a",
    Prose
      { unProse =
          [ PTokenDivStart "left-margin",
            ptext "1." [],
            PTokenDivEnd,
            PTokenDivStart "right-inline",
            ptext "Knuth DE. Literate programming. Stanford, Calif.: Center for the Study of Language; Information; 1992. (CSLI lecture notes; no. 27)." [],
            PTokenDivEnd
          ]
      }
  )
]

Check usage of small caps.

ti:0400-citeproc-smallcaps
🎯 test/Lilac/WeaveSpec/0400-citeproc-smallcaps
#+bibliography: bib.json
#+cite_export: csl ../../../csl-styles/acm-siggraph.csl

[cite:@a]
t:0400-citeproc-smallcaps
expectCiteprocResult
  "0400-citeproc-smallcaps"
  $ Right
    CP.Result
      { resultCitations =
          Expected citations for 0400-citeproc-smallcaps,
        resultBibliography =
          Expected bibliography for 0400-citeproc-smallcaps,
        resultWarnings = []
      }
Expected citations for 0400-citeproc-smallcaps
[ Prose
    { unProse =
        [ ptext "[" [],
          PTokenLink
            Link
              { name = [stext "Knuth 1992" []],
                target =
                  LinkTargetURI
                    N.URI
                      { N.uriScheme = "",
                        N.uriAuthority = Nothing,
                        N.uriPath = "#ref-a",
                        N.uriQuery = "",
                        N.uriFragment = ""
                      }
              },
          ptext "]" []
        ]
    }
]
Expected bibliography for 0400-citeproc-smallcaps
[ ( "a",
    Prose
      { unProse =
          [ ptext "Knuth, D.E." [Smallcaps],
            ptext " 1992. " [],
            ptext "Literate programming" [Italic],
            ptext ". Center for the Study of Language; Information, Stanford, Calif." []
          ]
      }
  )
]

We should be able to handle citations written across multiple lines. Because this is only a formatting change, we expect to get the same citations and bibliography as we did in ti:0400-citeproc-smallcaps.

ti:0400-citeproc-multiline
🎯 test/Lilac/WeaveSpec/0400-citeproc-multiline
#+bibliography: bib.json
#+cite_export: csl ../../../csl-styles/acm-siggraph.csl

[cite:
@a]
t:0400-citeproc-multiline
expectCiteprocResult
  "0400-citeproc-multiline"
  $ Right
    CP.Result
      { resultCitations =
          Expected citations for 0400-citeproc-smallcaps,
        resultBibliography =
          Expected bibliography for 0400-citeproc-smallcaps,
        resultWarnings = []
      }

Check a slightly more complex example, again for a citation spread over multiple lines.

ti:0400-citeproc-multiline-complex
🎯 test/Lilac/WeaveSpec/0400-citeproc-multiline-complex
#+bibliography: bib.json
#+cite_export: csl ../../../csl-styles/acm-siggraph.csl

[cite: *bold* /italic/
@a and really
~amazing~
; global suffix]
t:0400-citeproc-multiline-complex
expectCiteprocResult
  "0400-citeproc-multiline-complex"
  $ Right
    CP.Result
      { resultCitations =
          Expected citations for 0400-citeproc-multiline-complex,
        resultBibliography =
          Expected bibliography for 0400-citeproc-smallcaps,
        resultWarnings = []
      }
Expected citations for 0400-citeproc-multiline-complex
[ Prose
    { unProse =
        [ ptext "[" [],
          ptext "bold" [Bold],
          ptext " " [],
          ptext "italic" [Italic],
          PTokenLink
            Link
              { name = [stext "Knuth 1992" []],
                target =
                  LinkTargetURI
                    N.URI
                      { N.uriScheme = "",
                        N.uriAuthority = Nothing,
                        N.uriPath = "#ref-a",
                        N.uriQuery = "",
                        N.uriFragment = ""
                      }
              },
          ptext "and really " [],
          ptext "amazing" [Code],
          ptext "global suffix]" []
        ]
    }
]

Citations can be in a multiline list item. They can also have links and inline math in them, thanks to f:proseToCslJsonText which preserves these prose elements in the conversion to CslJson Text.

ti:0400-citeproc-multiline-complex-list-item
🎯 test/Lilac/WeaveSpec/0400-citeproc-multiline-complex-list-item
#+bibliography: bib.json
#+cite_export: csl ../../../csl-styles/acm-siggraph.csl

  - List item [cite: global \(1 + 1
    = 2\) ;*bold* /italic/ [fn:@a]
    @a and really
    ~amazing~;
    some bit of
    =@b verbatim=
    for a @c thing
    ; global suffix with OID link
    [[id:00000000-0000-0000-0000-000000000000::foo]]]

[fn:@a] Definition of @a.
t:0400-citeproc-multiline-complex-list-item
expectCiteprocResult
  "0400-citeproc-multiline-complex-list-item"
  $ Right
    CP.Result
      { resultCitations =
          Expected citations for 0400-citeproc-multiline-complex-list-item,
        resultBibliography =
          Expected bibliography for 0400-citeproc-multiline-complex-list-item,
        resultWarnings = []
      }
Expected citations for 0400-citeproc-multiline-complex-list-item
[ Prose
    { unProse =
        [ ptext "[global " [],
          PTokenMathFragment
            MathFragment
              { parentOid = nilOid,
                meta = fromList [],
                body = "1 + 1\n    = 2"
              },
          ptext "bold" [Bold],
          ptext " " [],
          ptext "italic" [Italic],
          PTokenLink
            Link
              { name = [stext "1" []],
                target = LinkTargetFootnote "@a"
              },
          PTokenLink
            Link
              { name = [stext "Knuth 1992" []],
                target =
                  LinkTargetURI
                    N.URI
                      { N.uriScheme = "",
                        N.uriAuthority = Nothing,
                        N.uriPath = "#ref-a",
                        N.uriQuery = "",
                        N.uriFragment = ""
                      }
              },
          ptext "and really " [],
          ptext "amazing" [Code],
          ptext "; some bit of " [],
          ptext "@b verbatim" [Verbatim],
          ptext " for a" [],
          PTokenLink
            Link
              { name = [stext "Turing 1937" []],
                target =
                  LinkTargetURI
                    N.URI
                      { N.uriScheme = "",
                        N.uriAuthority = Nothing,
                        N.uriPath = "#ref-c",
                        N.uriQuery = "",
                        N.uriFragment = ""
                      }
              },
          ptext "thing global suffix with OID link " [],
          PTokenLink
            Link
              { name = [stext "foo" []],
                target =
                  LinkTargetOid
                    LinkOid
                      { oid = nilOid,
                        name = "foo"
                      }
              },
          ptext "]" []
        ]
    }
]
Expected bibliography for 0400-citeproc-multiline-complex-list-item
[ ( "a",
    Prose
      { unProse =
          [ ptext "Knuth, D.E." [Smallcaps],
            ptext " 1992. " [],
            ptext "Literate programming" [Italic],
            ptext ". Center for the Study of Language; Information, Stanford, Calif." []
          ]
      }
  ),
  ( "c",
    Prose
      { unProse =
          [ ptext "Turing, A.M." [Smallcaps],
            ptext " 1937. " [],
            PTokenLink
              Link
                { name = [stext "On Computable Numbers, with an Application to the Entscheidungsproblem" []],
                  target =
                    LinkTargetURI
                      N.URI
                        { N.uriScheme = "https:",
                          N.uriAuthority =
                            Just
                              N.URIAuth
                                { N.uriUserInfo = "",
                                  N.uriRegName = "doi.org",
                                  N.uriPort = ""
                                },
                          N.uriPath = "/10.1112/plms/s2-42.1.230",
                          N.uriQuery = "",
                          N.uriFragment = ""
                        }
                },
            ptext ". " [],
            ptext "Proceedings of the London Mathematical Society" [Italic],
            ptext " " [],
            ptext "s2-42" [Italic],
            ptext ", 1, 230\8211\&265." []
          ]
      }
  )
]

We support the /t (text) and /na (no author) citation style variations. The /t makes it so that you can use the citation at the beginning of the sentence because the author name will be pulled out of the citation delimiters. The /na variation simply suppresses the author name from inside the citation.

ti:0400-citeproc-style-variations
🎯 test/Lilac/WeaveSpec/0400-citeproc-style-variations
#+bibliography: bib.json
#+cite_export: csl ../../../csl-styles/acm-siggraph.csl

  - [cite/t:@a] discusses a great programming idea. On an unrelated note, Turing
    was a pioneer in computer science. He wrote about "computable numbers"
    before there were computers [cite/na:@c].
t:0400-citeproc-style-variations
expectCiteprocResult
  "0400-citeproc-style-variations"
  $ Right
    CP.Result
      { resultCitations =
          Expected citations for 0400-citeproc-style-variations,
        resultBibliography =
          Expected bibliography for 0400-citeproc-multiline-complex-list-item,
        resultWarnings = []
      }
Expected citations for 0400-citeproc-style-variations
[ Prose
    { unProse =
        [ ptext "Knuth [" [],
          PTokenLink
            Link
              { name = [stext "1992" []],
                target =
                  LinkTargetURI
                    N.URI
                      { N.uriScheme = "",
                        N.uriAuthority = Nothing,
                        N.uriPath = "#ref-a",
                        N.uriQuery = "",
                        N.uriFragment = ""
                      }
              },
          ptext "]" []
        ]
    },
  Prose
    { unProse =
        [ ptext "[" [],
          PTokenLink
            Link
              { name = [stext "1937" []],
                target =
                  LinkTargetURI
                    N.URI
                      { N.uriScheme = "",
                        N.uriAuthority = Nothing,
                        N.uriPath = "#ref-c",
                        N.uriQuery = "",
                        N.uriFragment = ""
                      }
              },
          ptext "]" []
        ]
    }
]

A citation may not be written across a cell boundary (double newline).

ti:0400-citeproc-multiline-cell-boundary
🎯 test/Lilac/WeaveSpec/0400-citeproc-multiline-cell-boundary
#+bibliography: bib.json
#+cite_export: csl ../../../csl-styles/acm-siggraph.csl

[cite: *bold* /italic/
@a and really
~amazing~

; global suffix]
t:0400-citeproc-multiline-cell-boundary
expectCiteprocResult
  "0400-citeproc-multiline-cell-boundary"
  $ Left "unexpected newline"

Same thing applies for list items as well.

ti:0400-citeproc-multiline-cell-boundary-list-item
🎯 test/Lilac/WeaveSpec/0400-citeproc-multiline-cell-boundary-list-item
#+bibliography: bib.json
#+cite_export: csl ../../../csl-styles/acm-siggraph.csl

  1) [cite: *bold* /italic/
     @a and really
     ~amazing~

     ; global suffix]
t:0400-citeproc-multiline-cell-boundary-list-item
expectCiteprocResult
  "0400-citeproc-multiline-cell-boundary-list-item"
  $ Left "unexpected newline"

16.2 CSS

These CSL classes are used by the Citeproc library. Specifically these are used for the bibliographical entries when using #+print_bibliography:.

css:Citations
".bibliography" & do
  marginTop (rem 1)
".bib-ref" & do
  marginTop (rem 0.5)
".csl-left-margin" & do
  float floatLeft
".csl-right-inline" & do
  margin 0 0 0 (rem 2)

17 Table of contents (TOC)

The Table of Contents or TOC is automatically generated for both the headings of the current document (which we call page contents for the user) and also the relationship of this document with others in the overall archive (which we call site contents for the user, or GTOC for "global" table of contents internally).

The page contents track the first three levels of headings.

f:weaveAllTocs
weaveAllTocs :: [(LCI, Cell)] -> HtmlWeaver ()
weaveAllTocs lciCellPairs = do
  nav_ [class_ "tocs"] $ do
    weaveConf <- ask
    weaveGtoc weaveConf.archive.gtoc
    weaveToc $ map snd lciCellPairs
css:tocs
nav # ".tocs" ? do
  "grid-area" -: "tocs"
  "height"
    -: """
       calc(100vh - css:navbar-height)
       """
  width $ px 300
  fontSans
  position fixed
  "top" -: "css:navbar-height"
  borderRight (px 1) solid colorCodeBorder
  overflow hidden
  display grid
  "grid-template-rows" -: "auto auto auto 1fr"
  "grid-template-areas"
    -: """
       "gtoc-title"
       "gtoc"
       "toc-title"
       "toc"
       """
  "z-index" -: "990"
  backgroundColor colorBg

17.1 Page contents

We convert the flat heading cells into a nested structure (with bullet points), because in the future we may decide to make TOC headings collapsible, and in that scenario it would feel much more natural to have a nested structure. The spirit of this is very similar to how we computed the section numbers for each heading in f:headingParser. Every time we enter a nesting level, we'll have to create a new ordered list tag (<ol>), and every time we leave a level (and we may leave more than one level at once), we have to close those unordered list tags (with </ol>).

f:weaveToc
weaveToc :: [Cell] -> HtmlWeaver ()
weaveToc cells = do
  -- 95
  let pageMetricsCells =
        page-metrics-cells
      cellsWithPageMetrics = cells <> pageMetricsCells
  p_ [class_ "widget-title toc-title"] $ do
    toHtml @Text "Page contents"
  nav_ [class_ "toc"] $ do
    div_ [class_ "toc-entries"] $ do
      closeHeadingLevels =<< foldlM f 0 cellsWithPageMetrics -- 96
  where
    f :: Int -> Cell -> HtmlWeaver Int
    f prevLevel = \case
      CHeading heading -> do
        if
          | prevLevel < heading.level -> weaveListMarker LOrderedStart
          | prevLevel > heading.level -> do
              closeHeadingLevels (prevLevel - heading.level) -- 97
              weaveListMarker LItemEnd
          | otherwise -> weaveListMarker LItemEnd
        weaveTocHeading heading
        pure heading.level
      _ -> pure prevLevel
    closeHeadingLevels :: Int -> HtmlWeaver ()
    closeHeadingLevels levels =
      forM_ [1 .. levels] $ \_ -> do
        weaveListMarker LItemEnd
        weaveListMarker LOrderedEnd

95 constructs artificial heading cells for the page metrics, so that we get entries for these bits which are generated automatically. It's admittedly a bit hacky, because we have to also manually craft heading ancestry entries at f:weaveHeadingAncestry.

All of the business around using LItemEnd and LOrderedEnd is needed because when we nest levels, we cannot close the current list item (because nested list items are technically children of the current list item). The call to closeHeadingLevels 97 is needed because the f helper only works to close nesting levels when we encounter a heading that is less nested than the previous one. So this means the last heading will always remain "open". We close off the last heading level in 96.

f:weaveTocHeading weaves the TOC heading as a list item.

f:weaveTocHeading
weaveTocHeading :: Heading -> HtmlWeaver ()
weaveTocHeading heading = do
  toHtmlRaw
    ( "<li class=\"toc-level-"
        <> show heading.level
        <> "\">" ::
        Text
    )
  div_ [class_ "toc-entry"] $ do
    if null heading.section
      then pure ()
      else do
        span_ [class_ "toc-counter"] $ do
          toHtml sectionText
    a_ [href_ $ "#" <> linkAnchor] $ do
      weaveProse heading.body
  where
    sectionText = getSectionNumber heading
    linkAnchor = getLinkAnchor Nothing $ CHeading heading

17.1.1 Heading ancestry

For the Active headings TOC indicator we want to highlight all visible headings as well as all of their parent headings ("ancestor" headings) in the document. We need to generate a "heading ancestry" map where the keys are individual headings, and the values are lists of other headings which are considered its ancestors.

To be a bit more precise though, we need ancestry information for all cells, not just headings, because it could be the case that the viewport is not showing any headings, but only the paragraphs (or other non-heading cells, such as a code block or table) that belong to a heading. In these situations we would still want to highlight the most relevant heading (and its ancestors). What we can do is group a heading and all of its non-heading children into a single <div> wrapper. Then we can just check for whether this wrapper is visible. So the heading ancestry should be available for all heading wrappers in the document, where the keys are the headings (and their children that the wrappers have grouped up together) and the values are the headings we want to highlight in the TOC as being active.

In f:genHeadingAncestry we fold over all cells, but skip over any non-heading cell. For headings, we get the unique ID of it by calling f:getLinkAnchor. This is used as the map key. And whenever we detect Headings we update the list of ancestor headings (also using f:getLinkAnchor to get the name), and use this as the basis of the map value (with a minor tweak via ancestorsOf 101).

We update the heading ancestry as follows:

  1. If the list is empty, push the current heading into the list.

  2. Otherwise, compare current heading level with heading level of the first element (most nested) of the list:

    • If current one is higher 98, pop off elements until we see one that's even higher and push this one.

    • If they're equal 100, then pop off one element and push this one.

    • If current one is lower 99, then just push into the list (no popping).

f:genHeadingAncestry
genHeadingAncestry :: [Cell] -> Map Text [Text]
genHeadingAncestry = snd . foldl' f ([], mempty)
  where
    f ::
      ([Heading], Map Text [Text]) ->
      Cell ->
      ([Heading], Map Text [Text])
    f ([], m) cell@(CHeading heading) =
      let updatedAncestry = [heading]
          key = getLinkAnchor Nothing cell
          val = [] :: [Text]
       in (updatedAncestry, insert key val m)
    f (ancestry@(a : as), m) cell@(CHeading heading) =
      let updatedAncestry
            | heading.level < a.level -- 98
              =
                heading
                  : (dropWhile (\a' -> heading.level <= a'.level) ancestry)
            | heading.level > a.level = heading : ancestry -- 99
            | otherwise = heading : as -- 100
          key = getLinkAnchor Nothing cell
          val =
            map
              (getLinkAnchor Nothing . CHeading)
              $ ancestorsOf heading ancestry
       in (updatedAncestry, insert key val m)
    f acc _ = acc
    ancestorsOf :: Heading -> [Heading] -> [Heading]
    ancestorsOf leafHeading ancestry -- 101
      | null ancestry = []
      | otherwise = dropWhile (\a' -> leafHeading.level <= a'.level) ancestry

The ancestorsOf function 101 is needed because we can't use either the existing ancestry (from the previous iteration) or the updatedAncestry as is. This is because ancestry is simply outdated (it may have an elaborate hierarchy that is not relevant to a new top-level heading), and updatedAncestry includes the current heading (and the current heading cannot be its own ancestor).

We then convert this map into a JSON object and embed it into the document directly in f:weaveHeadingAncestry, so that the frontend code can parse and reference it.

f:weaveHeadingAncestry
weaveHeadingAncestry :: [(LCI, Cell)] -> HtmlWeaver ()
weaveHeadingAncestry lciCellPairs = do
  let headingAncestry = genHeadingAncestry $ map snd lciCellPairs
      -- 102
      pageMetricsAncestry
  script_ [id_ "heading-ancestry", type_ "application/json"] $ do
    toHtmlRaw . TLE.decodeUtf8 . encode $ headingAncestry <> pageMetricsAncestry

17.1.2 CSS

css:page-toc
".toc-title" ? do
  "grid-area" -: "toc-title"
  margin (px 0) (px 20) (px 10) (px 20)
nav # ".toc" ? do
  "grid-area" -: "toc"
  overflowY auto
  div # ".toc-entries" ? do
    paddingLeft $ px 30
    ol ? do
      marginTop $ px 0
      paddingLeft $ rem 0
      listStyleType none
      li # ".toc-level-1" ? do
        position relative
        before & do
          content $ stringContent "symbol:h2" -- 103
          marginLeft (rem $ -1.3)
          position absolute
          left (px 0)
          top (px $ -2)
          fontSize (px 24)
          fontFamily ["Arial"] [sansSerif]
          lineHeight $ unitless 1
      li # ".toc-level-2" ? do
        position relative
        before & do
          content $ stringContent "symbol:h3" -- 104
          marginLeft (rem $ -1.3)
          position absolute
          left (px 0)
          top (px $ -2)
          fontSize (px 24)
          fontFamily ["Arial"] [sansSerif]
          lineHeight $ unitless 1
      div ? do
        paddingLeft $ px 30
        paddingRight $ px 10
        marginLeft $ px (-30)
        ".active" & do
          backgroundColor colorTocActive
        ".active-ancestor" & do
          backgroundColor colorTocActiveAncestor
    span # ".toc-counter" ? do
      marginRight $ rem 0.3

17.2 Site contents

The main difference between the two is that a GTOC uses a tree data structure, whereas the TOC uses a flat list of cells.

The entrypoint is f:weaveGtoc, which uses f:weaveGtocForest to recursively weave the tree into a nested structure of unordered list items (<ul>...</ul>).

We could feed the entire tree to f:weaveGtocTree as is, but we first make the root index.org node a sibling to its children 106. This way, we avoid showing the root node on its own level (always by itself, without siblings, taking up valuable indentation space). That is, instead of

- Root node
  - Topic A
  - Topic B
  - ...

we will render it as

- Root node
- Topic A
- Topic B
- ...

The root node gets rendered separately on its own, much like an "Introduction" chapter in a book. For this reason the root index should typically be titled as "Introduction".

f:weaveGtoc
weaveGtoc :: GTOC -> HtmlWeaver ()
weaveGtoc gtoc = do
  p_ [class_ "widget-title gtoc-title"] $ do
    toHtml @Text "Site contents" -- 105
  nav_ [class_ "gtoc"] $ do
    let (Node rootNode forest) = gtoc.unGTOC
    ul_ [] $ do
      weaveGtocForest (Node rootNode [] : forest) -- 106

f:weaveGtocForest just delegates to f:weaveGtocTree.

f:weaveGtocForest
weaveGtocForest :: Forest GPath -> HtmlWeaver ()
weaveGtocForest nodes = forM_ nodes weaveGtocTree

f:weaveGtocTree renders a single object's document title or file name, before calling f:weaveGtocForest for all of its child nodes 107.

f:weaveGtocTree
weaveGtocTree :: Tree GPath -> HtmlWeaver ()
weaveGtocTree (Node path children) = do
  weaveConf <- ask
  li_ [] $ do
    let (docName, docPath) =
          case getRootRelativeOrgDocLink weaveConf path of
            Nothing ->
              ( "LILAC_ERROR_DOC_NAME_NOT_FOUND",
                "LILAC_ERROR_DOC_LINK_NOT_FOUND"
              )
            Just res -> res
        docLabel
          | Just obj <- lookup path weaveConf.archive.objects,
            Just title <- lookup "title" obj.orgDoc.meta =
              title
          | otherwise = docName
        boldAttr =
          if path == weaveConf.originObjectPath
            then [class_ "bold"]
            else []
    a_ ([href_ docPath] <> boldAttr) $ do
      toHtml @Text docLabel
    let isAncestor = path `isGtocAncestor` weaveConf.originObjectPath
    when (isAncestor && (not $ null children)) $ do
      ul_ [] $ weaveGtocForest children -- 107

f:isGtocAncestor checks if the first path is an "ancestor" of the current object being woven. We use this to make sure we only render the part of the GTOC we care about, because we don't want to weave the entire GTOC tree because it may be too big.

f:isGtocAncestor
isGtocAncestor :: GPath -> GPath -> Bool
isGtocAncestor path originObjectPath =
  let pathDirOsp =
        reverse
          . drop 1
          . reverse
          . P.splitPath
          . P.unsafeEncodeUtf
          $ decodeGPath path
      originOsp =
        P.splitPath
          . P.unsafeEncodeUtf
          $ decodeGPath originObjectPath
   in pathDirOsp `isPrefixOf` originOsp

t:0305-gtoc-ancestor encodes some expected behavior of f:isGtocAncestor. Perhaps the most interesting input is 108 where the given path deviates from the originObjectPath; this is the condition which will make sure we "prune" the tree so that we don't render the entire GTOC tree in f:weaveGtocTree.

t:0305-gtoc-ancestor
it "0305-gtoc-ancestor" $ do
  mapM_
    ( \(path, originObjectPath, expectation) ->
        (LW.isGtocAncestor (GPath path) (GPath originObjectPath))
          `shouldBe` expectation
    )
    [ ("a/index.lobj", "a/foo.lobj", True),
      ("a/b/index.lobj", "a/b/foo.lobj", True),
      ("a/index.lobj", "a/b/foo.lobj", True),
      ("a/index.lobj", "a/b/c/foo.lobj", True),
      ("a/b/c/index.lobj", "a/b/foo.lobj", False),
      ("a/m/index.lobj", "a/b/c/foo.lobj", False) -- 108
    ]

17.2.1 CSS

css:site-toc
".gtoc-title" ? do
  "grid-area" -: "gtoc-title"
  yx margin (px 10) (px 20)
nav # ".gtoc" ? do
  "grid-area" -: "gtoc"
  maxHeight $ vh 50
  paddingLeft $ px 20
  overflowY auto
  fontSans
  ul ? do
    marginTop $ px 0
    paddingLeft $ rem 1

18 Navigation bar

The navigation bar is the horizontal bar at the top of the page.

f:weaveNavbar
weaveNavbar :: HtmlWeaver ()
weaveNavbar = do
  div_ [class_ "navbar"] $ do
    div_ [class_ "breadcrumbs"] $ do
      weaveBreadcrumbs

One of the things in the navbar is the "breadcrumbs" area, which is the path that points to the current document. This path is constructed by starting with the project name, followed by the path to the object currently being woven, which is taken from the originObjectPath field from data:WeaveConf.

The breadcrumbs are made up of four pieces:

  1. project name 109

  2. (optional) project version 110

  3. middle 113

  4. Org document name 116

The middle part can be any number of intervening directory names. The project name comes from data:ProjectConf, and the Org document name comes from the originObjectPath from data:WeaveConf.

The breadcrumbs are woven in f:weaveBreadcrumbs.

f:weaveBreadcrumbs
weaveBreadcrumbs :: HtmlWeaver ()
weaveBreadcrumbs = do
  weaveConf <- ask
  weaveProjectName weaveConf.projectConf -- 109
  weaveProjectVersion weaveConf.projectConf -- 110
  let breadcrumbsMiddle =
        (\parts -> take (length parts - 2) (drop 1 parts)) -- 111
          . inits -- 112
          . P.splitDirectories
          . P.unsafeEncodeUtf
          $ decodeGPath weaveConf.originObjectPath
      breadcrumbLast =
        osPathDecoder
          . flip P.replaceExtensions (P.unsafeEncodeUtf "org")
          . P.takeFileName
          . P.unsafeEncodeUtf
          $ decodeGPath weaveConf.originObjectPath
  -- 113
  forM_ breadcrumbsMiddle $ \dirs -> do
    let dirName =
          osPathDecoder
            . fromMaybe (P.unsafeEncodeUtf "LILAC_ERROR_EMPTY_OSPATH_DIR")
            . listToMaybe
            . take 1
            $ reverse dirs
        indexPath =
          fromMaybe emptyGPath
            . encodeGPath
            . T.unpack
            . osPathDecoder
            $ P.combine (P.joinPath dirs) (P.unsafeEncodeUtf "index.lobj") -- 114
    weaveBreadcrumbSeparator
    if
      -- 115
      | Just _ <- lookup indexPath weaveConf.archive.objects -> do
          let (_, docPath) =
                fromMaybe ("", "LILAC_ERROR_RELATIVE_LINK_ERROR")
                  $ getRootRelativeOrgDocLink weaveConf indexPath
          a_ [href_ docPath] $ do
            weaveBreadcrumb dirName
      | otherwise -> weaveBreadcrumb dirName
  -- 116
  weaveBreadcrumbSeparator
  a_ [href_ "#top"] $ do
    weaveBreadcrumb breadcrumbLast
  where
    osPathDecoder osp = case P.decodeUtf osp :: Maybe FilePath of
      Nothing -> "LILAC_ERROR_INVALID_OSPATH"
      Just fp -> T.pack fp
    weaveProjectName :: ProjectConf -> HtmlWeaver ()
    weaveProjectName projectConf = case projectConf.homepage of
      Nothing -> do
        weaveBreadcrumb projectConf.name
      Just uri -> do
        let homepageLinkTarget = T.pack $ N.uriToString id uri ""
        a_ [href_ homepageLinkTarget] $ do
          weaveBreadcrumb projectConf.name
    weaveProjectVersion :: ProjectConf -> HtmlWeaver ()
    weaveProjectVersion projectConf = case projectConf.version of
      Nothing -> pure ()
      Just version -> toHtml $ " " <> version
    weaveBreadcrumb :: Text -> HtmlWeaver ()
    weaveBreadcrumb breadcrumb = do
      span_ [class_ "breadcrumb"] $ do
        toHtml breadcrumb
    weaveBreadcrumbSeparator :: HtmlWeaver ()
    weaveBreadcrumbSeparator = do
      toHtml @Text " "
      span_ [class_ "breadcrumb-separator"] $ do
        toHtml @Text "/"
      toHtml @Text " "

The middle breadcrumbs 113 are perhaps the most interesting. We get a list of all parent objects, which are all index.org objects in parent directories (including the root). We can get this list of parent index.org objects by getting all leading paths (with inits 112) like

a
a/b
a/b/c

for an origin object at path a/b/c/foo.lobj. 111 is just for dropping the first and last elements, because they are not needed. Then we just make each of those leading substrings end with /index.lobj 114, and then do lookups into the object map 115. If the path exists, then we render a link to it. If not, we leave it as a plaintext string.

The reliance on originObjectPath assumes that the archive has object paths that are relative to the repository root. It could be that this is not true, but we don't bother to handle that edge case here.

18.1 CSS

css:navbar
div # ".navbar" ? do
  "grid-area" -: "navbar"
  height $ rem 4
  "width" -: "100%"
  top $ px 0
  position fixed
  "z-index" -: "1000"
  borderBottom (px 1) solid colorCodeBorder -- 117
  backgroundColor colorBg
  css:breadcrumbs

The total height of the navbar is \(4\mathrm{em} + 1\mathrm{px}\) (and not just 4em) because of the 1-pixel border at the bottom 117.

css:navbar-height
4rem

The navbar causes a problem for links that point to elements inside the page, because the browser will normally go to the target element by putting it at the top of the page, but the navbar will sit on top of it. So we have to adjust it so that the navbar never hides something underneath it.

18.1.1 Breadcrumbs

The breadcrumbs (f:weaveBreadcrumbs) should be large and prominent at the top of the page.

css:breadcrumbs
div # ".breadcrumbs" ? do
  fontSans
  fontSize $ rem 1.6
  margin (px 12) 0 (px 10) (px 20)
span # ".breadcrumb-separator" ? do
  color colorBreadcrumbSeparator

19 Page metrics

The page metrics shows additional information about the current document. It sits on the bottom of the page (see css:Layout). It has the following parts:

  1. list of tangled files,

  2. list of backlinks,

  3. list of named cells, and

  4. a footer.

f:weavePageMetrics
weavePageMetrics :: [(LCI, Cell)] -> HtmlWeaver ()
weavePageMetrics lciCellPairs = do
  let blocks = catMaybes $ map (getBlock . snd) lciCellPairs
      tangledBlocks = catMaybes $ map getTangled blocks
  div_
    [ id_ "heading-container-h--Page-metrics",
      class_ "heading-container page-metrics"
    ]
    $ do
      h2_ [id_ "h--Page-metrics"] $ do
        a_ [href_ $ "#h--Page-metrics"] $ do
          toHtml ("Page metrics" :: Text)
  weaveBacklinks
  weaveTangledBlocksList tangledBlocks
  weaveNamedCellsIndex $ map snd lciCellPairs
  weaveFooter

f:getBlock just retrieves the block out of a cell.

f:getBlock
getBlock :: Cell -> Maybe Block
getBlock = \case
  CBlock block -> Just block
  _ -> Nothing

Because these page metrics are generated, they won't have entries in the Page contents (because the user did not write these headings). So we have to inject artificial heading cells in f:weaveToc 95, with page-metrics-cells.

page-metrics-cells
let haveTangled = not . null $ getTangledBlocks cells
    haveNamedCells = not . null $ getNamedCells cells
    defaultMetricsEntries =
      [ CHeading
          Heading
            { level = 1,
              section = [],
              body = Prose [ptext "Page metrics" []],
              meta = mempty
            },
        CHeading
          Heading
            { level = 2,
              section = [],
              body = Prose [ptext "Backlinks" []],
              meta = mempty
            }
      ]
    tangledFilesEntry =
      if haveTangled
        then
          [ CHeading
              Heading
                { level = 2,
                  section = [],
                  body = Prose [ptext "Tangled files" []],
                  meta = mempty
                }
          ]
        else []
    namedCellsEntry =
      if haveNamedCells
        then
          [ CHeading
              Heading
                { level = 2,
                  section = [],
                  body = Prose [ptext "Named cells" []],
                  meta = mempty
                }
          ]
        else []
 in defaultMetricsEntries <> tangledFilesEntry <> namedCellsEntry

f:getTangledBlocks builds on f:getBlock to get blocks from a list of cells.

f:getTangledBlocks
getTangledBlocks :: [Cell] -> [(Text, Block)]
getTangledBlocks cells = mapMaybe f cells
  where
    f cell = do
      block <- getBlock cell
      getTangled block

Similarly, we have to construct the heading ancestry information as well 102:

pageMetricsAncestry
pageMetricsAncestry :: Map Text [Text]
pageMetricsAncestry =
  M.fromList
    [ ("h--Page-metrics", []),
      ("h--Tangled-files", ["h--Page-metrics"]),
      ("h--Backlinks", ["h--Page-metrics"]),
      ("h--Named-cells", ["h--Page-metrics"])
    ]

19.1 Tangled files list

To better support Literate Programming, we list all files that are tangled from here in f:weaveTangledBlocksList. This goes through all code blocks that are tangled from the current document, and sorts them based on the tangled path.

f:weaveTangledBlocksList
weaveTangledBlocksList :: [(Text, Block)] -> HtmlWeaver ()
weaveTangledBlocksList tangledBlocks = do
  when (not $ null tangledBlocks) $ do
    div_
      [ id_ "heading-container-h--Tangled-files",
        class_ "heading-container tangled-blocks"
      ]
      $ do
        h3_ [id_ "h--Tangled-files"] $ do
          a_ [href_ $ "#h--Tangled-files"] $ do
            toHtml @Text "Tangled files ("
            toHtml . T.show $ length tangledBlocks
            toHtml @Text ")"
        ol_ [] $ do
          forM_ (sort tangledBlocks) $ \(path, block) -> do
            li_ [] $ do
              let linkAnchor = getLinkAnchor Nothing $ CBlock block
              a_ [href_ $ "#" <> linkAnchor, class_ "verbatim"] $ do
                toHtml path
f:getTangled
getTangled :: Block -> Maybe (Text, Block)
getTangled b
  | Just path <- lookup "tangle" b.meta =
      Just (path, b)
  | otherwise = Nothing

19.3 Named cells index

The named cells index displays a list of all named cells used in the current document, because if a cell was given a name it is probably of some interest to the reader.

f:weaveNamedCellsIndex
weaveNamedCellsIndex :: [Cell] -> HtmlWeaver ()
weaveNamedCellsIndex cells = do
  let namedCells = getNamedCells cells -- 124
      lenNamedCells = T.show $ length namedCells
  when (length namedCells > 0) $ do
    div_
      [ id_ "heading-container-h--Named-cells",
        class_ "heading-container named-cells"
      ]
      $ do
        h3_ [id_ "h--Named-cells"] $ do
          a_ [href_ $ "#h--Named-cells"] $ do
            toHtml @Text $ "Named cells (" <> lenNamedCells <> ")"
        ol_ [] $ do
          forM_ namedCells $ \(prose, cell) -> do
            li_ [] $ do
              let linkAnchor = "#" <> getLinkAnchor Nothing cell
              a_ [href_ linkAnchor] $ do
                weaveProse prose

f:getNamedCells filters out cells that are unnamed, and also sorts them by the name for a clean listing at 124.

f:getNamedCells
getNamedCells :: [Cell] -> [(Prose, Cell)]
getNamedCells cells =
  sort
    $ [ (prose, cell)
      | cell <- cells,
        Just prose <- [getCellName cell],
        isNotHiddenBlock cell
      ]
  where
    -- 125
    isNotHiddenBlock :: Cell -> Bool
    isNotHiddenBlock = \case
      CBlock block -> case lookup "hidden" block.meta of
        Nothing -> True
        Just val -> val /= "true"
      _ -> True

125 filters out blocks that have been hidden, such as index blocks 6.

19.5 CSS

css:page-metrics
forM_ [".backlinks", ".tangled-blocks", ".named-cells"] $ \metric -> do
  div # metric ? do
    fontSans
    ol ? do
      ev margin $ px 0
      paddingLeft $ px 15

The lists of tangled file, backlinks, etc do not need any additional customization because they are ultimately just links. The footer, however, needs additional tweaks.

css:Footer
footer ? do
  div # ".lilac-stamp" ? do
    fontSans
    fontStyle italic
    fontSize $ rem 0.9
    marginTop (px 40)
    marginBottom (px 10)

20 Testing framework

The tests here use Org files as input. The output are HTML documents. For the test cases, we search for the presence of certain substrings within the HTML.

20.1 Test module and helpers

The test module here mirrors what we've already seen in ParseSpec helpers. Namely we have some test helpers and some test cases using those helpers.

module:Lilac.WeaveSpec
🎯 test/Lilac/WeaveSpec.hs
module Lilac.WeaveSpec (spec) where

import Citeproc
  ( Result (resultBibliography, resultCitations, resultWarnings),
  )
import Citeproc qualified as CP
import Data.Text qualified as T
import Lilac.Compile qualified as LC
import Lilac.Parse (styleMaskFrom)
import Lilac.Parse qualified as LP
import Lilac.Types
  ( Link (Link, name, target),
    LinkOid (LinkOid, name, oid),
    LinkTarget
      ( LinkTargetFootnote,
        LinkTargetOid,
        LinkTargetURI
      ),
    MathFragment (MathFragment, body, meta, parentOid),
    PToken
      ( PTokenDivEnd,
        PTokenDivStart,
        PTokenLink,
        PTokenMathFragment
      ),
    Prose (Prose, unProse),
    Style (Bold, Code, Italic, Smallcaps, Superscript, Verbatim),
    StyledText (StyledText, body, style),
    WeaveConf
      ( WeaveConf,
        archive,
        citeprocResult,
        originObjectPath,
        outDir,
        projectConf,
        rootRelativePrefix
      ),
    defaultOrgParserState,
    emptyProjectConf,
    encodeGPath,
    nilOid,
    nilOidOrgDocPrefix,
    ptext,
    stext,
  )
import Lilac.Types.Internal (GPath (GPath))
import Lilac.Util qualified as LU
import Lilac.Weave qualified as LW
import Network.URI qualified as N
import Relude
  ( Bool (False, True),
    Either (Left, Right),
    FilePath,
    IO,
    Maybe (Just, Nothing),
    String,
    Text,
    foldl',
    fromList,
    fst,
    map,
    mapM_,
    otherwise,
    pure,
    reverse,
    runStateT,
    sequence_,
    show,
    ($),
    (.),
    (<$>),
    (<>),
    (==),
  )
import Test.Hspec
  ( Expectation,
    Spec,
    describe,
    expectationFailure,
    it,
    runIO,
    shouldBe,
    shouldContain,
    shouldStartWith,
  )
import Text.Megaparsec qualified as MP

spec :: Spec
spec = do
  All test cases

f:getWovenPrettyFromPath
f:searchOrderedNeedles
f:weaveExpect
f:getCiteprocResultFromPath
f:expectCiteprocResult

f:getWovenPrettyFromPath weaves the given Org file to a Text, by first compiling it to an object. Then in our test cases we can check for expected substrings inside it. Note how it uses renderHtml from the Text.Blaze.Html.Renderer.Pretty module, because we want to be able to write test cases where we write expected HTML substrings in a prettified way (for readability).

f:getWovenPrettyFromPath
getWovenPrettyFromPath :: FilePath -> IO (Either String Text)
getWovenPrettyFromPath path = do
  inputOrgFileGPath <- encodeGPath $ "test/Lilac/WeaveSpec/" <> path
  inputOrgFile <- LU.readFrom "test/Lilac/WeaveSpec/" path
  outDir <- encodeGPath "test/Lilac/WeaveSpec/"
  let parseResult =
        MP.runParser
          (runStateT LP.orgDocParser defaultOrgParserState)
          path
          (nilOidOrgDocPrefix <> inputOrgFile)
  case parseResult of
    Left errBundle -> pure . Left $ MP.errorBundlePretty errBundle
    Right (orgDoc, _) -> do
      let object = LC.mkObject orgDoc
      maybeArchive <-
        LC.mkArchive
          emptyProjectConf
          [(inputOrgFileGPath, object)]
      case maybeArchive of
        Left errMsg -> pure $ Left errMsg
        Right archive -> do
          result <-
            LW.prepareCitations
              emptyProjectConf
              inputOrgFileGPath
              object
              archive
          let weaveConf =
                WeaveConf
                  { projectConf = emptyProjectConf,
                    archive = archive,
                    originObjectPath = inputOrgFileGPath,
                    outDir = outDir,
                    rootRelativePrefix = GPath "/",
                    citeprocResult = result
                  }
          pure
            . Right
            . LW.renderAsText weaveConf
            $ LW.weaveObject object

f:searchOrderedNeedles looks through a haystack and searches for all given needles to be in the haystack, in order.

f:searchOrderedNeedles
searchOrderedNeedles :: Text -> [Text] -> (String, [Expectation])
searchOrderedNeedles haystackRaw needles =
  (\expectations -> ("sees needles", expectations))
    . reverse
    . fst
    $ foldl' f ([], haystackRaw) needles
  where
    f ::
      ([Expectation], Text) ->
      Text ->
      ([Expectation], Text)
    f (testCases, haystack) needle =
      let (discardMe, foundAndRest) = T.breakOn needle haystack
          discardLen = T.length discardMe
          needleStr = T.unpack needle
          foundAndRestStr = T.unpack foundAndRest
          expectation = foundAndRestStr `shouldStartWith` needleStr
          expectationF =
            expectationFailure
              $ "could not find "
              <> (show $ needleStr)
              <> " in haystack:\n"
              <> (T.unpack haystack)
          remainingHaystack = T.drop discardLen haystack
       in if foundAndRest == T.empty
            then (expectationF : testCases, remainingHaystack)
            else (expectation : testCases, remainingHaystack)

In practice we will only need multiple needles if we are dealing with a complicated haystack (with nested structures) and we only want to check some key structures within it.

f:weaveExpect takes a path to an Org file and the list of expected substrings inside the woven result.

f:weaveExpect
weaveExpect :: FilePath -> [Text] -> Spec
weaveExpect path needles = do
  result <- runIO $ getWovenPrettyFromPath path
  describe path $ do
    case result of
      Right haystack -> do
        let (name, expectations) = searchOrderedNeedles haystack needles
        it name $ sequence_ expectations
      Left errMsg -> it "programmer error" $ expectationFailure errMsg

f:getCiteprocResultFromPath is like f:getWovenPrettyFromPath, but it only returns the result of calling f:prepareCitations.

f:getCiteprocResultFromPath
getCiteprocResultFromPath :: FilePath -> IO (Either String (CP.Result Prose))
getCiteprocResultFromPath path = do
  inputOrgFileGPath <- encodeGPath $ "test/Lilac/WeaveSpec/" <> path
  inputOrgFile <- LU.readFrom "test/Lilac/WeaveSpec/" path
  let parseResult =
        MP.runParser
          (runStateT LP.orgDocParser defaultOrgParserState)
          path
          (nilOidOrgDocPrefix <> inputOrgFile)
  case parseResult of
    Left errBundle -> pure . Left $ MP.errorBundlePretty errBundle
    Right (orgDoc, _) -> do
      let object = LC.mkObject orgDoc
      maybeArchive <-
        LC.mkArchive
          emptyProjectConf
          [(inputOrgFileGPath, object)]
      case maybeArchive of
        Left errMsg -> pure $ Left errMsg
        Right archive ->
          Right
            <$> LW.prepareCitations
              emptyProjectConf
              inputOrgFileGPath
              object
              archive

f:expectCiteprocResult takes an Either String (CP.Result Prose). If it's a Right values, it checks the citations and bibliography for a given Org doc in the test/Lilac/WeaveSpec folder. If it's a Left value, it expects the operation to fail, and expects to find a substring.

f:expectCiteprocResult
expectCiteprocResult :: FilePath -> Either String (CP.Result Prose) -> Spec
expectCiteprocResult path expected = do
  result <- runIO $ getCiteprocResultFromPath path
  describe path $ do
    if
      | Right got <- result,
        Right expectedResult <- expected ->
          it "matches expected result" $ do
            got `shouldBe` expectedResult
      | Left errMsg <- result,
        Left expectedErrorSubstring <- expected ->
          it "has error substring" $ do
            errMsg `shouldContain` expectedErrorSubstring
      | otherwise ->
          it "unexpected type mismatch" $ do
            result `shouldBe` expected

20.2 All test cases

21 Glossary

.code-block-body
Main body of the block, which contains the actual code that have been fenced by #+begin_... and #+end_....
.code-block-meta
The name or tangle path. These attributes are so common (and important) that we always display them where possible.
.code-block-meta-raw
Other metadata such as the programming language syntax of the code block. These attributes are typically not as interesting, and so require the user to press a button (labeled M) in the .code-block-controls container to reveal them.
a
This is in its own <dl> tag.
b
This is also in its own (separate) <dl> tag.
Backlinks
List of backlinks to any named cell in the current document.
c
x
d
y
Main
Holds all of the content of the document. The list of cells in the document are rendered one after another in here, top to bottom.
Named cells
List of named cells in the current document.
Navbar
Contains the filesystem path of the object, as well as a link to the homepage of the project data:ProjectConf2.
Page metrics
Holds various widgets which aid in navigation. The widgets are:
Table of contents
Holds both the site contents and page contents, which give a macro and micro overview of the current document. See Table of contents (TOC).
Tangled files
List of tangled files in the document.

Page metrics

Tangled files (15)

  1. src/Lilac/Weave.hs
  2. src/Lilac/Weave/Css.hs
  3. test/Lilac/WeaveSpec.hs
  4. test/Lilac/WeaveSpec/0300-weave-lists
  5. test/Lilac/WeaveSpec/0300-weave-styled-text
  6. test/Lilac/WeaveSpec/0400-citeproc-basic
  7. test/Lilac/WeaveSpec/0400-citeproc-multiline
  8. test/Lilac/WeaveSpec/0400-citeproc-multiline-cell-boundary
  9. test/Lilac/WeaveSpec/0400-citeproc-multiline-cell-boundary-list-item
  10. test/Lilac/WeaveSpec/0400-citeproc-multiline-complex
  11. test/Lilac/WeaveSpec/0400-citeproc-multiline-complex-list-item
  12. test/Lilac/WeaveSpec/0400-citeproc-smallcaps
  13. test/Lilac/WeaveSpec/0400-citeproc-style-variations
  14. test/Lilac/WeaveSpec/0400-citeproc-superscript
  15. test/Lilac/WeaveSpec/bib.json

Named cells (208)

  1. All test cases
  2. ClojureScript frontend script
  3. Colors
  4. Default MathJax settings
  5. Expected bibliography for 0400-citeproc-basic
  6. Expected bibliography for 0400-citeproc-multiline-complex-list-item
  7. Expected bibliography for 0400-citeproc-smallcaps
  8. Expected bibliography for 0400-citeproc-superscript
  9. Expected citations for 0400-citeproc-basic
  10. Expected citations for 0400-citeproc-multiline-complex
  11. Expected citations for 0400-citeproc-multiline-complex-list-item
  12. Expected citations for 0400-citeproc-smallcaps
  13. Expected citations for 0400-citeproc-style-variations
  14. Expected citations for 0400-citeproc-superscript
  15. Google Fonts
  16. HTML head
  17. HTML link symbol
  18. Load CSS
  19. MathJax customization
  20. MathJax script
  21. css:Block body
  22. css:Block controls
  23. css:Block controls buttons
  24. css:Block controls inline
  25. css:Block grid layout
  26. css:Block line labels
  27. css:Block meta
  28. css:Block meta raw
  29. css:Block rounding
  30. css:Citations
  31. css:Code blocks
  32. css:Document metadata
  33. css:Figures
  34. css:Footer
  35. css:Footer height
  36. css:Footnotes
  37. css:Glossary terms
  38. css:Headings
  39. css:Layout
  40. css:Links
  41. css:Links (external)
  42. css:List item minimum height
  43. css:List items
  44. css:StyledTextStyles
  45. css:Tables
  46. css:active-figures
  47. css:breadcrumbs
  48. css:counter-suffix-parenthesis
  49. css:h1 (title) underline
  50. css:main-content
  51. css:mobile-tearing-fix
  52. css:navbar
  53. css:navbar-height
  54. css:navbar-link-target-adjustment
  55. css:page-metrics
  56. css:page-toc
  57. css:site-toc
  58. css:tocs
  59. custom-html-head
  60. data:WeaveConf
  61. external-link-svg
  62. f:citationToCiteprocCitation
  63. f:citeToCiteprocCitationItems
  64. f:colorFrom
  65. f:colorizeText
  66. f:cslJsonTextToProse
  67. f:defaultCss
  68. f:ev
  69. f:expectCiteprocResult
  70. f:fontMono
  71. f:fontSans
  72. f:fontSerif
  73. f:genCss
  74. f:genHeadingAncestry
  75. f:getBlock
  76. f:getBlockContentsWithoutLinks
  77. f:getCiteprocResultFromPath
  78. f:getExternalRelPath
  79. f:getFirstColumnCells
  80. f:getHeaderRowIndex
  81. f:getLinkAnchor
  82. f:getLinkToGCI
  83. f:getMaxCol
  84. f:getMaxRow
  85. f:getNamedCells
  86. f:getProseContents
  87. f:getRootRelativeOrgDocCellLink
  88. f:getRootRelativeOrgDocLink
  89. f:getRootRelativePath
  90. f:getSectionNumber
  91. f:getTableCellsFromRow
  92. f:getTangled
  93. f:getTangledBlocks
  94. f:getWovenPrettyFromPath
  95. f:headingPrefix
  96. f:hexColorParser
  97. f:isExternalLink
  98. f:isGtocAncestor
  99. f:kateLilac
  100. f:lciAttrs
  101. f:prepareCitations
  102. f:proseHas
  103. f:proseToCslJsonText
  104. f:proseToMaybeCslJsonText
  105. f:rawCss
  106. f:renderAsText
  107. f:roundedRectangleLink
  108. f:searchOrderedNeedles
  109. f:showBrokenLink
  110. f:sortFootnoteCells
  111. f:spliceLinks
  112. f:tableCellLacks
  113. f:toNormalizedText
  114. f:toSafeHtmlId
  115. f:weaveAllTocs
  116. f:weaveBacklink
  117. f:weaveBacklinks
  118. f:weaveBacklinksForExternalObj
  119. f:weaveBibliography
  120. f:weaveBlock
  121. f:weaveBlockAncestry
  122. f:weaveBlockBody
  123. f:weaveBlockMeta
  124. f:weaveBreadcrumbs
  125. f:weaveCell
  126. f:weaveCitation
  127. f:weaveCollectedGlossaryTerm
  128. f:weaveDocMeta
  129. f:weaveExpect
  130. f:weaveFigure
  131. f:weaveFooter
  132. f:weaveFootnote
  133. f:weaveFootnoteBacklink
  134. f:weaveGlossary
  135. f:weaveGlossaryTerm
  136. f:weaveGtoc
  137. f:weaveGtocForest
  138. f:weaveGtocTree
  139. f:weaveHeading
  140. f:weaveHeadingAncestry
  141. f:weaveImportantMetadata
  142. f:weaveInlineBlockControls
  143. f:weaveLink
  144. f:weaveLinkDisplayName
  145. f:weaveLinkTarget
  146. f:weaveLinkTargetLinkOid
  147. f:weaveListMarker
  148. f:weaveMain
  149. f:weaveMathFragmentInline
  150. f:weaveMathFragmentStandalone
  151. f:weaveNamedCellsIndex
  152. f:weaveNavbar
  153. f:weaveObject
  154. f:weavePToken
  155. f:weavePageMetrics
  156. f:weaveParentBlock
  157. f:weaveProse
  158. f:weaveStyledText
  159. f:weaveTable
  160. f:weaveTableBody
  161. f:weaveTableCaption
  162. f:weaveTableColumnGroups
  163. f:weaveTableHead
  164. f:weaveTableRows
  165. f:weaveTangledBlocksList
  166. f:weaveToc
  167. f:weaveTocHeading
  168. f:wrapWith
  169. f:wrapWithDiv
  170. f:yx
  171. font-mono
  172. font-sans
  173. font-serif
  174. load-favicon
  175. module:Lilac.Weave
  176. module:Lilac.Weave.Css
  177. module:Lilac.WeaveSpec
  178. page-metrics-cells
  179. pageMetricsAncestry
  180. symbol:h2
  181. symbol:h3
  182. t:0300-weave-lists
  183. t:0300-weave-styled-text
  184. t:0305-gtoc-ancestor
  185. t:0400-citeproc-basic
  186. t:0400-citeproc-multiline
  187. t:0400-citeproc-multiline-cell-boundary
  188. t:0400-citeproc-multiline-cell-boundary-list-item
  189. t:0400-citeproc-multiline-complex
  190. t:0400-citeproc-multiline-complex-list-item
  191. t:0400-citeproc-smallcaps
  192. t:0400-citeproc-style-variations
  193. t:0400-citeproc-superscript
  194. tbl:Marking characters
  195. ti:0300-weave-lists
  196. ti:0300-weave-styled-text
  197. ti:0400-citeproc-basic
  198. ti:0400-citeproc-multiline
  199. ti:0400-citeproc-multiline-cell-boundary
  200. ti:0400-citeproc-multiline-cell-boundary-list-item
  201. ti:0400-citeproc-multiline-complex
  202. ti:0400-citeproc-multiline-complex-list-item
  203. ti:0400-citeproc-smallcaps
  204. ti:0400-citeproc-style-variations
  205. ti:0400-citeproc-superscript
  206. ti:Sample CSL JSON bibliography
  207. type:ColorParser
  208. type:HtmlWeaver