Skip to content

feat: preserve the authored autolink form on save - #243

Merged
Azganoth merged 1 commit into
mainfrom
feature/authored-autolink-form
Aug 15, 2026
Merged

feat: preserve the authored autolink form on save#243
Azganoth merged 1 commit into
mainfrom
feature/authored-autolink-form

Conversation

@Azganoth

Copy link
Copy Markdown
Owner

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.

  • The two forms parse into the same link node, so the authored form is recorded on the link mark at parse time, where the source span still distinguishes them, and honored at serialize time.
  • A bare literal serializes under its own Markdown type. That keeps the default link handler 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.
  • A bare literal is only written where GFM reads it back: its text must still spell its target, and its neighbouring characters must neither hide the link nor join its target. Otherwise the link falls back to the angle-bracket or inline form, so a saved target cannot change.
  • The form travels through the DOM as data-bare-autolink, because an in-editor copy and paste round-trips through the DOM rather than through Markdown.
  • Link label bounds in source projection now start at the first child's offset instead of one past the link's, which was correct only for a form with an opening marker.

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.md against this branch and against main. Every autolink line in it is now written back unchanged; on main each one was rewritten, www.example.com/path into 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.

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 Azganoth added the Feature New feature or request label Aug 15, 2026
@Azganoth Azganoth self-assigned this Aug 15, 2026
@Azganoth
Azganoth marked this pull request as ready for review August 15, 2026 20:10
@Azganoth
Azganoth merged commit 07f5893 into main Aug 15, 2026
3 checks passed
@Azganoth
Azganoth deleted the feature/authored-autolink-form branch August 15, 2026 20:10
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Preserve the authored autolink form on save

1 participant