Personal
asciidoc-to-djot
An AST-based converter from Asciidoc to Djot, written so that a format migration in the Nostr wiki specification did not require anyone to rewrite their articles by hand.
A converter from Asciidoc to Djot, published to npm as a library and a CLI. It exists because NIP-54, the Nostr wiki specification, changed format, and the articles already published in the old one belong to people who were never going to convert them by hand.
- Shape
- npm package, library and CLI
- Method
- Asciidoc AST → custom Djot converter backend
- Role
- Author
- Repository
- VincenzoImp/asciidoc-to-djot
The problem
Converting between two lightweight markup languages looks like a text-substitution job and is not. Headings, emphasis and code spans map across cleanly; lists, tables, source blocks, admonitions and sidebars are structures, and a regular expression that rewrites them handles the common shapes and silently mangles the rest.
NIP-54 adds a second difficulty. Its articles carry two constructs that are not Asciidoc at all:
[[wikilinks]], in both the plain and the [[target|display]] form, and nostr: URIs. A parser
built for Asciidoc does not know about either, so a naive parse loses them before conversion can
begin.
What I built
A five-stage pipeline with an AST-based conversion stage.
The pre-process step replaces wikilinks and nostr: URIs with placeholders, precisely because
Asciidoctor does not handle them natively: they have to survive the parse rather than be
understood by it. @asciidoctor/core then parses the source into an AST, and a custom
DjotConverter walks that AST, emitting Djot per node type. The post-process step
restores the placeholders as Djot reference-style and inline links and normalises blank lines.
By default, the last stage parses the output with @djot/djot and collects diagnostics.
The library returns both the converted text and its warnings; the CLI prints warnings while
still writing the output. Callers decide whether a diagnostic should block their migration.
Hard parts
The two wikilink forms map to different Djot constructs. [[Target]] becomes the
reference-style [Target][], while [[target|display]] becomes [display][target]: the
components swap order. Getting that backwards produces valid Djot with the link text and the link
target exchanged, which is exactly the class of bug that survives review.
Placeholders need their own boundary. Preprocessing uses numbered strings such as
XPLACEHOLDER0X to carry links through the parser. The implementation does not check whether an
input already contains one of those strings, so collision freedom is not established. The
placeholder path is useful, but still has that edge case.
Parsing is one check on a conversion. The suite checks converter nodes and expected headings, links, lists and tables in a full article, and includes a Djot parse check. It does not convert the result back to Asciidoc or prove semantic equivalence for every input. A parseable document can still contain the wrong link target or lose source meaning.