feat: preserve the authored autolink form on save - #243
Merged
Conversation
The two forms parse into the same link node, so the authored form has to be recorded at parse time or it is gone: only the source span separates them. A bare literal is written back through its own Markdown type, which keeps the default `link` handler reachable for every other link instead of reimplementing the angle-bracket and inline forms. It falls back to that handler when the neighbouring characters would hide the literal or pull them into its target, because a bare URL only survives where GFM reads it back.
Azganoth
marked this pull request as ready for review
August 15, 2026 20:10
This was referenced Aug 16, 2026
Azganoth
added a commit
that referenced
this pull request
Aug 16, 2026
## Summary Delivers the spike outcome from #135: an automated round-trip guard over the corpus subtrees whose syntax Leafdown supports. - Adds `src/features/editor/tests/corpusRoundTrip.test.tsx`, asserting that `serialize(parse(source))` and a second pass over that result produce the same string for 16 files across `corpus/commonmark/`, `corpus/gfm/`, and `corpus/isolated/end-of-file/`. The editor may normalize on first open; it must not keep changing the document afterwards. - Notes in `CONTRIBUTING.md` that those three directories now carry automated convergence coverage, so the manual corpus pass over them is for rendering, interaction, and navigator behavior. The remaining subtrees stay fully manual. ### Why convergence, and why it is interim Byte identity is the property worth wanting here: open a file, save it, the file is unchanged. It is not assertable today. Measured over the scoped set, **12 of the 16 files are rewritten on first open**, several by around 100 lines. Three causes: 1. **Escaping applied out of context**, the dominant one by volume. The serializer escapes characters that would not parse as syntax where they sit — `garden_sensor_name` → `garden\_sensor\_name` (intraword `_` is never emphasis), `*opening-only` → `\*opening-only` (an unpaired `*` is already literal), `name@example` → `name\@example`. This is not forced by the document model, and it is the open question in #245. 2. **Delimiter form the model does not retain**: `-` bullets → `*`, `~strike~` → `~~strike~~`, `_em_` → `*em*`. Fixable per construct, as #243 did for autolinks. 3. **Structural spelling the model does not retain**: lazy blockquote continuation gains an explicit `>`, tight lists are written loose. The 4 files that already round-trip byte-identically do so because they contain almost no inline constructs to escape, not because anything preserves authored form. So there is no meaningful identity-holding subset to pin, and no useful hybrid between the two assertions. Convergence is therefore chosen as an interim: it holds the editor to a stable document without blessing any particular normalization, needs no baseline or deviation list, and is what catches the #247 class of defect. It should tighten toward identity as #245 settles. A green run here does **not** mean the corpus saves unchanged, and this file should not be read as endorsing the current normalization. Nothing new was built to support this. `setupMilkdownEditorMount`, `createMarkdownReferenceContext`, and `mockTauriApiCommand` already cover it, and reading corpus files from disk follows `editorPresentation.test.tsx`. ## Related Issue Closes #135 Refs #247 ## Verification `corpusRoundTrip.test.tsx` proves the property directly: all 16 scoped files converge. The same test at the spike's baseline failed on three of them (`commonmark/lists-and-blockquotes.md`, `commonmark/text-and-breaks.md`, `gfm/tables.md`), which is what surfaced #247; those three pass here on top of its fix, so the file is a real guard rather than a tautology. Ran `pnpm check:frontend` for this frontend-only change. Not verified: rendering, interaction, and navigator behavior, which convergence says nothing about. This test never types, undoes, or copies. ## Notes Scope, so a green run is not over-read: - Convergence catches a normalization that keeps changing the document. It cannot catch one that is stable and wrong. `**one` followed by `**` on its own line normalizes to escaped literal text and converges cleanly — correct per CommonMark, since the closing delimiter run follows whitespace, but the point stands that "stops changing" is not "changes correctly". - The guard asserts the first two passes only. - It does not cover the open editing-surface defects (#231, #232, #233, #235, #244). Deliberately excluded, per the analysis in #135: - `corpus/boundaries/bytes/`, which exists to pin CR, CRLF, BOM, NUL, and a missing final newline — precisely what reading and serializing normalizes away. - `corpus/environment/`, whose subject needs the Rust backend and is already covered by the `createArticleTree` and `createFolderContext` factories. - `corpus/extensions/`, which mostly asserts that unsupported syntax survives as escaped literal text, already covered in `markdownCompatibility.test.tsx`. - `corpus/practical/`, whose local image and link references would turn this into per-path resolution mocks. Three counts in the #135 analysis were off and are corrected by this branch and its `Outcome` section: - `isolated/end-of-file/` holds 4 files rather than 3, so the scoped total is 16 rather than 15. - Only `commonmark/links-and-images.md` needs the image resolver mock, not two files. - "Nine of the fifteen candidate files contain a construct the serializer normalizes" understates it substantially. Measured, 12 of 16 differ from source, and the listed constructs are not the main driver — out-of-context escaping is. Observed while measuring, not investigated and not claimed as a defect: tight lists are serialized loose (`- A\n- B` → `* A\n\n* B`), which changes rendered output (`<li>A</li>` versus `<li><p>A</p></li>`). Whether the schema distinguishes tight from loose at all is unchecked. Recorded in #135 for whoever picks up #245.
This was referenced Aug 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A URL authored as a GFM autolink literal is now written back as one, and one authored with angle brackets keeps them. Editor rendering is unchanged either way.
linkhandler reachable for every other link rather than reimplementing the angle-bracket and inline forms, and the bare handler delegates to it whenever the literal is not safe to write.data-bare-autolink, because an in-editor copy and paste round-trips through the DOM rather than through Markdown.Related Issue
Closes #240
Verification
Markdown compatibility fixtures cover both forms surviving open and save, on their own and mixed in one paragraph, including the
www.and address literals whose targets GFM rewrites, trailing punctuation, and a balanced and an unmatched parenthesis in a path. Two further fixtures pin the fallbacks: an edit that makes the text stop spelling the target writes the inline form, and an edit that closes text in on the link writes the angle-bracket form. Source-projection tests assert that both forms project as their authored source and commit unchanged, and native clipboard tests carry both forms through a copy and a paste.Round-tripped
corpus/gfm/autolinks.mdagainst this branch and againstmain. Every autolink line in it is now written back unchanged; onmaineach one was rewritten,www.example.com/pathinto an inline link and addresses into angle-bracket autolinks. The remaining differences in that file are unrelated pre-existing normalization, identical on both branches.Not verified: the desktop E2E suite and manual verification in the Tauri application. The change is serialization and projected document text, both observable in the automated DOM tests.
Notes
The neighbour check uses character sets measured against the GFM version this repository pins rather than derived from the specification text. If those drift, the failure mode is a bare URL written as
<https://…>, which is the behavior before this change, not a corrupted target.Preserving the form across the clipboard goes past the issue's acceptance criteria, which cover open, save, and projection. Without it a bare URL copied and pasted inside the editor would gain angle brackets on the next save, which reads as the same churn the issue removes.