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/src/ocda_write.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/src/ocda_write.gleam')
| -rw-r--r-- | gleam/src/ocda_write.gleam | 149 |
1 files changed, 149 insertions, 0 deletions
diff --git a/gleam/src/ocda_write.gleam b/gleam/src/ocda_write.gleam new file mode 100644 index 0000000..3c224fa --- /dev/null +++ b/gleam/src/ocda_write.gleam @@ -0,0 +1,149 @@ +//// ocda-write: a sample writer over the abstraction. +//// +//// ocda-write --text|--html <file>.ssp|<file>.ocda.db|<file>.zip [lang] +//// +//// The input may be either serialisation, and the writer is not told which. +//// That is the claim being demonstrated: a writer is a function from the +//// model to bytes, so a document renders the same whichever of its two +//// abstraction files it is given. +//// +//// Output goes to stdout, so that the comparison against spine's own output +//// is a shell pipeline and not a directory of files to clean up. + +import argv +import gleam/list +import gleam/string +import ocda/images +import gleam/io +import ocda/load +import simplifile +import write/epub +import write/html +import write/markdown +import write/text +import write/typst + +pub fn main() { + case argv.load().arguments { + ["--text", path] -> render(path, "", "text") + ["--text", path, lang] -> render(path, lang, "text") + ["--html", path] -> render(path, "", "html") + ["--html", path, lang] -> render(path, lang, "html") + ["--epub", path, "--out", out] -> write_epub(path, "", out) + ["--epub", path, lang, "--out", out] -> write_epub(path, lang, out) + ["--md", path] -> render_markdown(path, "", "image") + ["--md", path, lang] -> render_markdown(path, lang, "image") + ["--md", path, "--image-prefix", pth] -> render_markdown(path, "", pth) + ["--md", path, lang, "--image-prefix", pth] -> + render_markdown(path, lang, pth) + ["--typ", path] -> render(path, "", "typst") + ["--typ", path, lang] -> render(path, lang, "typst") + ["--typst", path] -> render(path, "", "typst") + ["--typst", path, lang] -> render(path, lang, "typst") + ["--typst", path, "--out", out] -> write_typst(path, "", out) + ["--typst", path, lang, "--out", out] -> write_typst(path, lang, out) + _ -> usage() + } +} + +fn usage() -> Nil { + io.println_error( + "usage: ocda-write --text|--html|--typst <file> [lang]\n" + <> " ocda-write --md <file> [lang] [--image-prefix <path>]\n" + <> " ocda-write --epub <file> [lang] --out <file>.epub", + ) + halt(2) +} + +fn render(path: String, lang: String, format: String) -> Nil { + case load.load(path, lang, False) { + Error(message) -> { + io.println_error("error: " <> message) + halt(2) + } + Ok(doc) -> + io.print(case format { + "html" -> html.write(doc) + "typst" -> typst.write(doc) + _ -> text.write(doc) + }) + } +} + +/// Markdown takes one thing the other formats do not: where the images are, +/// relative to wherever the .md is put. It has its own entry for that, as the +/// epub and the typst do for --out. +fn render_markdown(path: String, lang: String, image_pth: String) -> Nil { + case load.load(path, lang, False) { + Error(message) -> { + io.println_error("error: " <> message) + halt(2) + } + Ok(doc) -> io.print(markdown.write(doc, image_pth)) + } +} + +/// An epub is a zip, so it goes to a file rather than to stdout. +fn write_epub(path: String, lang: String, out: String) -> Nil { + case load.load(path, lang, False) { + Error(message) -> { + io.println_error("error: " <> message) + halt(2) + } + Ok(doc) -> + case simplifile.write_bits(out, epub.write(doc, path)) { + Ok(_) -> Nil + Error(e) -> { + io.println_error("error: " <> out <> ": " <> simplifile.describe_error(e)) + halt(2) + } + } + } +} + +/// Gleam has no standard exit, and the exit status is what a sweep script +/// reads. +@external(erlang, "erlang", "halt") +fn halt(code: Int) -> Nil + +/// A .typ names its images by path rather than carrying them, so when it goes +/// to a file the images are written beside it. +fn write_typst(path: String, lang: String, out: String) -> Nil { + case load.load(path, lang, False) { + Error(message) -> { + io.println_error("error: " <> message) + halt(2) + } + Ok(doc) -> { + case simplifile.write(out, typst.write(doc)) { + Ok(_) -> Nil + Error(e) -> { + io.println_error( + "error: " <> out <> ": " <> simplifile.describe_error(e), + ) + halt(2) + } + } + let dir = case string.split(out, "/") { + [_] -> "image" + parts -> + string.join(list.take(parts, list.length(parts) - 1), "/") <> "/image" + } + case images.can_give_images(path) { + False -> Nil + True -> + epub.images_named(doc) + |> list.each(fn(name) { + case images.image_bytes(path, name) { + Ok(bits) -> { + let _ = simplifile.create_directory_all(dir) + let _ = simplifile.write_bits(dir <> "/" <> name, bits) + Nil + } + Error(_) -> Nil + } + }) + } + } + } +} |
