Store bookmarks and progress
A bookmark is the one thing your app writes that has to be read back a year later, by a newer build, under a different reading. Three fields that look like stable keys are not. This page is the record to write, the call that resolves it, and the cases where resolving returns two verses or none.
The three things that shift
Page breaks belong to the muṣḥaf being printed, and a word
number belongs to one build of the dataset. The record that survives is which edition, its own ayah number,
and the text it was built from: not a page number without an edition, not a word number without a rebuild guard,
and never a bare 2:255 that assumes the count.
| Looks stable | Shifts when | Persist instead |
|---|---|---|
2:255 alone | the user switches riwayah — Warsh numbers āyat al-Kursī 2:253, and 2:255 is another verse | the edition key beside the number, and convert with .to() |
| a page number | a different muṣḥaf is on screen — page breaks belong to the printing | the ayah, and derive the page from the edition being shown |
the shared word number, e.g. 5177 | a future publisher release adds or removes one word, shifting every number after it | the ayah plus the 1-based word index within it, or the word index’s content-derived key |
Write the record
Everything below is read off the loaded muṣḥaf, so the record is right for whatever edition the user was reading. The reference is stored as the ayah key, surah and ayah together (Engineering, rule 2.1), and
sourceis the SHA-256 of the publisher’s package the words came from, the provenance that lets a later build pin what was read (Versioning and corrections, rule 6.3) and tell whether the text underneath has changed.import { Mushaf } from "quran-text"; const hafs = await Mushaf.hafs(); /** What to write to storage: the edition, its own count, and the text it came from. */ function bookmark(m, surah, ayah) { const a = m.ayah(surah, ayah); return { edition: m.key, // "hafs" counting: m.countingSystem, // "kufi" surah: a.surah.number, // 2 ayah: a.number, // 255 — in this edition's own count source: m.provenance.text.sha256, // "cdec7341b7c684e7…" }; } bookmark(hafs, 2, 255); // { edition: "hafs", counting: "kufi", surah: 2, ayah: 255, source: "cdec7341b7c684e7b8dd469c4b68988914d2784e924c7e6cbb9cb4b57c24f013" }For a reading position inside a verse, add the word’s 1-based index within the ayah —
hafs.word(2, 255, 3).indexis3— not itspositionin the array and not its sharednumber.Resolve it against whatever is on screen
A bookmark made under Ḥafṣ and opened under Warsh has to be translated, and the translation is one call. The result names its
relation.const warsh = await Mushaf.load("data/mushaf/warsh.json"); const editions = { hafs, warsh }; /** Resolve a stored bookmark against the edition being displayed now. */ function resolve(b, target) { const origin = editions[b.edition]; const match = origin.ayah(b.surah, b.ayah).to(target); return { key: match.key, relation: match.relation, ayahs: match.ayahs }; } resolve(bookmark(hafs, 2, 255), warsh); // { key: "2:253-254", relation: "split", ayahs: [Ayah, Ayah] } resolve(bookmark(warsh, 2, 253), hafs); // { key: "2:255", relation: "merged", ayahs: [Ayah] } resolve(bookmark(hafs, 1, 4), warsh); // { key: "1:3", relation: "same", ayahs: [Ayah] }If the origin edition is not loaded on the device,
data/ayah-map.jsonanswers the same question without any muṣḥaf file —AyahMap.convert(2, 255, "warsh")returns{ surah: 2, ayah: 253, ayahLast: 254, relation: "split" }. It maps from Ḥafṣ, so store Ḥafṣ coordinates if you intend to rely on it.Branch on relation, every time
relationis one ofsame,merged,split,shifted,unnumberedormissing. Two of them change how many verses you highlight, and one of them gives you nothing to highlight at all.const r = resolve(b, current); switch (r.relation) { case "same": case "shifted": return scrollTo(r.ayahs[0]); // one verse, possibly a different number case "split": return highlight(r.ayahs); // two verses in this edition — show both case "merged": return scrollTo(r.ayahs[0]); // this edition's verse contains the bookmarked one case "unnumbered": return scrollTo(current.surah(b.surah).basmalah); // see below case "missing": return explain("not in this reading"); }A bookmark on 1:1 resolves to an empty key under four editionsḤafṣ numbers the opening formula of al-Fātiḥa as ayah 1. Warsh, Qālūn, al-Dūrī and al-Sūsī print it unnumbered. So
hafs.ayah(1, 1).to(warsh)returnsrelation: "unnumbered",ayahs: []— andkeyis the empty string"", whileAyahMap.convert(1, 1, "warsh").keyis"1:0"for the same case. Code that useskeyas a lookup gets""and shows nothing. Readrelationfirst; the words are insurah(1).basmalah.Guard against a rebuild
On load, compare the stored
sourcewith the current edition’s. The same digest means the same text and the record is exact. A different digest means the publisher released a correction; the ayah numbers are almost certainly still right, and the word index may not be.const current = editions[b.edition]; const sameText = current.provenance.text.sha256 === b.source; if (!sameText && b.word) { // The text moved under the bookmark. Keep the ayah; re-derive the word by // content rather than by index, or drop to ayah precision and say so. }If you need a word-level key that survives a rebuild, the word index’s
keyfield is content-derived (surah:pointed#occurrence, for example2:اسرايل#1), and itshafsblock gives Ḥafṣ coordinates in the Kufan count. Both are stable across rebuilds of the same sources; the runningnumberis not.
Skip the conversion and warsh.ayah(2, 255).text opens لَآ إِكْرَاهَ, the passage Ḥafṣ numbers 2:256, not āyat
al-Kursī. The edition key in the record is what makes the conversion possible.
The whole thing
// bookmarks.mjs — run with: node bookmarks.mjs
import { Mushaf } from "quran-text";
const hafs = await Mushaf.hafs();
const warsh = await Mushaf.load("data/mushaf/warsh.json");
const editions = { hafs, warsh };
function bookmark(m, surah, ayah) {
const a = m.ayah(surah, ayah);
return { edition: m.key, counting: m.countingSystem, surah: a.surah.number, ayah: a.number, source: m.provenance.text.sha256 };
}
function resolve(b, target) {
const match = editions[b.edition].ayah(b.surah, b.ayah).to(target);
return { key: match.key, relation: match.relation, ayahs: match.ayahs };
}
const b = bookmark(hafs, 2, 255);
console.log(resolve(b, warsh).key, resolve(b, warsh).relation); // 2:253-254 split
console.log(resolve(bookmark(warsh, 2, 253), hafs).key); // 2:255
console.log(resolve(bookmark(hafs, 1, 1), warsh).relation); // unnumbered
const w = hafs.word(2, 255, 3);
console.log({ surah: 2, ayah: 255, word: w.index }, w.number, w.to(warsh).ayah.key); // { … word: 3 } 5177 2:253
Beyond the seven editions
The conversion above is between these seven printed editions. If your references come from somewhere else —
a translation keyed to a different counting system, a scholarly citation — the hub is Kufan numbering and the tool is
Qiraat Ayah Map, which gives the same advice: store one numbering, translate on the
way to the screen, and branch on status.
The same ayah in all seven editions, with each one’s own boundaries — the conversion this page persists across.