Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0
- Read a typed `*` or `_` run the way Markdown reads the same characters in a file, so `***text*` gives two literal asterisks before italic text, `_**text**` a literal underscore before bold text, and `_**text**_` italic bold, instead of leaving every marker as text that saved with backslashes and reopened without the formatting. A run whose closing marker is shorter than its opening one is read once the caret leaves it, because another marker typed there would spell something else.
- Pair a `*`, `_`, or `~` typed against bold, italic, or strikethrough with the matching literal marker already on the other side of it, so closing `_**text**` with a `_` gives italic bold and saves `_**text**_`, instead of leaving both markers as text that saved as `\_**text**\_` and reopened without the italic. A marker a file keeps literal by escaping it stays literal.
- Leave a `*` or `_` bare on save where it sits directly against bold or italic text written with the same marker, so `***text**` and `**text***` are saved as written instead of gaining a backslash the next read does not need. The marker and the span's own markers spell one run of markers there, and Markdown already leaves the extra one as text. A marker Markdown could still read as a pair keeps its backslash.
- Leave a `*` or `_` bare on save where the only marker that could close it is one Markdown refuses to pair it with, so text such as `_**text*` is saved as written instead of gaining a backslash the next read does not need. Markdown declines a pair whose two runs of markers add up to a multiple of three, unless both runs are, so a run that could still pair keeps its backslash, including one whose lengths are both multiples of three.
- Leave a `*`, `_`, or `~` bare on save wherever nothing else on its line could pair with it, so text such as `[a](b)*` keeps its marker as written instead of collecting a backslash merely because a link, an image, or a bold span shares the line with it. A marker that could still pair keeps its backslash.
- Keep the address of a URL or email address written on its own when a `*`, `_`, or `~` follows it, so text such as `https://example.com*` keeps its link pointing where it did. The backslash the file writes to keep that marker literal was being read back as part of the address, which gained another backslash every time the document was opened and saved.
- Keep a URL or email address written on its own bare when a run shaped like a character reference but naming nothing, such as `&notarealentity;`, follows it, so text such as `https://example.com&notarealentity;` is saved as it was written instead of gaining angle brackets. Markdown leaves such a run outside the link whether or not the name exists.
Expand Down
1 change: 1 addition & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@
- The pairing waits where the closing run is shorter than the run it would pair with. An input rule reads only the text before the caret and cannot know another delimiter is coming, and here the continuations are each other: firing on the keystroke serves `***text*` at the cost of `***text**`, and refusing serves the second at the cost of the first. Reading the pair once the caret leaves the run it closed serves both, and is how typed link source already reaches the object it spells. A closing run that could equally open a construct is refused outright rather than deferred, on the conservatism above: `**a*b` leaves its delimiters as text the author can see and correct, rather than pairing a run they may still be typing into. A tilde is left out of this pass entirely, because a strikethrough is spelled only by runs of equal length.
- A pair the pairing is still waiting on is written as the literal text it is, so two saves with no edit between them write different files: `***text*` saved with the caret still on the closing run writes `\*\*\*text*`, and saved once the caret has left it writes `***text*`. That is accepted rather than repaired with a save-time finalize, because under [Preserve the form a file was written in](#preserve-the-form-a-file-was-written-in) it costs neither content nor closure: each file holds every character the document does, matches what the editor was showing, and reopens as the document that wrote it. What differs between the two is which document the author had when they saved. A pending pair is therefore not finalized before serialization where a projection is, because a projection stands in for something and a pending pair does not. Projected characters are the source of an object the document holds, so writing them unfinalized would write escaped markers where that object belongs and cost content and closure both. A pending pair's delimiters are ordinary text the author typed, and finalizing them on a save would spend the save answering the question the deferral exists to leave open, against an author who has not moved the caret off the run.
- A delimiter run flush against a mark's delimiters is written bare, decided in [issue #353](https://github.com/Azganoth/leafdown/issues/353). The two spell one run rather than a pair, and no run pairs with itself, so the mark's own pairing spends as many delimiters as the mark wrote and the run beside it is the surplus that pairing leaves literal: `\***text**`, `**text**\*`, and `\*\**text*` are written `***text**`, `**text***`, and `***text*`. Both spellings reopen as the same document, which is why neither the corpus round trip nor the document-preservation guard can see the difference, and why the coverage asserts the written bytes and the reopened document together. The escape is kept wherever the merge cannot be shown to cost nothing: where the two runs sum to a multiple of three without both being one, which CommonMark refuses wherever either run can also play the other part, and whether the mark's far delimiters can is decided by text no handler writing the run has seen; where the mark's content spells the same character, which could take the pairing the merged run is measured against; where the text before the run or past the mark spells a counterpart of its own; and for a tilde, which GFM closes only with a run of its own length.
- That same sum settles a pair the text holds on its own, decided in [issue #372](https://github.com/Azganoth/leafdown/issues/372), where both runs are in hand rather than one of them belonging to a mark: `_\*\*text*` and `x\*\*\*\*strong**` are written `_**text*` and `x****strong**`, because a two-marker run and the one marker closing it sum to three and reach no pairing, while `a\*\*\*text\*\*\*` keeps its backslashes on the exception the rule carves out for two runs that are both multiples of three. The roles the rule is stated over are read as micromark settles them rather than as the flanking rules alone state them: a run facing another attention marker can play both parts, and the GFM extension puts the tilde in that set, which is what separates `_**text*` and `~**text*` from `.**text*`, where the pair forms. A tilde run is tokenized by strikethrough, which reads its flanking without that widening and closes only with a run of its own length, so no sum reaches it.
- Develop custom UI components only when required by the product specification.

### Do not use Crepe
Expand Down
17 changes: 16 additions & 1 deletion src/features/editor/tests/markdownCompatibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,13 @@ describe("Escape precision", () => {
// delimiters can open wherever the text past them admits it, so a sum of three keeps the
// escape it cannot be shown to have outgrown.
{ saved: "\\**em*", source: "\\**em*" },
{ saved: "x\\*\\*\\*\\*strong**", source: "x\\*\\*\\*\\*strong**" },
// Two runs the same text holds are measured by that same sum, and here both parts are known,
// so a pair the sum rules out leaves the run literal without a backslash. The character beside
// the run is what puts it in reach of the rule: micromark reads a run facing another attention
// marker as able to play both parts, which is the condition the sum is stated under.
{ saved: "_**a*", source: "_\\*\\*a*" },
{ saved: "~**a*", source: "~\\*\\*a*" },
{ saved: "x****strong**", source: "x\\*\\*\\*\\*strong**" },
// A delimiter inside the span could take the pairing the merged run is measured against, and
// one on the other side of the run is a counterpart the merge does not hide.
{ saved: "**bold *and* italic**\\*", source: "**bold *and* italic**\\*" },
Expand Down Expand Up @@ -797,6 +803,11 @@ describe("Escape precision", () => {
// does. Six is the last depth a heading admits.
"\\###### six hashes",
"\\#\nsecond line",
// A backslash on the opening run is what holds a pair apart, and the closing run needs none
// once that opener has stopped being a delimiter. The sum rule spares a pair whose lengths are
// both multiples of three, and reaches none whose sum is not a multiple of three at all.
"a\\*\\*\\*b***",
"a\\*\\*b**",
// At the start of a block any start number opens a list; on a continuation line only one
// interrupts the paragraph.
"2\\. not an ordered list item",
Expand Down Expand Up @@ -830,6 +841,10 @@ describe("Escape precision", () => {
"**strong***trailing",
"x***strong**",
"x___strong__",
// A run the sum rule leaves nothing to pair with reopens as its own text whether or not a
// backslash holds it, which is the same blind spot.
"_**underscore and one asterisk stay literal*",
"x****strong**",
// A marker run too short to open a thematic break reopens as its own text whether or not a
// backslash holds it, which puts it in the same blind spot.
"**",
Expand Down
47 changes: 43 additions & 4 deletions src/features/editor/utils/markdownText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ const classifyCharacter = (character: string | undefined): CharacterClass => {
};

interface Flanking {
after: string | undefined;
before: string | undefined;
left: boolean;
next: CharacterClass;
previous: CharacterClass;
Expand All @@ -182,18 +184,35 @@ const readFlanking = (before: string | undefined, after: string | undefined): Fl
const next = classifyCharacter(after);

return {
after,
before,
left: next !== "whitespace" && (next !== "punctuation" || previous !== "other"),
next,
previous,
right: previous !== "whitespace" && (previous !== "punctuation" || next !== "other"),
};
};

// micromark reads a run as able to play a part wherever the character on that side is another
// attention marker, past what flanking alone gives it, and the GFM extension puts the tilde in
// that set. A tilde run is tokenized by strikethrough instead, which reads its flanking without
// the widening, so it is answered by flanking alone.
const facesMarker = (character: string, neighbour: string | undefined) =>
character !== "~" && neighbour !== undefined && ATTENTION_CHARACTERS.includes(neighbour);

const opensSide = (character: string, flanking: Flanking) =>
flanking.left || facesMarker(character, flanking.after);

const closesSide = (character: string, flanking: Flanking) =>
flanking.right || facesMarker(character, flanking.before);

const canOpenRun = (character: string, flanking: Flanking) =>
flanking.left && (character !== "_" || !flanking.right || flanking.previous === "punctuation");
opensSide(character, flanking) &&
(character !== "_" || !closesSide(character, flanking) || flanking.previous === "punctuation");

const canCloseRun = (character: string, flanking: Flanking) =>
flanking.right && (character !== "_" || !flanking.left || flanking.next === "punctuation");
closesSide(character, flanking) &&
(character !== "_" || !opensSide(character, flanking) || flanking.next === "punctuation");

const findAttentionRuns = (
slots: readonly EscapeSlot[],
Expand Down Expand Up @@ -406,6 +425,19 @@ const findMergedSide = (
: undefined;
};

// CommonMark refuses a pair whose two runs sum to a multiple of three without both being one,
// wherever either run can also play the other part. Both parts are known inside one text node, so
// a run the sum leaves literal reaches no counterpart and needs no backslash to hold it.
const pairsAcrossSum = (opening: AttentionRun, closing: AttentionRun) => {
const closingSize = closing.end - closing.start;

return (
(!opening.canClose && !closing.canOpen) ||
closingSize % 3 === 0 ||
(opening.end - opening.start + closingSize) % 3 !== 0
);
};

const relaxAttentionEscapes = (
slots: EscapeSlot[],
before: string,
Expand Down Expand Up @@ -448,7 +480,13 @@ const relaxAttentionEscapes = (
) ||
(mergedSide === "later" ? neighbors.laterRest : neighbors.later).includes(run.character) ||
(run.canOpen &&
runs.some((other) => counterpart(other) && other.start > run.start && other.canClose)) ||
runs.some(
(other) =>
counterpart(other) &&
other.start > run.start &&
other.canClose &&
pairsAcrossSum(run, other),
)) ||
// An earlier opener that kept its escape is no longer a delimiter, so it leaves nothing here
// to close. Runs are decided in order, so an earlier run's slot already holds its answer.
(run.canClose &&
Expand All @@ -457,7 +495,8 @@ const relaxAttentionEscapes = (
counterpart(other) &&
other.start < run.start &&
other.canOpen &&
!slots[other.start].escaped,
!slots[other.start].escaped &&
pairsAcrossSum(other, run),
));

if ((run.canOpen || run.canClose) && pairable) {
Expand Down