Skip to content
Source
Sign inSign up

markdownTaskList.ts

94 lines3.6 KB

1import type { ListItem, Nodes } from "mdast";
2import remarkGfm from "remark-gfm";
3import remarkParse from "remark-parse";
4import { unified } from "unified";
5​
6const parser = unified().use(remarkParse).use(remarkGfm);
7​
8/** Offset of the `[ ]` marker on the first line of the list item starting at `listItemStart`. */
9export function taskListMarkerOffset(markdown: string, listItemStart: number): number | null {
10 const firstLineEnd = markdown.indexOf("\n", listItemStart);
11 const firstLine = markdown.slice(
12 listItemStart,
13 firstLineEnd === -1 ? markdown.length : firstLineEnd,
14 );
15 const match = firstLine.match(/^(?:\s*(?:[-+*]|\d+[.)])\s+)(\[[ xX]\])/);
16 if (!match?.[1]) return null;
17 return listItemStart + firstLine.indexOf(match[1]);
19​
20export function setMarkdownTaskChecked(
21 markdown: string,
22 markerOffset: number,
23 checked: boolean,
24): string {
25 if (
26 markerOffset < 0 ||
27 markdown[markerOffset] !== "[" ||
28 !/[ xX]/.test(markdown[markerOffset + 1] ?? "") ||
29 markdown[markerOffset + 2] !== "]"
30 ) {
31 return markdown;
32 }
33​
34 return `${markdown.slice(0, markerOffset + 1)}${checked ? "x" : " "}${markdown.slice(markerOffset + 2)}`;
36​
37/** Hard breaks as U+2028, as iOS draws them; soft breaks stay "\n". */
38function plainText(node: Nodes): string {
39 if (node.type === "text" || node.type === "inlineCode") return node.value;
40 if (node.type === "break" || (node.type === "html" && /^<br\b/i.test(node.value))) {
41 return "\u2028";
42 }
43 return "children" in node ? node.children.map(plainText).join("") : "";
45​
46const lettersAndDigits = (text: string) => text.replace(/[^\p{L}\p{N}]/gu, "");
47​
48/** iOS sends the first paragraph whole. Android cuts it at a hard break, or any break with hard breaks on. */
49function renderedTexts(item: ListItem): string[] {
50 const first = item.children[0];
51 const text = first?.type === "paragraph" ? plainText(first) : "";
52 return [text, text.replace(/\u2028[^]*$/, ""), text.replace(/[\u2028\n][^]*$/, "")].map(
53 lettersAndDigits,
54 );
56​
57// Native renderers give other top-level blocks (code, tables, quotes, media) their own views, and
58// each view numbers its tasks from 0. Lists, headings and rules share the text view around them.
59const sharedViewBlocks = new Set(["list", "heading", "thematicBreak"]);
60​
61/**
62 * Marker offset of the task a native checkbox tap names. Null unless exactly one task has that
63 * text and state and could have that index under some split of the document into native views.
64 */
65export function markdownTaskMarkerOffset(
66 markdown: string,
67 index: number,
68 renderedText: string,
69 checked: boolean,
70): number | null {
71 // Native attachments replace their labels, so the remaining text could name another task.
72 if (renderedText.includes("\uFFFC")) return null;
73 const tasks: ListItem[] = [];
74 const collect = (node: Nodes) => {
75 if (node.type === "listItem" && typeof node.checked === "boolean") tasks.push(node);
76 if ("children" in node) node.children.forEach(collect);
77 };
78 const viewStarts = new Set([0]);
79 for (const block of parser.parse(markdown).children) {
80 const before = tasks.length;
81 collect(block);
82 if (sharedViewBlocks.has(block.type)) continue;
83 for (let count = before; count <= tasks.length; count++) viewStarts.add(count);
84 }
85 const rendered = lettersAndDigits(renderedText);
86 const matches = tasks.filter(
87 (task, position) =>
88 task.checked === !checked &&
89 viewStarts.has(position - index) &&
90 renderedTexts(task).includes(rendered),
91 );
92 const start = matches.length === 1 ? matches[0]?.position?.start.offset : undefined;
93 return start === undefined ? null : taskListMarkerOffset(markdown, start);