diff options
| author | Ralph Amissah <ralph.amissah@gmail.com> | 2026-09-27 15:39:02 -0400 |
|---|---|---|
| committer | Ralph Amissah <ralph.amissah@gmail.com> | 2026-09-27 15:39:02 -0400 |
| commit | 8d03e44a28c3d1de7d59b07f5e7f611cde0c38c6 (patch) | |
| tree | 9db9f5c1e0ce10542c9bde49182ab53e0fd9187c /gleam/test/ocda_write_test.gleam | |
format 2.0: sample writers over the abstraction, in D and in Gleam
Writers over sisudoc-ocda-lib: functions from the document model to
bytes, in five formats, one program, ocda-write, in each language.
Begun 2026-09-25; this is the writer as it stood at format 2.0.
ocda-write --text|--md|--html|--epub|--typst <file> [lang]
They are samples. Spine is the output pipeline and nothing here
replaces it. What they show is that the abstraction is sufficient:
given a .ssp, a .ocda.db or a .sisupod and nothing else, a few hundred
lines per format make a complete document. A writer is never told
which form it was given, and every writer is run against every form
of every document.
Each format is held to an oracle that can say no:
--text the words equal the html writer's objects, as a multiset;
every object number exactly once
--md CommonMark; every (#x) resolves to an <a id=x>, no id twice
--html it parses; every internal link resolves; the objects' text
equals spine's scroll html
--epub epubcheck
--typst typst itself: a link to a missing label does not compile
and in every format the same bytes from every input form, and the D
and Gleam writers byte for byte alike. Over the markup document
samples every check holds for every document-language. The two
languages' writers were not written from each other, and their
agreement is the strongest check: twice it caught what nothing else
did (an ampersand in an attribute mangled by Erlang's replacement
string; typst label names built from bytes in one language and
graphemes in the other).
Byte identity with spine is not the aim, by decision. The text writer
began byte identical to spine --text, and was rewritten: a note is
written once, markup comments and dummy headings are not written, an
image keeps its caption, a table is padded to display width. The html
writer is valid html5 rather than a copy of spine's (h1..h6 where
spine writes <h0>; one charset; no placeholder metadata; an id for
every anchor tag; self-contained). Each difference is a better
presentation, and the code says why at the point of choice.
The typst writer puts the object number in the margin as the label
every citation points at, a note as a real footnote with a link back,
and a table of contents that locates by object number rather than by
page, as the markdown writer's does: a page number is a fact about one
typesetting, the object number is not. The markdown writer is
CommonMark with yaml front matter; a note is at its reference, with
the document's own number, and --image-prefix gives the image path,
which the model rightly does not know.
Holding the writers to these checks found defects in spine, several
since fixed there, and spine's typst and markdown writers were ported
from these.
Use: nix build, in dlang/ or in gleam/. Each flake takes the library
from git.sisudoc.org, and flake.lock pins the commit of it these were
built and checked against. gleam/ builds with nix-gleam's builder,
kept in gleam/nix-gleam/ (MIT); in a working tree, gleam.toml reads
the library through the symlink ./ocda-lib.
See README.md, dlang/README.md and gleam/README.md, and pipeline/
for building everything and running it over a directory of artefacts.
(assisted by Claude-Code)
Diffstat (limited to 'gleam/test/ocda_write_test.gleam')
| -rw-r--r-- | gleam/test/ocda_write_test.gleam | 233 |
1 files changed, 233 insertions, 0 deletions
diff --git a/gleam/test/ocda_write_test.gleam b/gleam/test/ocda_write_test.gleam new file mode 100644 index 0000000..433dfdc --- /dev/null +++ b/gleam/test/ocda_write_test.gleam @@ -0,0 +1,233 @@ +import gleam/list +import gleam/string +import gleeunit +import gleeunit/should +import ocda/load_ssp +import ocda/model +import gleam/bit_array +import write/epub +import write/html +import write/text + +pub fn main() { + gleeunit.main() +} + +/// A document small enough to reason about, carrying the things the writer +/// has to get right: a heading, an object with a note inside it, an object +/// with no ocn, and a trailing section with only its own heading. +const minimal = "% SiSU Document Abstraction v2.0 +% Source: example.sst + +@meta { + title.full: An Example + creator.author: A Writer + date.published: 2026-09-25 +} + +@head { +[0] heading :A the_title +.part: frontmatter +| An Example + +} + +@toc { +[0] toc +.indent: 0 4 +| ┥Chapter One┝┤2├ + +} + +@body { +[1] heading :1 c1 +.part: body +| Chapter One + +[2] para +.part: body +.parent: 1 +| A paragraph with a note【1 The note itself.】in it. + +} + +@glossary { +[0] heading :A glossary +.part: backmatter +| Glossary + +} +" + +fn render() -> String { + text.write(load_ssp.parse(minimal)) +} + +/// head, toc, body. Not glossary: it holds its own heading and nothing else, +/// and spine omits a trailing section of one object. +pub fn sections_test() { + let doc = load_ssp.parse(minimal) + text.text_sections(doc) |> should.equal(["head", "toc", "body"]) + render() |> string.contains(contain: "Glossary") |> should.be_false +} + +/// A heading is preceded by a blank line; an object with ocn 0 gets no mark. +pub fn heading_test() { + render() |> string.contains(contain: "\nAn Example\n\n") |> should.be_true + render() |> string.contains(contain: "「0」") |> should.be_false +} + +/// The ocn follows the object, in its own brackets. +pub fn ocn_test() { + render() |> string.contains(contain: "「1」") |> should.be_true + render() |> string.contains(contain: "「2」") |> should.be_true +} + +/// A note is carried inside the object that refers to it: the reference +/// becomes [n] in place and the note is appended after the object. +pub fn note_test() { + let out = render() + out |> string.contains(contain: "a note[1]in it.") |> should.be_true + out |> string.contains(contain: "1. The note itself.") |> should.be_true +} + +/// A toc entry's link becomes its text and, its target being an object, +/// the ocn it points at; one level in use, so no indent. +pub fn toc_test() { + render() + |> string.contains(contain: "\nChapter One ≫2\n") + |> should.be_true +} + +/// The footer names the document and then the program. +pub fn tail_test() { + let out = render() + out |> string.contains(contain: "Title: An Example") |> should.be_true + out |> string.contains(contain: "Author: A Writer") |> should.be_true + out |> string.contains(contain: "Published: 2026-09-25") |> should.be_true + // no copyright or licence in this document, so no line for either + out |> string.contains(contain: "Copyright:") |> should.be_false + out |> string.ends_with("spine\nhttps://sisudoc.org\n") |> should.be_true +} + +/// The writer is given the model and nothing else, so an object it has no +/// case for writes nothing rather than failing. +pub fn unknown_kind_writes_nothing_test() { + let doc = load_ssp.parse(minimal) + let body = model.section(doc, "body") + body |> list.length |> should.equal(2) +} + +// ---- the html writer ----------------------------------------------------- + +fn render_html() -> String { + html.write(load_ssp.parse(minimal)) +} + +/// A level A heading is h1. Spine writes <h0>, which is not an element. +pub fn heading_element_test() { + let out = render_html() + out |> string.contains(contain: "<h1 id=\"the_title\">") |> should.be_true + out |> string.contains(contain: "<h0") |> should.be_false +} + +/// One charset declaration, and no placeholder metadata: a document with no +/// publisher gets no dc.publisher rather than the word "Publisher". +pub fn head_test() { + let out = render_html() + out |> string.contains(contain: "<meta charset=\"utf-8\" />") |> should.be_true + out |> string.contains(contain: "text/plain") |> should.be_false + out |> string.contains(contain: "dc.publisher") |> should.be_false + out |> string.contains(contain: "content=\"year\"") |> should.be_false +} + +/// The object number is a span, not a label, and links to itself. +pub fn ocn_mark_test() { + render_html() + |> string.contains(contain: "<span class=\"ocn\"><a href=\"#2\" class=\"lnkocn\">2</a></span>") + |> should.be_true +} + +/// A note reference points at the gathered note and carries the dpub role. +pub fn noteref_test() { + render_html() + |> string.contains( + contain: "<a id=\"noteref_1\" href=\"#note_1\" role=\"doc-noteref\">1</a>", + ) + |> should.be_true +} + +/// A section carries its dpub-aria role and no invented id. +pub fn section_test() { + let out = render_html() + out |> string.contains(contain: "<section class=\"toc\" role=\"doc-toc\">") |> should.be_true + out |> string.contains(contain: "id=\"section_toc\"") |> should.be_false +} + +/// It is self-contained: the stylesheet is in the file, not linked out of it. +pub fn self_contained_test() { + let out = render_html() + out |> string.contains(contain: "<style>") |> should.be_true + out |> string.contains(contain: "html_scroll.css") |> should.be_false +} + +/// An ampersand in the text becomes &, and a quotation mark in an +/// attribute becomes ". Erlang's re:replace reads "&" in a replacement +/// *string* as the whole match, which turned "&" into the match itself; +/// every replacement here goes through the function form instead. +pub fn escaping_test() { + let doc = + load_ssp.parse( + "% SiSU Document Abstraction v2.0 + +@body { +[1] para +.part: body +| Smith & Jones said \"hello\" <here> + +} +", + ) + let out = html.write(doc) + out |> string.contains(contain: "Smith & Jones") |> should.be_true + out |> string.contains(contain: "<here>") |> should.be_true + out |> string.contains(contain: "\"quot;") |> should.be_false +} + +// ---- the epub writer ----------------------------------------------------- + +/// The container is first and stored: a reading system identifies an epub +/// from the first bytes of the file, so "mimetype" has to be there and +/// uncompressed. +pub fn epub_mimetype_first_test() { + let bytes = epub.write(load_ssp.parse(minimal), "test/nothing.ssp") + // local file header, then the name at offset 30 + let assert Ok(head) = bit_array.slice(bytes, 0, 4) + head |> should.equal(<<0x50, 0x4b, 0x03, 0x04>>) + let assert Ok(name) = bit_array.slice(bytes, 30, 8) + name |> should.equal(<<"mimetype":utf8>>) + let assert Ok(kind) = bit_array.slice(bytes, 38, 20) + kind |> should.equal(<<"application/epub+zip":utf8>>) +} + +/// Every entry has the same date, so that the same document gives the same +/// epub twice. +pub fn epub_is_reproducible_test() { + let doc = load_ssp.parse(minimal) + epub.write(doc, "test/nothing.ssp") + |> should.equal(epub.write(doc, "test/nothing.ssp")) +} + +/// The segments are grouped by name, not by run: a section's objects are not +/// always contiguous, and two zip entries of one name is an epubcheck error. +pub fn epub_segments_are_unique_test() { + let segs = epub.segments(load_ssp.parse(minimal)) + let names = list.map(segs, fn(s) { s.file }) + list.length(names) |> should.equal(list.length(list.unique(names))) +} + +/// A `.ssp` outside a pod cannot give images, and the writer asks before it +/// emits one: an `<img>` whose file is not in the manifest is an error. +pub fn epub_images_test() { + epub.images_named(load_ssp.parse(minimal)) |> should.equal([]) +} |
