Repository: typst/hayagriva (rust)

Issue:
Support for Citeproc bibliography tests
Currently, all tests of the citeproc test suite that test the bibliography are ignored. Many important features are therefore untested. This should either be fixed or (if there are some issues I am unaware of) a comparable test suite for hayagriva should be build.

Are there any problems preventing support for Bibliography mode?

Pull request:
Support bibliography citeproc tests
Fixes #229.

As a drive-by, affixes around bibliography `<layout>` are now included in the output. (This should be the only visible change of this PR to users.)

Initial inspiration was taken from #228, and includes its drive-by fix of affixes in bibliography layout, but the approach taken for test comparison is entirely different: I implemented code to generate HTML similar to that generated by citeproc for the citeproc tests. The generated HTML is not indented, and so indentation in the original test result is stripped for comparison.

## Future work

This revealed a bug, it appears that formatting is always being applied to affixes by hayagriva (cf `sort_VariousNameMacros1.txt`, where bold is being applied to spaces around a bold group), but this can be fixed later.

Also, there are various test failures to investigate, for example `"bugreports_parenthesis` seems to have some extraneous spacing to the left on hayagriva's output, which is weird. Perhaps citeproc is compressing two consecutive spaces in a way we didn't think of.

In addition, I considered moving affix application to `layout::render` to apply it to anywhere else using bibliography, but it appears this is already manually handled by citation, so this breaks things.

## Scripts used

Nushell file (`parse.nu`):

```nu
# Converts citeproc test in stdin into record with one column per section:
# { MODE: "citation | bibliography | ...", RESULT: "...", ... }
def parse-citeproc-test [] {
  parse --regex '(?:>>=+ (?P<section>[\w\-]+) =+>>
(?P<body>[\s\S]+?)
<<=+ [\w\-]+ =+<<)' | transpose --header-row -d
}

# Parse all tests in a folder (or current folder)
# Returns a table with columns name (test filename), MODE (citation / bibliography), RESULT, ...
def parse-citeproc-test-dir [dir: string = "."] {
  ls $dir | filter { $in.type == "file" } | get name | each { |name| { name: $name, results: ($"(open --raw $name)" | parse-citeproc-test) } } | flatten
}

# Converts parsed XML or HTML (from '$row.RESULT | from xml', which is a good approximation for our case)
# into a flattened list of element tag / attribute / text information. 
# Good to extract all kinds of elements.
def html-flattener []: record<tag: string, attributes: record, content: any> -> list<record<tag: string, attributes: record, text: any>> {
  let rec = $in
  mut queue = [$rec]
  mut results = []
  while $queue != [] {
    let next = $queue | last
    $queue = $queue | drop
    let desc = $next.content | describe

    let text = if $desc == "string" { $next.content }
    let attributes = if $next.attributes == null { {} } else { $next.attributes }
    $results = $results | append {tag: $next.tag, attributes: $attributes, text: $text}

    if $desc != "nothing" and $desc != "string" {
      # Content is a list of elements
      $queue = $queue | append $next.content
    }
  }

  return $results
}

# Receives a result from 'parse-citeproc-test-dir' and returns parsed bibliography tests only.
# The parsed_res column contains parsed HTML.
# The flattened_res column contains the flattened parsed HTML (from html-flattener), which is a list
# of element tag / attributes / text.
def parse-citeproc-bib-tests []: table -> table { 
  filter { $in.MODE? == "bibliography" }
  | insert parsed_res { if $in.RESULT? != null { $in.RESULT | from xml } }
  | insert flattened_res { if $in.parsed_res? == null { [] } else { get parsed_res | html-flattener } }
}
```

You can use this at `processor-tests/humans` with

```nu
source parse.nu
let parsed_bibs = parse-citeproc-test-dir | parse-citeproc-bib-tests
```
Which provides a table with columns `name` (filename), `MODE` (citation / bibliography), `RESULT` (test result), ..., as well as `parsed_res` (`RESULT` parsed as HTML (actually XML, but here that works) into a table) and `flattened_res` (flattened list of HTML element information)

The command below provides the list of HTML elements used:

```nu
$parsed_bibs.flattened_res | flatten | filter { $in.tag != null } | uniq
```

<details><summary>Output</summary>
<p>

```
╭────┬──────┬──────────────────────────────────────┬──────╮
│  # │ tag  │              attributes              │ text │
├────┼──────┼──────────────────────────────────────┼──────┤
│  0 │ div  │ ╭───────┬──────────────╮             │      │
│    │      │ │ class │ csl-bib-body │             │      │
│    │      │ ╰───────┴──────────────╯             │      │
│  1 │ div  │ ╭───────┬───────────╮                │      │
│    │      │ │ class │ csl-entry │                │      │
│    │      │ ╰───────┴───────────╯                │      │
│  2 │ i    │ {record 0 fields}                    │      │
│  3 │ span │ ╭───────┬──────────────────────────╮ │      │
│    │      │ │ style │ font-variant:small-caps; │ │      │
│    │      │ ╰───────┴──────────────────────────╯ │      │
│  4 │ div  │ ╭───────┬──────────────────╮         │      │
│    │      │ │ class │ csl-right-inline │         │      │
│    │      │ ╰───────┴──────────────────╯         │      │
│  5 │ b    │ {record 0 fields}                    │      │
│  6 │ div  │ ╭───────┬─────────────────╮          │      │
│    │      │ │ class │ csl-left-margin │          │      │
│    │      │ ╰───────┴─────────────────╯          │      │
│  7 │ div  │ ╭───────┬───────────╮                │      │
│    │      │ │ class │ csl-block │                │      │
│    │      │ ╰───────┴───────────╯                │      │
│  8 │ sup  │ {record 0 fields}                    │      │
│  9 │ span │ ╭───────┬──────────╮                 │      │
│    │      │ │ style │ baseline │                 │      │
│    │      │ ╰───────┴──────────╯                 │      │
│ 10 │ div  │ ╭───────┬────────────╮               │      │
│    │      │ │ class │ csl-indent │               │      │
│    │      │ ╰───────┴────────────╯               │      │
│ 11 │ span │ ╭───────┬────────────────────╮       │      │
│    │      │ │ style │ font-style:normal; │       │      │
│    │      │ ╰───────┴────────────────────╯       │      │
│ 12 │ span │ ╭───────┬──────────────────────╮     │      │
│    │      │ │ style │ font-variant:normal; │     │      │
│    │      │ ╰───────┴──────────────────────╯     │      │
│ 13 │ sub  │ {record 0 fields}                    │      │
╰────┴──────┴──────────────────────────────────────┴──────╯
```

</p>
</details> 

## TODO
- [x] Fix some bugs and differences on some failing tests (e.g. no formatting when the text is just whitespace...)
   - Actually, this formatting difference (from the `sort_VariousNameMacros1.txt` test) is probably a bug in hayagriva and best fixed separately. I think the problem is that formatting is being applied to affixes when it shouldn't.
- [x] Share some of the scripts and commands I used perhaps

Patch:
diff --git a/Cargo.toml b/Cargo.toml
index 4728f4f..4e9bd30 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -36,6 +36,7 @@ strum = { version = "0.26", features = ["derive"], optional = true }
 [dev-dependencies]
 heck = "0.5"
 serde_json = "1"
+regex = "1"
 
 [[bin]]
 name = "hayagriva"
diff --git a/src/csl/mod.rs b/src/csl/mod.rs
index 8ac6a33..2d587f9 100644
--- a/src/csl/mod.rs
+++ b/src/csl/mod.rs
@@ -15,7 +15,7 @@ use citationberg::{
     taxonomy as csl_taxonomy, Affixes, BaseLanguage, Citation, CitationFormat, Collapse,
     CslMacro, Display, GrammarGender, IndependentStyle, InheritableNameOptions, Layout,
     LayoutRenderingElement, Locale, LocaleCode, Names, SecondFieldAlign, StyleCategory,
-    StyleClass, TermForm, ToFormatting,
+    StyleClass, TermForm, ToAffixes, ToFormatting,
 };
 use citationberg::{DateForm, LongShortForm, OrdinalLookup, TextCase};
 use indexmap::IndexSet;
@@ -1386,7 +1386,14 @@ impl<'a> StyleContext<'a> {
         let mut ctx = self.ctx(entry, props, locale, term_locale, true);
         ctx.writing
             .push_name_options(&self.csl.bibliography.as_ref()?.name_options);
-        self.csl.bibliography.as_ref()?.layout.render(&mut ctx);
+
+        let layout = &self.csl.bibliography.as_ref()?.layout;
+        let affixes = layout.to_affixes();
+
+        let affix_loc = ctx.apply_prefix(&affixes);
+        layout.render(&mut ctx);
+        ctx.apply_suffix(&affixes, affix_loc);
+
         Some(ctx)
     }
 


Test patch:
diff --git a/tests/citeproc-pass.txt b/tests/citeproc-pass.txt
index 3619e67..4346931 100644
--- a/tests/citeproc-pass.txt
+++ b/tests/citeproc-pass.txt
@@ -8,14 +8,25 @@ affix_InterveningEmpty
 affix_TextNodeWithMacro
 bugreports_Abnt
 bugreports_ArabicLocale
+bugreports_AsaSpacing
 bugreports_AuthorYear
 bugreports_BadCitationUpdate
 bugreports_ChineseCharactersFamilyOnlyPluralLabel
 bugreports_ContextualPluralWithMainItemFields
+bugreports_DisambiguationAddNamesBibliography
 bugreports_EmptyIfMatchNoneFail
+bugreports_MatchedAuthorAndDate
+bugreports_NoEventInNestedMacroWithOldProcessor
 bugreports_SectionAndLocator
+bugreports_SimpleBib
 bugreports_SingletonIfMatchNoneFail
+bugreports_SortSecondaryKeyBibliography
+bugreports_StyleError001
 bugreports_TitleCase
+bugreports_UndefinedInName2
+bugreports_UndefinedNotString
+bugreports_UndefinedStr
+bugreports_UnisaHarvardInitialization
 bugreports_YearSuffixLingers
 bugreports_disambiguate
 bugreports_effingBug
@@ -26,6 +37,7 @@ collapse_AuthorCollapseNoDate
 collapse_CitationNumberRangesMixed
 collapse_CitationNumberRangesMixed2
 collapse_CitationNumberRangesMixed3
+collapse_CitationNumberRangesOneOnly
 collapse_NumericDuplicate
 collapse_NumericDuplicate2
 collapse_TrailingDelimiter
@@ -40,18 +52,21 @@ condition_NameAndTextVars
 condition_NumberIsNumeric
 condition_NumeralIsNumeric
 condition_NumeralWithTextIsNumeric
+condition_RefTypeBranching
 condition_SingletonIfMatchNone
 condition_TextIsNotNumeric
 condition_VariableAll
 condition_VariableAny
 condition_VariableNone
 date_Accessed
+date_AccessedCrash
 date_DateAD
 date_DateNoDateWithTest
 date_DisappearingBug
 date_EmptyStrings
 date_IgnoreNonexistentSort
 date_January
+date_KeyVariable
 date_LiteralFailGracefullyIfNoValue
 date_LocalizedDateFormats-af-ZA
 date_LocalizedDateFormats-ar-AR
@@ -121,21 +136,29 @@ disambiguate_DifferentSpacingInInitials
 disambiguate_DisambiguateTrueAndYearSuffixOne
 disambiguate_FailWithYearSuffix
 disambiguate_FamilyNameOnly
+disambiguate_HonorFullnameInBibliography
+disambiguate_ImplicitYearSuffixOnceOnly
 disambiguate_LastOnlyFailWithByCite
 disambiguate_NoTextElementUsesYearSuffixVariable
 disambiguate_PrimaryNameWithNonDroppingParticle
+disambiguate_ThreeNoAuthorNoTitleEntries
 disambiguate_WithOriginalYear
 disambiguate_YearCollapseWithInstitution
 disambiguate_YearSuffixMacroSameYearExplicit
 disambiguate_YearSuffixMacroSameYearImplicit
 disambiguate_YearSuffixWithEtAlSubsequent
+display_DisplayBlock
+etal_CitationAndBibliographyDecorationsInBibliography
 flipflop_OrphanQuote
 form_TitleShort
 form_TitleShortNoLong
 form_TitleTestNoLongFalse
+fullstyles_ABdNT
 fullstyles_APA
+fullstyles_ChicagoNoteWithBibliographyWithPublisher
 group_ShortOutputOnly
 group_SuppressValueWithEmptySubgroup
+group_SuppressWithEmptyNestedDateNode
 integration_CitationSort
 integration_CitationSortTwice
 label_CompactNamesAfterFullNames
@@ -193,9 +216,12 @@ name_CeltsAndToffsWithHyphens
 name_CiteGroupDelimiterWithYearCollapse
 name_CollapseRoleLabels
 name_Delimiter
+name_EditorTranslatorBoth
 name_EditorTranslatorSameEmptyTerm
 name_EditorTranslatorSameWithTerm
+name_EditorTranslatorWithTranslatorOnlyBib
 name_EtAlKanji
+name_EtAlUseLast
 name_FirstInitialFullForm
 name_FormattingOfParticles
 name_GreekSimple
@@ -207,6 +233,7 @@ name_InstitutionDecoration
 name_LabelAfterPlural
 name_LabelAfterPluralDecorations
 name_LabelFormatBug
+name_LiteralWithComma
 name_MultipleLiteral
 name_NoNameNode
 name_NonDroppingParticleDefault
@@ -231,48 +258,98 @@ name_WesternTwoAuthors
 name_WithNonBreakingSpace
 name_namepartAffixesNameAsSortOrder
 name_namepartAffixesNameAsSortOrderDemoteNonDroppingParticle
+nameattr_AndOnBibliographyInBibliography
 nameattr_AndOnBibliographyInCitation
+nameattr_AndOnCitationInBibliography
 nameattr_AndOnCitationInCitation
+nameattr_AndOnNamesInBibliography
 nameattr_AndOnNamesInCitation
+nameattr_AndOnStyleInBibliography
+nameattr_AndOnStyleInCitation
+nameattr_DelimiterPrecedesEtAlOnBibliographyInBibliography
 nameattr_DelimiterPrecedesEtAlOnBibliographyInCitation
+nameattr_DelimiterPrecedesEtAlOnCitationInBibliography
 nameattr_DelimiterPrecedesEtAlOnCitationInCitation
+nameattr_DelimiterPrecedesEtAlOnNamesInBibliography
 nameattr_DelimiterPrecedesEtAlOnNamesInCitation
+nameattr_DelimiterPrecedesEtAlOnStyleInBibliography
 nameattr_DelimiterPrecedesEtAlOnStyleInCitation
+nameattr_DelimiterPrecedesLastOnBibliographyInBibliography
 nameattr_DelimiterPrecedesLastOnBibliographyInCitation
+nameattr_DelimiterPrecedesLastOnCitationInBibliography
 nameattr_DelimiterPrecedesLastOnCitationInCitation
+nameattr_DelimiterPrecedesLastOnNamesInBibliography
 nameattr_DelimiterPrecedesLastOnNamesInCitation
+nameattr_DelimiterPrecedesLastOnStyleInBibliography
 nameattr_DelimiterPrecedesLastOnStyleInCitation
+nameattr_EtAlMinOnBibliographyInBibliography
 nameattr_EtAlMinOnBibliographyInCitation
+nameattr_EtAlMinOnCitationInBibliography
 nameattr_EtAlMinOnCitationInCitation
+nameattr_EtAlMinOnNamesInBibliography
 nameattr_EtAlMinOnNamesInCitation
+nameattr_EtAlMinOnStyleInBibliography
 nameattr_EtAlMinOnStyleInCitation
+nameattr_EtAlSubsequentMinOnBibliographyInBibliography
 nameattr_EtAlSubsequentMinOnBibliographyInCitation
+nameattr_EtAlSubsequentMinOnCitationInBibliography
+nameattr_EtAlSubsequentMinOnNamesInBibliography
+nameattr_EtAlSubsequentMinOnStyleInBibliography
+nameattr_EtAlSubsequentUseFirstOnBibliographyInBibliography
 nameattr_EtAlSubsequentUseFirstOnBibliographyInCitation
+nameattr_EtAlSubsequentUseFirstOnCitationInBibliography
+nameattr_EtAlSubsequentUseFirstOnStyleInBibliography
+nameattr_EtAlUseFirstOnBibliographyInBibliography
 nameattr_EtAlUseFirstOnBibliographyInCitation
+nameattr_EtAlUseFirstOnCitationInBibliography
 nameattr_EtAlUseFirstOnCitationInCitation
+nameattr_EtAlUseFirstOnNamesInBibliography
 nameattr_EtAlUseFirstOnNamesInCitation
+nameattr_EtAlUseFirstOnStyleInBibliography
 nameattr_EtAlUseFirstOnStyleInCitation
+nameattr_InitializeWithOnBibliographyInBibliography
 nameattr_InitializeWithOnBibliographyInCitation
+nameattr_InitializeWithOnCitationInBibliography
 nameattr_InitializeWithOnCitationInCitation
+nameattr_InitializeWithOnNamesInBibliography
 nameattr_InitializeWithOnNamesInCitation
+nameattr_InitializeWithOnStyleInBibliography
 nameattr_InitializeWithOnStyleInCitation
+nameattr_NameAsSortOrderOnBibliographyInBibliography
 nameattr_NameAsSortOrderOnBibliographyInCitation
+nameattr_NameAsSortOrderOnCitationInBibliography
 nameattr_NameAsSortOrderOnCitationInCitation
+nameattr_NameAsSortOrderOnNamesInBibliography
 nameattr_NameAsSortOrderOnNamesInCitation
+nameattr_NameAsSortOrderOnStyleInBibliography
 nameattr_NameAsSortOrderOnStyleInCitation
+nameattr_NameDelimiterOnBibliographyInBibliography
 nameattr_NameDelimiterOnBibliographyInCitation
+nameattr_NameDelimiterOnCitationInBibliography
 nameattr_NameDelimiterOnCitationInCitation
+nameattr_NameDelimiterOnNamesInBibliography
 nameattr_NameDelimiterOnNamesInCitation
+nameattr_NameDelimiterOnStyleInBibliography
 nameattr_NameDelimiterOnStyleInCitation
+nameattr_NameFormOnBibliographyInBibliography
 nameattr_NameFormOnBibliographyInCitation
+nameattr_NameFormOnCitationInBibliography
 nameattr_NameFormOnCitationInCitation
+nameattr_NameFormOnNamesInBibliography
 nameattr_NameFormOnNamesInCitation
+nameattr_NameFormOnStyleInBibliography
 nameattr_NameFormOnStyleInCitation
 nameattr_NamesDelimiterOnBibliographyInCitation
+nameattr_NamesDelimiterOnCitationInBibliography
+nameattr_NamesDelimiterOnNamesInBibliography
 nameattr_NamesDelimiterOnNamesInCitation
+nameattr_SortSeparatorOnBibliographyInBibliography
 nameattr_SortSeparatorOnBibliographyInCitation
+nameattr_SortSeparatorOnCitationInBibliography
 nameattr_SortSeparatorOnCitationInCitation
+nameattr_SortSeparatorOnNamesInBibliography
 nameattr_SortSeparatorOnNamesInCitation
+nameattr_SortSeparatorOnStyleInBibliography
 nameattr_SortSeparatorOnStyleInCitation
 nameorder_Long
 nameorder_LongNameAsSortDemoteDisplayAndSort
@@ -281,6 +358,7 @@ nameorder_Short
 nameorder_ShortDemoteDisplayAndSort
 nameorder_ShortNameAsSortDemoteNever
 namespaces_NonNada3
+number_FailingDelimiters
 number_IsNumericWithAlpha
 number_MixedPageRange
 number_PageFirst
@@ -296,28 +374,48 @@ page_NumberPageFirst
 page_PluralDetectWithEndash
 page_WithLocaleAndWeirdDelimiter
 plural_LabelForced
+position_IbidWithSuffix
 position_NearNoteUnsupported
 position_TrueInCitation
 punctuation_DateStripPeriods
+punctuation_DelimiterWithStripPeriodsAndSubstitute1
+punctuation_DelimiterWithStripPeriodsAndSubstitute2
+punctuation_DelimiterWithStripPeriodsAndSubstitute3
 punctuation_DoNotSuppressColonAfterPeriod
 punctuation_NoSuppressOfPeriodBeforeSemicolon
+punctuation_SemicolonDelimiter
+quotes_Punctuation
+quotes_PunctuationNasty
+sort_BibliographyResortOnUpdate
+sort_CaseInsensitiveBibliography
 sort_CaseInsensitiveCitation
 sort_Citation
+sort_CitationNumberPrimaryAscendingViaMacroBibliography
+sort_CitationNumberPrimaryAscendingViaVariableBibliography
 sort_CitationSecondaryKey
 sort_CiteGroupDelimiter
+sort_DaleDalebout
+sort_DateMacroSortWithSecondFieldAlign
 sort_DateVariable
 sort_DateVariableMixedElementsAscendingA
 sort_DateVariableMixedElementsAscendingB
 sort_DateVariableMixedElementsDescendingA
 sort_DateVariableMixedElementsDescendingB
+sort_EtAlUseLast
+sort_FamilyOnly
 sort_LatinUnicode
 sort_LocalizedDateLimitedParts
+sort_NameParticleInNameSortFalse
+sort_NameParticleInNameSortTrue
+sort_NamesUseLast
+sort_StatusFieldDescending
 sort_TestInheritance
 sortseparator_SortSeparatorEmpty
 substitute_RepeatedNamesOk
 substitute_SubstituteOnlyOnceString
 substitute_SubstituteOnlyOnceTerm
 substitute_SubstituteOnlyOnceVariable
+substitute_SuppressOrdinaryVariable
 textcase_AfterQuote
 textcase_CapitalsUntouched
 textcase_StopWordBeforeHyphen
diff --git a/tests/citeproc.rs b/tests/citeproc.rs
index 0f1f10e..b3f09ce 100644
--- a/tests/citeproc.rs
+++ b/tests/citeproc.rs
@@ -3,6 +3,7 @@
 use std::io::{BufWriter, Write};
 use std::path::PathBuf;
 use std::str::FromStr;
+use std::sync::OnceLock;
 use std::{fmt, fs};
 
 mod common;
@@ -491,19 +492,17 @@ where
                 .map_or(false, |d| d.end.is_some())
         });
 
-    if case.mode == TestMode::Bibliography {
-        if print {
-            eprintln!("Skipping test {}\t(cause: Bibliography mode)", display());
-        }
-        false
-    } else if !can_test {
+    if !can_test {
         if print {
             eprintln!("Skipping test {}\t(cause: unsupported test feature)", display());
         }
         false
-    } else if case.result.contains('<') {
+    } else if case.mode != TestMode::Bibliography && case.result.contains('<') {
         if print {
-            eprintln!("Skipping test {}\t(cause: HTML suspected)", display());
+            eprintln!(
+                "Skipping test {}\t(cause: HTML suspected in citation result)",
+                display()
+            );
         }
         false
     } else if contains_date_ranges {
@@ -579,15 +578,39 @@ where
 
     let rendered = driver.finish(BibliographyRequest::new(&style, None, locales));
 
-    for citation in rendered.citations {
-        citation
-            .citation
-            .write_buf(&mut output, hayagriva::BufWriteFormat::Plain)
-            .unwrap();
-        output.push('\n');
-    }
+    let formatted_result = match case.mode {
+        TestMode::Citation => {
+            for citation in rendered.citations {
+                citation
+                    .citation
+                    .write_buf(&mut output, hayagriva::BufWriteFormat::Plain)
+                    .unwrap();
+                output.push('\n');
+            }
 
-    if output.trim() == case.result.trim() {
+            case.result.trim()
+        }
+        TestMode::Bibliography => {
+            static INDENT_REGEX: OnceLock<regex::Regex> = OnceLock::new();
+
+            let bib = rendered
+                .bibliography
+                .expect("Bibliography mode test but no bibliography was rendered");
+            citeproc_bib::render(&bib, &mut output).unwrap();
+            output.push('\n');
+
+            // Remove indentation from original result to match our own output,
+            // which is not indented or pretty-printed. It appears that
+            // citeproc test results simply indent elements with two spaces at
+            // the start when the line gets too long, or for each inner bib
+            // entry.
+            &*INDENT_REGEX
+                .get_or_init(|| regex::Regex::new(r#"\n\s*"#).unwrap())
+                .replace_all(case.result.trim(), "")
+        }
+    };
+
+    if output.trim() == formatted_result {
         true
     } else {
         eprintln!("Test {} failed", display());
@@ -597,6 +620,161 @@ where
     }
 }
 
+/// Functions to format bibliography rendered by hayagriva using citeproc's HTML
+/// output format, in order to be able to compare the generated HTML.
+mod citeproc_bib {
+    use core::fmt;
+
+    use citationberg::{Display, FontStyle, FontVariant, FontWeight, VerticalAlign};
+    use hayagriva::{BufWriteFormat, Elem, ElemChild, Formatting};
+
+    pub(super) fn render(
+        bib: &hayagriva::RenderedBibliography,
+        output: &mut String,
+    ) -> Result<(), fmt::Error> {
+        output.push_str(r#"<div class="csl-bib-body">"#);
+        for item in &bib.items {
+            render_item(item, output)?;
+        }
+        output.push_str("</div>");
+        Ok(())
+    }
+
+    fn render_item(
+        item: &hayagriva::BibliographyItem,
+        output: &mut String,
+    ) -> Result<(), fmt::Error> {
+        let mut second_field_align_suffix = "";
+        output.push_str(r#"<div class="csl-entry">"#);
+        if let Some(field) = &item.first_field {
+            // Uses 'second-field-align', so add implicit alignment
+            // (cf. test bugreports_AsmJournals.txt)
+            output.push_str("<div class=\"csl-left-margin\">");
+            render_child(field, output)?;
+            output.push_str("</div><div class=\"csl-right-inline\">");
+            second_field_align_suffix = "</div>";
+        }
+        for child in &item.content.0 {
+            render_child(child, output)?;
+        }
+        output.push_str(second_field_align_suffix);
+        output.push_str("</div>");
+        Ok(())
+    }
+
+    fn render_child(child: &ElemChild, output: &mut String) -> Result<(), fmt::Error> {
+        match child {
+            ElemChild::Text(formatted) => render_formatted_text(formatted, output),
+            ElemChild::Elem(e) => render_elem(e, output),
+
+            // Citeproc bib tests do not output <a href=...></a> for links
+            ElemChild::Link { text, url: _ } => render_formatted_text(text, output),
+            elem => elem.write_buf(output, BufWriteFormat::Html),
+        }
+    }
+
+    /// Applies the appropriate HTML tags to formatted text, according to
+    /// citeproc test output.
+    ///
+    /// Note that citeproc (csl-json) input accepts
+    /// '<span class="nodecor">...</span>' within text to nullify outer
+    /// formatting, for example apply 'font-style:normal' inside bold and
+    /// italics (see flipflop_ItalicsWithOk), or 'font-variant:normal' inside
+    /// smallcaps (see flipflop_ItalicsWithOkAndTextcase). We do not support
+    /// this notation, especially since we do not expose csl-json input to
+    /// hayagriva users anyway.
+    fn render_formatted_text(
+        text: &hayagriva::Formatted,
+        output: &mut String,
+    ) -> Result<(), fmt::Error> {
+        let formatting = text.formatting;
+        if formatting == Formatting::default() {
+            output.push_str(&text.text);
+            return Ok(());
+        }
+
+        // NOTE: There are no tests with multiple CSS styles, so for now we
+        // join them without any spacing.
+        let mut css = String::new();
+        let mut suffix = String::new();
+        let mut push_elem = |start, end| {
+            output.push_str(start);
+            suffix.insert_str(0, end);
+        };
+
+        match formatting.vertical_align {
+            VerticalAlign::Sub => {
+                push_elem("<sub>", "</sub>");
+            }
+            VerticalAlign::Sup => {
+                push_elem("<sup>", "</sup>");
+            }
+            VerticalAlign::Baseline => {
+                push_elem(r#"<span style="baseline">"#, "</span>");
+            }
+            VerticalAlign::None => {}
+        }
+
+        match formatting.font_weight {
+            FontWeight::Bold => {
+                push_elem("<b>", "</b>");
+            }
+            FontWeight::Light => {
+                // NOTE: This is not used in any tests, so we can only assume
+                // this is done through this CSS style for now.
+                css.push_str("font-weight:lighter;");
+            }
+            FontWeight::Normal => {}
+        }
+
+        match formatting.font_style {
+            FontStyle::Italic => {
+                // NOTE: This has to come after (be inside) bold
+                // (Citeproc tests use <b><i>...</i></b> when both are present)
+                push_elem("<i>", "</i>")
+            }
+            FontStyle::Normal => {}
+        }
+
+        match formatting.font_variant {
+            FontVariant::SmallCaps => css.push_str("font-variant:small-caps;"),
+            FontVariant::Normal => {}
+        }
+
+        if !css.is_empty() {
+            push_elem(&format!("<span style=\"{css}\">"), "</span>");
+        }
+
+        output.push_str(&text.text);
+        output.push_str(&suffix);
+        Ok(())
+    }
+
+    fn render_elem(elem: &Elem, output: &mut String) -> Result<(), fmt::Error> {
+        let mut div_suffix = "";
+        if let Some(display) = elem.display {
+            div_suffix = "</div>";
+            let div_class = match display {
+                Display::Block => "csl-block",
+                Display::LeftMargin => "csl-left-margin",
+                Display::RightInline => "csl-right-inline",
+                Display::Indent => "csl-indent",
+            };
+
+            output.push_str(&format!("<div class=\"{div_class}\">"));
+        }
+
+        for child in &elem.children.0 {
+            render_child(child, output)?;
+        }
+
+        if !div_suffix.is_empty() {
+            output.push_str(div_suffix);
+        }
+        Ok(())
+    }
+}
+
 #[test]
 fn purposes() {
     let style = ArchivedStyle::by_name("apa").unwrap().get();
@@ -723,7 +901,7 @@ fn case_folding() {
         .content
         .write_buf(&mut buf, hayagriva::BufWriteFormat::Plain)
         .unwrap();
-    assert_eq!(buf, ". my lowercase container title");
+    assert_eq!(buf, ". my lowercase container title.");
 }
 
 #[test]