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.FileEmbed (embedFile)
import Data.IntMap.Strict qualified as IntMap
import Data.List (groupBy)
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,
        CRichBlock,
        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),
    RichBlock (marker, meta),
    RichBlockDelimiter (RBEnd, RBStart),
    Style
      ( Bold,
        Code,
        Italic,
        Smallcaps,
        Subscript,
        Superscript,
        Underline,
        Verbatim
      ),
    StyledText (StyledText, body, style),
    Table (caption, grid, meta, textAlign),
    TextAlign (TextAlignCenter, TextAlignLeft, TextAlignRight),
    WeaveConf
      ( archive,
        citeprocResult,
        originObjectPath,
        projectConf,
        rootRelativePrefix
      ),
    decodeGPath,
    emptyGPath,
    encodeGPath,
    gciToObjCell,
    gciToObjPath,
    getBlock,
    getNamedCells,
    getProseContents,
    getSectionNumber,
    getTangled,
    getTangledBlocks,
    isTextAlignMarker,
    markingChars,
    markingCharsInvisible,
    ptext,
    stext,
    stripLinks,
    stripStyles,
  )
import Lucid
  ( Attributes,
    HtmlT,
    a_,
    alt_,
    async_,
    body_,
    br_,
    button_,
    charset_,
    class_,
    code_,
    col_,
    colgroup_,
    content_,
    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_,
    name_,
    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, makeAttributes)
import Network.URI
  ( uriAuthority,
    uriPath,
    uriScheme,
  )
import Network.URI qualified as N
import Relude
  ( Bool (False, True),
    ByteString,
    Char,
    Either (Right),
    FilePath,
    IO,
    Identity,
    Int,
    Map,
    Maybe (Just, Nothing),
    ReaderT,
    String,
    Text,
    Type,
    abs,
    any,
    ask,
    catMaybes,
    comparing,
    concat,
    concatMap,
    decodeUtf8,
    drop,
    dropWhile,
    elem,
    filter,
    flip,
    fmap,
    foldMap',
    foldl',
    foldlM,
    forM_,
    fromMaybe,
    fst,
    id,
    inits,
    intersperse,
    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:weaveRichBlock
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,
    borderColor,
    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,
    minWidth,
    monospace,
    nav,
    none,
    normal,
    ol,
    opacity,
    overflow,
    overflowX,
    overflowY,
    p,
    padding,
    paddingLeft,
    paddingRight,
    pct,
    position,
    pre,
    pretty,
    px,
    relative,
    rem,
    renderWith,
    rgb,
    sansSerif,
    serif,
    sideCenter,
    sideLeft,
    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.FileEmbed (embedFile)
import Data.Text qualified as T
import Data.Text.Lazy qualified as TL
import Relude
  ( Bool (True),
    ByteString,
    Int,
    Maybe (Just),
    String,
    Text,
    Type,
    Void,
    concatMap,
    decodeUtf8,
    forM_,
    fromMaybe,
    otherwise,
    pure,
    toString,
    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 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 */\n"
    <> decodeUtf8 @Text @ByteString $(embedFile "src/Lilac/Weave/Css/raw.css")
raw.css
🎯 src/Lilac/Weave/Css/raw.css
css:counter-suffix-parenthesis
css:navbar-link-target-adjustment
css:mobile-tearing-fix
css:mobile
css:print-preview

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 separate 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
    weaveAllTocs lciCellPairs
    weaveMain object.orgDoc.meta 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 39, 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

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 (rem 0.125)
".doc-meta-date" & do
  fontStyle italic
  ev margin (rem 0.125)

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 34.

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
  CRichBlock richBlock -> weaveRichBlock lci richBlock
  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 121. 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"]
  HTML-mobile-avoid-desktop-emulation
  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 =
      decodeUtf8
        @Text
        @ByteString
        $(embedFile "src/Lilac/Weave/defaultMathJaxSettings.js")
when (not onlyLocalImports') $ script_ [] defaultMathJaxSettings

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

Default MathJax settings
🎯 src/Lilac/Weave/defaultMathJaxSettings.js
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 $ rem 0
  ev padding $ rem 0
  backgroundColor colorBg
  color colorText
  fontSerif
  fontSize $ px 16 -- 17
body ? do
  minHeight (vh 100)
  display grid
  gridTemplateColumns [rem css:tocs-width, rem css:page-width]
  "grid-template-rows" -: "auto 1fr"
  "grid-template-areas"
    -: "\"navbar navbar\"\n\"tocs   main\""
  yx margin (rem 0) auto
  overflowY auto
css:navbar
css:tocs
css:page-toc
css:site-toc
css:main-content
css:page-metrics
css:Footer -- 18
p # ".widget-title" -- 19
  ? do
    fontWeight bold
    borderBottom (px 1) solid colorCodeBorder
div ? do
  -- 20
  ev margin $ rem 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 18. 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 19.

The <div> element is used in many places to section off areas of content; to avoid repetition, we reset its margins to be 0 20 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 (rem 1)
  height auto
  overflowY visible
  padding (rem 0) (rem 1.25) (rem 0) (rem 1.25)
  paddingLeft (rem 1.5)
  "position" -: "relative"
  "top" -: "css:navbar-height"

css:page-width is 50 rem units, which is encoded below so that it can be reused in css:print-preview-blocks.

css:page-width
50

4.5.1 Mobile friendliness

On mobile screens, the default font size is too small because of the "desktop emulation" behavior of assuming a width of 980px and then zooming out to make everything fit. To avoid this default width, we need to inject the following:

HTML-mobile-avoid-desktop-emulation
meta_ [name_ "viewport", content_ "width=device-width, initial-scale=1.0"]

This then makes the text a bit too large, and so we need to scale things down a bit 21. Compare with 17.

The other major thing is getting rid of the sidebar showing the TOC entries; this width takes up too much room so we have to disable it by default 22. The other side of the coin for this is to make the body displayed as a block 23 instead of a grid (as one of the columns of the 2-column grid is gone, it no longer makes sense to display it as a grid).

css:mobile
@media (max-width: 768px), (max-aspect-ratio: 1/1) {
  html {
    font-size: 12px; /* 21 */
  }
  button#hamburger {
    display: block;
    border: none;
    background: none;
    cursor: pointer;
    font-size: 1.6rem;
    margin-left: 0.5rem;
    margin-top: -0.25rem;
  }
  nav#tocs {
    display: none; /* 22 */
  }
  nav#tocs.active {
    display: grid;
  }
  body {
    display: block; /* 23 */
    overflow-x: hidden;
  }
  .block-body {
    overflow-x: auto; /* 24 */
  }
}

The other bits are mainly for controlling how the main content looks. We only enable horizontal scrolling on the individual elements like blocks 24.

4.5.2 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.

4.5.3 Print preview

A number of things change for the print preview, namely the navbar, the sidebar, and the blocks.

css:print-preview
@media print {
  css:print-preview-navbar
  css:print-preview-tocs-sidebar
  css:print-preview-blocks
  css:print-preview-table
}

Make the navbar only show up on the first page in the print preview, instead of on every page.

css:print-preview-navbar
@media print {
  div.navbar {
    position: static;
    grid-area: auto;
  }
  main {
    top: 0; /* 25 */
  }
}

25 is needed because otherwise the gap is too wide between the navbar (which now no longer sits superimposed over <main>) and the title.

Hide the TOC sidebar completely for print previews, because it wastes far too much space as is for print media.

css:print-preview-tocs-sidebar
body {
  display: block;
}
nav#tocs {
  display: none;
}

The blocks need to avoid the grid display stuff to guarantee that the child DIVs are displayed contiguously (to avoid page breaks in the middle of the code block between child DIVs).

css:print-preview-blocks
/* 26 */
.block-controls {
  display: none;
}

/* 27 */
.block-container,
.block-body,
.block-meta,
.cell-idx {
  display: block;
}

.block-container {
  margin-left: 0; /* 28 */
  width: css:page-widthrem;
}

/* 29 */
pre > code.sourceCode > span {
  text-indent: 0;
  padding-left: 0;
}

26 hides the metadata buttons (they're useless in the non-interactive print preview).

27 keeps the different parts of the block together (otherwise the browser will be happy to insert page breaks in between the different DIVs).

28 resets the left margin, because the block-controls are not visible see 70.

Without 29, we end up with code with lots of extra whitespace (as if the text in the blocks are almost justified). Ideally we wouldn't have to tweak generated CSS (the .sourceCode stuff is generated by Skylighting, not us), but it works well enough for now.

css:print-preview-table
table th {
  top: 0;
}

css:print-preview-table disables the compensation of the navbar we have at 91, because the navbar is disabled during print previews.

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>" -- 33
  let containerId = "heading-container-" <> linkAnchor
  toHtmlRaw
    $ "<div id=\""
    <> containerId
    <> "\" class=\"heading-container\">" -- 34
  hN [id_ linkAnchor] $ do
    wrapWith lci $ do
      a_ [href_ $ "#" <> linkAnchor] $ do
        toHtml $ getSectionNumber heading -- 35
        toHtmlRaw ("&ensp;" :: Text)
        weaveProse heading.body -- 36
  where
    hN = case heading.level of
      1 -> h2_ -- 37
      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). 33 closes the <div> elements opened up by 34 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 37.

6.4 Rendered text

As for rendering the heading for the user to read, we again use the section number 35 and body 36. 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
    _ -> 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 38, instead of copy-pasting the same configuration across all headings.

css:Headings
forM_ (zip [(1 :: Int) ..] [h1, h2, h3, h4, h5, h6]) -- 38
  $ \(n, hN) -> do
    hN ? do
      marginTop (rem 0)
      marginBottom (rem 0)
      case n of
        1 -> do
          lineHeight $ unitless 1
          marginTop (rem 0.5)
          fontSize (rem 3)
          headingPrefix "●" (rem 3) (rem $ -1.8) (rem $ -0.1875) -- 39
        2 -> do
          fontSize (rem 2.4)
          headingPrefix "symbol:h2" (rem 2) (rem $ -1.5) (rem 0.375) -- 40
        3 -> do
          fontSize (rem 2)
          headingPrefix "symbol:h3" (rem 2) (rem $ -1.5) (rem 0.25) -- 41
        4 -> do
          fontSize (rem 1.6)
        5 -> do
          fontSize (rem 1.4)
        6 -> do
          fontSize (rem 1.2)
        _ -> pure ()
      position relative
      fontSans
      a ? do
        color $ colorFrom "#000000"
        ":visited" & do
          color colorText
      border (px 1) solid colorBg
      yx padding 0 (rem 0.3125)
      marginLeft (rem (-0.4375))
      maxWidth fitContent
      ".active" & do
        border (px 1) solid colorActiveBorder
        backgroundColor colorActiveBg
        ev borderRadius (rem 0.3125)

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 119 120. 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 (rem 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
  "white-space" -: "pre-wrap"
  "word-break" -: "break-word" -- 42
".code" & do
  fontMono
  "white-space" -: "pre-wrap"
  "word-break" -: "break-word"
  backgroundColor colorCodeBg
  border (px 1) solid colorCodeBorder
  ev borderRadius (rem 0.25)
  yx padding 0 (em 0.2)
".smallcaps" & do
  fontVariant smallCaps
".subscript" & do
  verticalAlign vAlignSub
".superscript" & do
  verticalAlign vAlignSuper

The word-break stuff 42 is needed to prevent long, non-space-containing monospaced text from becoming unbreakable (such text ruins the mobile viewing experience).

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)
    [ decodeUtf8
        @Text
        @ByteString
        $(embedFile "src/Lilac/WeaveSpec/0300-weave-lists/raw.html")
    ]
🎯 src/Lilac/WeaveSpec/0300-weave-lists/raw.html
<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 indentation
css:List item minimum height
css:List item vertical spacing

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. This means making the list items indented a bit more than regular paragraphs.

css:List item indentation
main_ ? do
  ol ? do
    paddingLeft (rem 2)
  ul ? do
    paddingLeft (rem 2)

9.1.3 Spacing between list items

Reduce paragraph spacing when inside list items.

css:List item vertical spacing
main_ ? do
  ol ? do
    p ? do
      yx margin (rem 0.5) 0
  ul ? do
    p ? do
      yx margin (rem 0.5) 0

9.1.4 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 Blocks

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
    let blockType = fromMaybe "" $ lookup "__block_type" block.meta
    div_ [class_ $ "block-container " <> blockType, id_ linkAnchor] $ do
      div_ [class_ "block-controls"] $ do
        -- 54
        button_
          [ class_ "toggle-block-meta-other",
            data_ "action" "toggle-block-meta-other"
          ]
          $ pure ()
        a_ [href_ $ "#" <> linkAnchor] $ do
          toHtmlRaw (HTML link symbol :: String)
      weaveImportantMetadata block -- 55
      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") -- 56
            | otherwise = ("", "")
      div_ [class_ $ "block-meta-other hidden" <> rawBorder] $ do
        weaveBlockAncestry block
        -- 57
        p_ [] $ do
          toHtml @Text "Other metadata key/value pairs:"
        ul_ [] $ do
          sequence_
            . map weaveBlockMeta
            . filter (\(k, _) -> k `notElem` ["name", "tangle"])
            $ M.toList block.meta
      weaveInlineBlockControls block -- 58
      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 blocks; that is, 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 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 55.

f:weaveImportantMetadata
weaveImportantMetadata :: Block -> HtmlWeaver ()
weaveImportantMetadata block
  | Nothing <- lookup "name" block.meta,
    Nothing <- lookup "tangle" block.meta =
      pure ()
  | otherwise = do
      div_ [class_ "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; " -- 59
                  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

f:weaveImportantMetadata weaves nothing if both the name and tangle paths are missing. 59 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 57 is hidden behind a button press 54. See Block controls 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 57.

f:weaveBlockMeta
weaveBlockMeta :: (Text, Text) -> HtmlWeaver ()
weaveBlockMeta (k, v) = do
  li_ [] $ do
    p_ [] $ do
      span_ [class_ "code"] $ do
        toHtml k
      toHtmlRaw @Text " = "
      span_ [class_ "code"] $ 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.

f:weaveBlockAncestry
weaveBlockAncestry :: Block -> HtmlWeaver ()
weaveBlockAncestry block = case lookup "name" block.meta of
  Nothing -> pure () -- 60
  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 = sortOn getName . 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)
        getName :: (GCI, [GCI], GPath, Block, Prose) -> Text
        getName (_, _, _, b, _) =
          T.toLower . fromMaybe "" $ lookup "name" b.meta
    when (not $ null parents) $ do
      p_ [] $ do
        toHtml @Text "Used by "
        sequence_
          $ intersperse (toHtml @Text ", ")
          $ map weaveParentBlock parents
        toHtml @Text "."

If a block is anonymous (does not have a name) (at 60), 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 a distinction whether a block belongs to the current document being woven.

f:weaveParentBlock
weaveParentBlock ::
  (GCI, [GCI], GPath, Block, Prose) ->
  HtmlWeaver ()
weaveParentBlock (gci, _, objPath, _, name) = do
  weaveConf <- ask
  case getLinkToGCI weaveConf gci of
    Nothing -> pure ()
    Just (docName, linkAnchor, _) -> do
      if objPath /= weaveConf.originObjectPath
        then do
          toHtml docName
          toHtml @Text "→"
          a_ [href_ linkAnchor, class_ "internal-link"]
            $ weaveProse name
        else do
          a_ [href_ $ "#" <> linkAnchor, class_ "internal-link"]
            $ weaveProse name

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 block body area hidden by default, by making f:weaveBlockBody start out in "collapsed" form 64. f:weaveInlineBlockControls weaves the toggle-block-body button to allow the showing (and hiding) of the 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_ $ "block-controls-inline" <> border] $ do
        -- 61
        button_
          [ class_ "toggle-block-body",
            data_ "action" "toggle-block-body"
          ]
          $ pure ()
  | otherwise = pure ()

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

css:Block controls inline
".block-controls-inline" & do
  yx padding (rem 0.25) (rem 0.5)
  border (px 1) solid colorCodeBorder
  fontSans
  button # ".toggle-block-body" ? do
    "::before" & do
      "content" -: "\"Expand contents\"" -- 62
    "border" -: "0"
    "cursor" -: "pointer"
    ev borderRadius (rem 0.125)
    border (px 1) solid colorCodeBorder
  button # ".toggle-block-body.action-collapse" ? do
    "::before" & do
      "content" -: "\"Collapse contents\"" -- 63
".active" ** ".block-controls-inline" ? do
  border (px 1) solid colorActiveBorder

10.4 Body

Finally we come to weaving the body of the 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" -- 64
        | otherwise = ""
  pre_ [class_ $ "block-body" <> bodyBorder <> maybeCollapsed] $ do
    let bodyText = getBlockContentsWithoutLinks block.body
    case lookup "__src_language" block.meta of -- 65
      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 {} -> Nothing
              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 66 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" -- 67
      | lang `elem` ["gitconfig"] = "ini" -- 68
      | otherwise = lang
    defaultTokeniserConfig = Sky.TokenizerConfig Sky.defaultSyntaxMap False

67 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".

68 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 65.

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 69.

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

10.5 CSS

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

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

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

css:Block controls
".block-controls" & do
  padding (rem 0.125) (rem 0.3125) 0 0
  width (rem 3.125)
  textAlign $ alignSide sideRight
  boxSizing borderBox
  top (rem 0)
  bottom (rem 0)
  position absolute
  "grid-column" -: "1"
  "grid-row" -: "1"
  visibility hidden -- 71
  opacity 0 -- 72
  css:Block controls buttons
(".block-container" # hover) |> ".block-controls" ? do
  visibility visible
  opacity 1

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

css:Block controls buttons
a ? do
  display block
a # hover ? do
  textDecoration none
button # ".toggle-block-meta-other" ? do
  "::before" & do
    "content" -: "\"M\""
  "border" -: "0"
  "cursor" -: "pointer"
  ev borderRadius (rem 0.125)
  border (px 1) solid colorCodeBorder

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

  1. .block-meta
    🔗 The name or tangle path. These attributes are so common (and important) that we always display them where possible.
  2. .block-meta-other
    🔗 Other metadata such as the programming language syntax of the block (for source code blocks). These attributes are typically not as interesting, and so require the user to press a button (labeled M) in the .block-controls container to reveal them.

    This also includes the ancestry of the block (links to other blocks that use this block as a child block).

  3. .block-body
    🔗 Main body of the block, which contains the actual code that have been fenced by #+begin_... and #+end_....
css:Block meta
".block-meta" & do
  yx padding 0 (rem 0.5)
  border (px 1) solid colorCodeBorder
  borderBottomWidth 0
  borderTopLeftRadius (rem 0.3125) (rem 0.3125)
  borderTopRightRadius (rem 0.3125) (rem 0.3125)
  backgroundColor colorBg
  fontSans
".active.block-container" ** ".block-meta" ? do
  fontWeight bold
  border (px 1) solid colorActiveBorder
  borderBottomWidth 0
css:Block meta other
".block-meta-other" & do
  p ? do
    yx margin (rem 0.25) 0
  ul ? do
    yx margin (rem 0.5) 0
  yx padding 0 (rem 0.5)
  fontSans
  fontSize $ rem 0.9
  border (px 1) solid colorCodeBorder
  backgroundColor colorBg
  borderBottomWidth 0
  ".hidden" & do
    display displayNone
".active.block-container" ** ".block-meta-other" ? do
  border (px 1) solid colorActiveBorder
  borderBottomWidth 0
css:Block body
pre ? do
  ev margin $ rem 0
code ? do
  ev margin $ rem 0
  a ? do
    roundedRectangleLink
    span ? do
      color colorNowebLinkFg
  "white-space" -: "pre-wrap"
  "word-break" -: "break-word"
".sourceCode" & do
  ev margin $ rem 0
".block-body" & do
  overflowX visible
  fontMono
  fontSize (rem 0.9)
  backgroundColor colorCodeBg
  color colorCodeFg
  borderBottomLeftRadius (rem 0.3125) (rem 0.3125)
  borderBottomRightRadius (rem 0.3125) (rem 0.3125)
  border (px 1) solid colorCodeBorder
  yx padding (rem 0.125) (rem 0.5)
".active.block-container" ** ".block-body" ? do
  border (px 1) solid colorActiveBorder
  backgroundColor colorActiveBg
-- 73
".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
".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 other. See 56.

css:Block rounding
".fully-rounded" & do
  ev borderRadius (rem 0.3125)
".top-rounded" & do
  borderTopLeftRadius (rem 0.3125) (rem 0.3125)
  borderTopRightRadius (rem 0.3125) (rem 0.3125)
-- 74
".bottom-rounded" & do
  borderBottomLeftRadius (rem 0.3125) (rem 0.3125)
  borderBottomRightRadius (rem 0.3125) (rem 0.3125)
-- 75
".hide-border-bottom" & do
  borderBottom (rem 0) solid colorCodeBorder
".active" ** ".hide-border-bottom" ? do
  borderBottom (rem 0) solid colorCodeBorder

10.5.1 Miscellaneous blocks

Lilac recognizes the following blocks other than #+begin_src:

The above get woven with special CSS considerations in css:Misc blocks.

css:Misc blocks
".example .block-body" ? do
  backgroundColor $ colorFrom "#ddeeff"
".example.active .block-body" ? do
  borderColor $ colorFrom "#0000aa"
".example.active .block-controls-inline" ? do
  borderColor $ colorFrom "#0000aa"
".example.active .block-meta-other" ? do
  borderColor $ colorFrom "#0000aa"
".quote .block-body" ? do
  fontStyle italic

Below are examples of these advisory blocks.

This is an example.
This is a quote.

11 Rich blocks

Rich blocks are trickier to weave than regular blocks because we don't have the full block with us; instead we only see the start or end of it at a time. This means we have to weave the outermost DIV manually using toHtmlRaw.

f:weaveRichBlock
weaveRichBlock :: LCI -> RichBlock -> HtmlWeaver ()
weaveRichBlock lci richBlock = case richBlock.marker of
  RBStart -> do
    let linkAnchor = getLinkAnchor Nothing $ CRichBlock richBlock
        richBlockType :: Text
        richBlockType = case lookup "__rich_block_type" richBlock.meta of
          Nothing -> "Note"
          Just word -> T.toTitle word
        recognizedRichBlockType = richBlockType `elem` ["Note", "Warning"]
        openingDivs :: Text
        openingDivs =
          T.concat
            [ "<div id=\"c-",
              T.show lci.unLCI,
              "\" class=\"cell-idx\">",
              "<div id=\"",
              linkAnchor,
              "\" class=\"rich-block-container ",
              fromMaybe "" $ lookup "__rich_block_type" richBlock.meta,
              if recognizedRichBlockType then "" else " unrecognized-rich-block-type",
              "\">"
            ]
    toHtmlRaw openingDivs
    div_ [class_ "rich-block-meta"] $ do
      a_ [href_ $ "#" <> linkAnchor] $ do
        span_ [class_ "rich-block-title"] $ do
          toHtml richBlockType
    toHtmlRaw @Text "<div class=\"rich-block-body\">"
  RBEnd -> toHtmlRaw @Text "</div></div></div>"

Below are some examples of rich blocks (assuming you are reading this in HTML):

This is a note. Bold text.

Table 2. Hello world.
foobar
12

\[2 + 2 = 4\]

List items:

  • foo

  • bar

    1. baz

    2. quux

This is a warning.

baz
echo hello world >/dev/null
echo this is a very long line which is longer than 80 characters, it will make the warning block stick out to the right

This is an "unrecognized" rich block (not "note" or "warning").

11.1 CSS

A rich block is just a thin wrapper around other cells.

css:RichBlocks
css:RichBlock container
css:RichBlock meta
css:RichBlock body
css:RichBlock body unrecognized

The rich-block-container CSS class is the main thin wrapper.

css:RichBlock container
".rich-block-container" & do
  ev borderRadius (rem 0.3125)
  yx margin (rem 1) (rem 0)
  yx padding (rem 0.5) (rem 1)
  minWidth fitContent
  border (px 1) solid colorCodeBorder
  p ? do
    marginBottom $ rem 0.5

The rich-block-meta class stores the title of the block.

css:RichBlock meta
".rich-block-meta" & do
  span # ".rich-block-title" ? do
    fontSans
    fontSize (rem 1.25)
    "font-variant" -: "small-caps"
  a ? do
    color colorText
    ":visited" & do
      color colorText

Now we have the rich block body. This is mostly about the colors of the different types of recognized rich blocks.

css:RichBlock body
".note.rich-block-container" & do
  backgroundColor $ colorFrom "#ffffee"
".note.active.rich-block-container" & do
  backgroundColor $ colorFrom "#ffffcc"
  borderColor $ colorFrom "#aaaa00"
".warning.rich-block-container" & do
  backgroundColor $ colorFrom "#ffeeee"
".warning.active.rich-block-container" & do
  backgroundColor $ colorFrom "#ffdddd"
  borderColor $ colorFrom "#ff3333"

Unrecognized rich block types get highlighted in similar fashion to code blocks.

css:RichBlock body unrecognized
".active.unrecognized-rich-block-type" ? do
  border (px 1) solid colorActiveBorder
  backgroundColor colorActiveBg

12 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

12.1 CSS

css:Figures
figure ? do
  yx margin (rem 1) 0
".figure-image" & do
  display block
  yx margin (rem 0) auto
figcaption ? do
  yx margin (rem 0.625) auto
  yx padding 0 (rem 0.3125)
  textAlign center
  fontSans
  maxWidth (pct 80)
  display displayTable
  border (px 1) solid colorBg
  ev borderRadius (rem 0.3125)
".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 (rem 0.3125)

13 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 <> "\\)"

14 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 76. We also weave the column groups (f:weaveTableColumnGroups) and rows (f:weaveTableRows).

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

The table-wrapper 77 is there to hide a tiny gap when the sticky table header 90 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.

14.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

14.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
      -- 78
      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] -- 79
    getColgroups =
      maybe
        [getMaxCol table]
        ( reverse -- 80
            . foldl' getColgroupSizes [0]
            . getTableCellsFromRow table
        )
        getColgroupsRow
    getColgroupsRow =
      foldl' findSlash Nothing $ getFirstColumnCells table -- 81
      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] -- 82
    getColgroupSizes ns prose = case ns of
      [] -> []
      [0] -> [1] -- 83
      acc@(latest : rest)
        | proseHasColgroupStartMarker prose -> 1 : acc
        | otherwise -> latest + 1 : rest
      where
        proseHasColgroupStartMarker :: Prose -> Bool
        proseHasColgroupStartMarker = proseHas "<" -- 84

We start with getColgroups 79, 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
    _ -> False

We get the row with the slash in it via getColgroupsRow 81, 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 80 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 -- 85

f:tableCellLacks just checks whether the column does not have any of the given marking characters. In 85, 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 82 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] 83 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 92 93, 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 78 to encode the total number of columns.

14.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 -> do
      case row of
        (hasTextAlignMarkers -> True) -> pure ()
        -- 86
        [Prose []] -> do
          tr_ [class_ "table-horizontal-line"] $ do
            forM_ columnNames $ \_ -> do
              td_ [] $ toHtml @Text ""
        _ -> do
          tr_ [class_ "table-body-row"] $ do
            forM_ (zip [1 ..] row) $ \(colNum, prose) -> do
              let tdAttrs
                    -- 87
                    | Just ta <- lookup colNum table.textAlign =
                        case ta of
                          TextAlignLeft -> [class_ "table-cell-text-align-left"]
                          TextAlignCenter -> [class_ "table-cell-text-align-center"]
                          TextAlignRight -> [class_ "table-cell-text-align-right"]
                    -- 88
                    | mostlyNumeric prose = [class_ "table-cell-numeric"]
                    | otherwise = []
              td_ tdAttrs $ weaveProse prose
  where
    getRows rowNums = map (getTableCellsFromRow table) rowNums
    isVisible :: ((Int, Int), Prose) -> Bool -- 89
    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
    hasTextAlignMarkers :: [Prose] -> Bool
    hasTextAlignMarkers = any (isJust . isTextAlignMarker)

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

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 3. Marking characters should be absent if you're reading this from HTML.
Header AHeader BHeader CHeader D
abcd
abcd
foo
bar
baz

14.4 CSS

css:Tables
".table-wrapper" & do
  border (px 2) solid colorCodeBorder
  backgroundColor colorBg
  yx margin (rem 0) auto
  width fitContent
table ? do
  fontSans
  borderCollapse collapse
  th ? do
    position sticky -- 90
    "top" -: "css:navbar-height" -- 91
    backgroundColor colorCodeBg
    yx padding (rem 0.25) (rem 0.5)
  td ? do
    yx padding (rem 0.25) (rem 0.5)
    ".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 (rem 0)
        td ? do
          backgroundColor colorCodeBorder
          yx padding (rem 0) (rem 0)
      ".table-body-row" & do
        borderTop (px 2) solid colorCodeBorder
".table-vertical-thick-line-colgroup" & do
  borderLeft (px 4) solid colorCodeBorder -- 92
".table-vertical-thin-line-col" & do
  -- 93
  borderLeft (px 2) solid colorCodeBorder
".table-cell-text-align-left" & do
  textAlign $ alignSide sideLeft
".table-cell-text-align-center" & do
  textAlign $ alignSide sideCenter
".table-cell-text-align-right" & do
  textAlign $ alignSide sideRight

91 sets the starting position of the header cell (<th>) to the navbar height, so that it stays sticky before it goes under the navbar when scrolling down. IOW, if we don't do this, the navbar will cover up the header cell.

15 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 -- 94
    | otherwise -> pure ()

At 94 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.

15.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 (rem 0)
      yx padding 0 (rem 0)
      yx margin (rem 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 (rem 0.3125)
  marginLeft (rem (-0.4375))
  yx padding 0 (rem 0.3125)
  ".active" & do
    border (px 1) solid colorActiveBorder
    backgroundColor colorActiveBg
    ev borderRadius (rem 0.3125)
  a # ".footnote-backlink" ? do
    display inlineBlock

16 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' -- 95
        | 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 -- 96
  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 -- 97
  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 96 and 97 are for. The isGlossaryTerm helper 95 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

16.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)) -- 98
                    $ M.toList originObj.orgDoc.glossary
            terms' = zip (True : repeat False) terms
        when (terms' /= []) $ toHtmlRaw @Text "<dl class=\"global-glossary\">"
        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 98, 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, -- 99
      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, -- 100
      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 <> "]", -- 101
                          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 99 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, 100 weaves external terms (terms that are defined in another document). These may need disambiguation, so we weave the externalOrgDocName 101. 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 (docName, linkAnchor, cellName), where

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

16.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 104.

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

103 and 105 combine to create a hanging indentation effect (the 1.8125 rem units has within it the extra 1.5 rem units to compensate for the negative margin from 103). This makes it slightly easier to visually scan the entries (this is stolen from the style of typical English dictionaries).

The overall left indent for the <dl> element 102 moves the entire definition list to the right, because otherwise the <dt> elements will start at the end; this is only for the global glossary (from f:weaveGlossary), because glossary terms defined by the user will sit inside a list item, and therefore do not need additional indentation.

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

17 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 106, this function uses the CslDiv constructor to encode information that cannot be represented natively in CslJson such as math fragments 107 and certain text styles 108.

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 ->
        -- 106
        let encoded = CslLink ("LILAC_LINK\NUL" <> unparseLink link) CslEmpty
         in acc <> encoded
      PTokenMathFragment mathFragment ->
        let encoded =
              -- 107
              CslDiv
                ("LILAC_MATH_FRAGMENT\NUL" <> unparseMathFragment mathFragment)
                CslEmpty
         in acc <> encoded
      _ -> 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
      -- 108
      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 -- 109

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 109). 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 -- 110
      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
      _ -> []

The CslNoCase is fine to ignore 110 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.

17.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"

17.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)

18 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_ [id_ "tocs"] $ do
    weaveConf <- ask
    weaveGtoc weaveConf.archive.gtoc
    weaveToc $ map snd lciCellPairs

The width of the TOC area is defined in css:tocs-width. It's reused in css:Layout.

css:tocs-width
16.25
css:tocs
nav # "#tocs" ? do
  "grid-area" -: "tocs"
  "height"
    -: "calc(100vh - css:navbar-height)"
  width $ rem css:tocs-width
  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

18.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
  -- 111
  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 -- 112
  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) -- 113
              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

111 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 113 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 112.

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

18.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 117).

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 114, pop off elements until we see one that's even higher and push this one.

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

    • If current one is lower 115, 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 -- 114
              =
                heading
                  : (dropWhile (\a' -> heading.level <= a'.level) ancestry)
            | heading.level > a.level = heading : ancestry -- 115
            | otherwise = heading : as -- 116
          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 -- 117
      | null ancestry = []
      | otherwise = dropWhile (\a' -> leafHeading.level <= a'.level) ancestry

The ancestorsOf function 117 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
      -- 118
      pageMetricsAncestry
  script_ [id_ "heading-ancestry", type_ "application/json"] $ do
    toHtmlRaw . TLE.decodeUtf8 . encode $ headingAncestry <> pageMetricsAncestry

18.1.2 CSS

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

18.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 122. 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" -- 121
  nav_ [class_ "gtoc"] $ do
    let (Node rootNode forest) = gtoc.unGTOC
    ul_ [] $ do
      weaveGtocForest (Node rootNode [] : forest) -- 122

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 123.

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 -- 123

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 124 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) -- 124
    ]

18.2.1 CSS

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

19 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
    -- 125
    button_
      [ id_ "hamburger",
        class_ "tocs-toggle-button",
        makeAttributes "aria-label" "Toggle table of contents sidebar"
      ]
      $ toHtmlRaw ("&#x2630;" :: Text)
    div_ [class_ "breadcrumbs"] $ do
      weaveBreadcrumbs
css:navbar
div # ".navbar" ? do
  "display" -: "flex"
  "grid-area" -: "navbar"
  height $ rem 3
  "width" -: "100%"
  top $ rem 0
  position fixed
  "z-index" -: "1000"
  borderBottom (px 1) solid colorCodeBorder -- 126
  backgroundColor colorBg
  css:hamburger
  css:breadcrumbs

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

css:navbar-height
3rem

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.

19.1 Hamburger button

The hamburger button to toggle the TOC sidebar 125 is only visible for mobile devices.

css:hamburger
button # ".tocs-toggle-button" ? do
  display displayNone

The JS that handles the toggling behavior is at Hamburger icon (toggle TOC sidebar).

19.2 Breadcrumbs

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 127

  2. (optional) project version 128

  3. middle 131

  4. Org document name 134

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 -- 127
  weaveProjectVersion weaveConf.projectConf -- 128
  let breadcrumbsMiddle =
        (\parts -> take (length parts - 2) (drop 1 parts)) -- 129
          . inits -- 130
          . P.splitDirectories
          . P.unsafeEncodeUtf
          $ decodeGPath weaveConf.originObjectPath
      breadcrumbLast =
        osPathDecoder
          . flip P.replaceExtensions (P.unsafeEncodeUtf "org")
          . P.takeFileName
          . P.unsafeEncodeUtf
          $ decodeGPath weaveConf.originObjectPath
  -- 131
  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") -- 132
    weaveBreadcrumbSeparator
    if
      -- 133
      | 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
  -- 134
  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 131 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 130) like

a
a/b
a/b/c

for an origin object at path a/b/c/foo.lobj. 129 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 132, and then do lookups into the object map 133. 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.

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 (rem 0.375) 0 0 (rem 0.625)
span # ".breadcrumb-separator" ? do
  color colorBreadcrumbSeparator

20 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 111, 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 118:

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"])
    ]

20.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 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

20.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 -- 141
      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 141.

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

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

20.5 CSS

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

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 (rem 2.5)
    marginBottom (rem 0.625)

21 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.

21.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.FileEmbed (embedFile)
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),
    ByteString,
    Either (Left, Right),
    FilePath,
    IO,
    Maybe (Just, Nothing),
    String,
    Text,
    decodeUtf8,
    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

21.2 All test cases

22 Glossary

#+begin_example
Contains examples.
#+begin_quote
Quotations. Meant to be verbatim text from another source.
.block-body
Main body of the block, which contains the actual code that have been fenced by #+begin_... and #+end_....
.block-meta
The name or tangle path. These attributes are so common (and important) that we always display them where possible.
.block-meta-other
Other metadata such as the programming language syntax of the block (for source code blocks). These attributes are typically not as interesting, and so require the user to press a button (labeled M) in the .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 (19)

  1. src/Lilac/Weave.hs
  2. src/Lilac/Weave/Css.hs
  3. src/Lilac/Weave/Css/external-link.svg
  4. src/Lilac/Weave/Css/raw.css
  5. src/Lilac/Weave/defaultMathJaxSettings.js
  6. src/Lilac/WeaveSpec/0300-weave-lists/raw.html
  7. test/Lilac/WeaveSpec.hs
  8. test/Lilac/WeaveSpec/0300-weave-lists
  9. test/Lilac/WeaveSpec/0300-weave-styled-text
  10. test/Lilac/WeaveSpec/0400-citeproc-basic
  11. test/Lilac/WeaveSpec/0400-citeproc-multiline
  12. test/Lilac/WeaveSpec/0400-citeproc-multiline-cell-boundary
  13. test/Lilac/WeaveSpec/0400-citeproc-multiline-cell-boundary-list-item
  14. test/Lilac/WeaveSpec/0400-citeproc-multiline-complex
  15. test/Lilac/WeaveSpec/0400-citeproc-multiline-complex-list-item
  16. test/Lilac/WeaveSpec/0400-citeproc-smallcaps
  17. test/Lilac/WeaveSpec/0400-citeproc-style-variations
  18. test/Lilac/WeaveSpec/0400-citeproc-superscript
  19. test/Lilac/WeaveSpec/bib.json

Named cells (229)

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