The library would implement WebVTT in
Clojure and ClojureScript, as dk.simongray/webvtt-clj on Clojars with
the root namespace dk.simongray.webvtt. It would parse a file as a
browser parses a <track> and write one back. Cue text would parse into
Hiccup with its voices and inline timestamps. The errors of a file that
doesn't conform would come back as data.
The first user would be podcast-clj, since WebVTT is the transcript format that the podcast namespace recommends and Apple Podcasts ingests. The IIIF captions of glossematics.dk and the captions of the blog's audio and video posts would follow.
The coordinates are free on Clojars, where a search for "webvtt" or "vtt" finds nothing. Its subtitle libraries read only SRT (the newest is a snapshot from 2020).
None of this exists yet, so the examples show the proposed API and haven't been run.
Since 20 May 2026 the published spec has been a Candidate Recommendation Draft of the Timed Text Working Group, not the Candidate Recommendation of 2019. It's built from the same commit as the editor's draft and keeps the section numbers of 2019. The group now publishes every merged change as a new draft, and its charter sets no date for a Recommendation.
Only one normative change since 2019 touches files: a cue setting now needs its colon and a value (#542). That changes what's valid but not what's parsed. The library would follow the draft of 20 May 2026 and cite its sections beside each rule, as whatwg-url does.
One open pull request matters.
#548 would let the lines after
WEBVTT hold metadata (e.g. a language and a kind) for the parser to
return. It has changes requested, so until it lands the header stays
text in the data.
The library covers:
It leaves out:
emit refuses what their setters refuse (e.g. a position
over 100%), since such a value wouldn't read back.parse takes a string. It skips a leading BOM.<lang> spans. That check needs BCP 47
(entry #11 of the ranking). Until then the check only asks for a tag
that isn't empty.No platform can parse a WebVTT string with a function call. VTTCue has a
constructor but no parse method. Its getCueAsHTML is the only way to
get at the tree of a cue's text. A page can still parse a file
through a detached <video> with a <track> of a blob: URL in hidden
mode (which works in Chromium), but:
Node and the JDK have no WebVTT parser either. So whatwg-url's rule of using the platform where every platform agrees doesn't apply here. The library is one implementation in cljc, which is what makes its results the same on every platform.
A file parses into a map:
(require '[dk.simongray.webvtt :as webvtt])
(webvtt/parse (str "WEBVTT\n\n"
"REGION\nid:left width:40%\n\n"
"intro\n"
"00:00:01.000 --> 00:00:04.000 region:left align:start\n"
"<v.loud Bob>Hi <00:00:02.000><i>there</i>"))
;; => {:regions [{:id "left" :width 40}]
;; :cues [{:id "intro"
;; :start-time 1.0
;; :end-time 4.0
;; :region "left"
;; :align :start
;; :text "<v.loud Bob>Hi <00:00:02.000><i>there</i>"}]}
cue-defaults and region-defaults, so a cue
without settings stays small. This is how whatwg-url leaves out the
parts that a URL lacks.WEBVTT to the first
blank line, when it's more than WEBVTT. That's where an HLS segment
has its X-TIMESTAMP-MAP.The text of a cue stays a string, since its meaning depends on the kind
of track. A metadata cue holds any text (often JSON), a chapter cue holds
a title, and only captions and subtitles have markup. Parse it with
hiccup, by section 6.4:
(webvtt/hiccup "<v.loud Bob>Hi <00:00:02.000><i>there</i>")
;; => [[:v {:voice "Bob" :class ["loud"]}
;; "Hi " [:timestamp {:time 2.0}] [:i {} "there"]]]
cue-text writes the tree back as the
same tags. Five of them (i, b, u, ruby and rt) are HTML elements
already.The root namespace holds the whole API. These functions parse:
parse, the map of a file, or nil when the text isn't WebVTT (6.1)hiccup, the node tree of a cue's text (6.4)text, the plain text of a cue by the rules for extracting a chapter
title (6.6), which leave out ruby textruns, the text of a cue split at its inline timestamps, as maps of
:start-time and :textparse-timestamp, the seconds of a WebVTT timestamp, or nil (6.3)These write:
emit, the text of a file mapcue-text, the cue text of a node tree, with &, < and > written as
character referencesformat-timestamp, seconds as a WebVTT timestampThese check:
conformance-errors, the errors of a file for a kind of trackconforming?, whether a file has noneTwo maps, cue-defaults and region-defaults, hold the defaults of
section 3. The functions that read cue text take a cue or its text, as
whatwg-url's functions take a URL map or its text. Only runs needs a
cue, since its first run starts at the cue's start time.
The names follow your other libraries: parse and emit as in
qname-hiccup (and the emit-vtt that this replaces), and hiccup and
text as in html-pieces. The conformance functions use the spec's
words, as whatwg-url's validation-errors and valid? use the URL
Standard's.
The internal namespaces sit under impl, with ^:no-doc:
impl.parser, the file parsing and the collecting of blocks (6.1)impl.settings, region settings, cue settings and timestamps (6.2,
6.3)impl.cue-text, the tokenizer, the tree and chapter titles (6.4, 6.6)impl.entities, the named character references of HTMLimpl.writer, emit and cue-textimpl.conformance, the rules of section 4 that the parser doesn't
enforceEach state of the tokenizer and each step of the file parsing is a named function. Each reads a run of text up to its next delimiter rather than a code point at a time, as html-pieces' tokenizer does. Benchmarks with criterium and clj-async-profiler, as in whatwg-url, would time the largest transcript of podcast-clj's corpus and a word-level file with a timestamp per word. An optimization stays where a benchmark shows that it matters.
The library carries the whole table of HTML's named character
references. The parser decodes references in cue text by HTML's rules.
The table in html-pieces keeps only the names of HTML 4.01 and the legacy
ones, which would fail two of WPT's cue text cases
(∲ and ⫅̸). A dev alias would make the
full table from WHATWG's entities.json into resources/, as whatwg-url
makes its Unicode tables, so the library needs nothing but Clojure. HTML
says the list will never change, so the table needs no upkeep.
Like a browser, parse never throws on text and reports nothing.
Whatever the parser skips is missing from the map, e.g. a setting it
doesn't know or a tag it ignores.
To check a file, conformance-errors runs the same parser and has it
report every forgiving branch it takes, the way whatwg-url's parser
collects validation errors only when asked. It then checks the rules of
section 4 that the parser doesn't enforce, e.g.:
--> in ids, comments or style
blocksSince one parser does both, the check can't disagree with the parse. Errors are maps of a :type, a :line and a :column, in the order of the file:
(webvtt/conformance-errors
"WEBVTT\n\n00:00:02.000 --> 00:00:01.000 align:left align:start\nHi")
;; => [{:type :end-not-after-start :line 3 :column 18}
;; {:type :duplicate-setting :line 3 :column 42}]
The spec names no errors, unlike the URL Standard. So each type takes its name from the rule it breaks. The docstring that lists the types cites their sections. Unlike whatwg-url's bare keywords, the errors carry positions, since a transcript can have a thousand cues. They have no messages, as podcast-clj's diagnostics have their own.
An optional kind of track, one of the kinds of HTML's <track>, picks
the rules for the text of cues:
--> and no blank
line (4.2.1)The library reads the nesting rule as "no two chapters partly overlap", since the literal wording would forbid the side-by-side chapters of the spec's own example.
Writing throws an ex-info for a map that wouldn't read back the same,
e.g. one with a blank line in a :text or --> in an :id. It still writes
a file that's merely non-conforming (e.g. with cues out of order), which
conformance-errors can catch.
The tests would pin web-platform-tests at
38d608e
of 12 October 2026, though webvtt/parsing hasn't changed since 25
August. As in whatwg-url, dev/fetch.sh downloads the test data into
dev-resources/ (which Git ignores), here with a sparse checkout of
webvtt/parsing and of HTML's track element tests at that commit.
.dat files of cue-text-parsing/dat hold 78
cases as html5lib-style trees of the DOM that section 6.5 builds. The
tests read the files directly. The harness prints the library's tree
in their notation: a c or a v as a span with a class or a title, a lang
as a span with a lang, and a timestamp as <?timestamp …>. It merges
the adjacent text that the expected trees keep apart..test files of file-parsing/support state
their expectations as JavaScript assertions on VTTCue, some of them in
loops. So the plan is to translate them once into an EDN file in
test/, which names the pinned commit and carries WPT's BSD licence.
The inputs are the generated .vtt files in tests/support, since the
.test files write their bodies with Python escapes. The 11 inputs
that signature-invalid.html expects to fail should give nil. Numbers
compare as JavaScript's SameValue does, since line:-0 must read as 0
and Clojure's = can't tell 0.0 from -0.0.assert_cues_match
and assert_cue_fragment), which translate into EDN the same way.emit, a valid one has no conformance
errors, and no random text makes parse throw.stylesheets.test checks nothing that a parser
returns) , e.g. �, C1 controls and
surrogates<lang> spans and <c> with several classesInvalid UTF-8 needs no test, since parse takes a string. CI would run
it all on the JVM (Java 11 and the current release) and in Node (with
advanced compilation too), as whatwg-url's does. It would skip the
browser, since the library calls nothing that Node lacks.
As wary-fetch builds its url namespace on whatwg-url, podcast-clj would build its reading and writing of WebVTT on webvtt-clj and keep its own transcript shape. The transcript namespace keeps:
parse with its sniffing, and the reportcarry-speakersturn-marked?, for hosts like Transistor that write a voice span only
where the speaker changes. That's a habit of hosts rather than WebVTT,
so it stays.emit-srt, emit-json, turns, text, at and matchesIt would lose:
vtt-cue, the voice span regex and the stripping
of tags from cue text (timing-pattern and strip-tags stay for SRT,
sniffing and HTML)vtt-text and the string building of emit-vttEach cue that webvtt/parse returns becomes a segment in parse-vtt,
with the speaker from the first :v of webvtt/hiccup and the body from
webvtt/text. To write, emit-vtt builds each cue's text with
webvtt/cue-text, e.g. from [[:v {:voice speaker} body]], and the
file with webvtt/emit.
Some transcripts would read differently, mostly where today's reader is looser than the spec:
00:15:03.1000. Today's reader drops one
and ends the other at 328.1 s rather than 329.0 s. The other
transcripts (from Buzzsprout, Megaphone, Talk Python and Transistor)
have conforming timings and no tags but voice spans, so they should
read as they do now. A diagnostic can count the dropped cues, from
conformance-errors.WEBVTT header doesn't parse. Sniffing already
reads one with SRT-like timings as SRT.& without its semicolon.Word timing. For "Word timing from inline WebVTT timestamps", a
segment would get the timed runs of webvtt/runs (a word each, in a
word-level transcript), and emit-vtt would write them back as
:timestamp elements. The needs report cites section 3.3.3 for those
timestamps, which are in sections 4.2.2 and 6.4.
X-TIMESTAMP-MAP belongs in podcast-clj, next to the playlists that
format.hls reads:
WEBVTT line. The draft revision of the RFC
notes that WebVTT dropped such headers in 2016. PR #548 might bring
them back.From webvtt-clj, podcast-clj needs only the :header text and
parse-timestamp (for the LOCAL time of the map). Its diagnostics
shouldn't count the map as a conformance error.
WebVTT chapter tracks barely matter for podcasts. The podcast:chapters
tag links JSON chapters and never mentions WebVTT. Apple Podcasts reads
ID3 and JSON chapters. A search found no podcast app or host that uses
WebVTT's. Chapter tracks matter in video players such as video.js
and JW Player, while IIIF builds chapters from Ranges. The library
supports them anyway, since they only add the rule for chapter titles
and the check that chapters nest. The chapters namespace of podcast-clj
needn't read them.
text/vtt file with a language (cookbook
recipes 0219
and 0074).
With webvtt-clj you write and check the files, while the IIIF library
(entry #7 of the ranking) would build the annotations. Mirador and the
Universal Viewer hand the file to a <track>, so the browser's parser
reads it.emit and check them with conformance-errors before publishing.Version 0.1.0 would have the whole API above and pass the WPT and track element tests on the JVM and in Node. Its README would follow whatwg-url's, with the MIT licence and a credit for the table of character references, which WHATWG publishes under CC BY 4.0. The work would go in this order:
emit and cue-text, with the generative testsThe source would be about 1,500 lines of cljc (half of whatwg-url), with as much again in tests and translated expectations. That's a few days, or 3 on the scale of podcast-clj's needs report. The ranking's estimate of 2 to 3 didn't count the conformance checks or the translation of the tests. Adopting it in podcast-clj, with word timing, is about a day.
⫅̸ would decode in a transcript but not in show
notes. It would also go against the size trade-off you made in
html-pieces. The choices:
character-reference public and add the MathML names, pulling its
tokenizer, whatwg-url and data.json into webvtt-cljemit be able to
write one, e.g. a note at the top of a file?emit matches qname-hiccup and
podcast-clj, while write or serialize might read better on their
own.Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |