diff --git a/CHANGELOG.md b/CHANGELOG.md
index d846f04..d9b9551 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0
- Extend seamless in-document Markdown source projection to strikethrough, inline code, links, autolinks, and footnote references.
- Present active mixed-format link labels as one coordinated source range.
- Extend link label source projection to labels that contain an image, such as badge links.
+- Keep a URL written on its own as it was written, bare or between angle brackets, instead of putting angle brackets around every bare URL in the file on the first save.
### Fixed
diff --git a/docs/decisions.md b/docs/decisions.md
index d175242..9c53b02 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -138,7 +138,7 @@
- Defer to GFM preset defaults unless explicitly overridden by the specification.
- The preset's empty-line mechanism is overridden. It encodes a blank paragraph as an emitted `
` and deletes every `
` it finds on parse, which consumes authored raw HTML as editor state and writes a visible line break into documents other readers render. Leafdown carries a blank paragraph in blank lines instead, decided in [issue #193](https://github.com/Azganoth/leafdown/issues/193).
-- Bare GFM URL literals and angle-bracket autolinks share Milkdown's canonical link representation. Milkdown serializes eligible bare URLs as ``, so source projection exposes that canonical serialized form. Leafdown does not preserve bare-versus-angle source provenance or bypass projection for bare URLs.
+- The preset's single canonical autolink form is overridden. Bare GFM URL literals and angle-bracket autolinks parse into the same link, which Milkdown serializes as ``, rewriting every bare URL in a file on its first save. Leafdown records the authored form on the link mark, decided in [issue #240](https://github.com/Azganoth/leafdown/issues/240), and writes and projects each form as authored. A bare literal falls back to the angle-bracket form when its neighbouring characters would hide it or extend its target, because a bare URL only survives where GFM reads it back.
- Develop custom UI components only when required by the product specification.
### Do not use Crepe
diff --git a/docs/specification.md b/docs/specification.md
index b3155b2..38ad519 100644
--- a/docs/specification.md
+++ b/docs/specification.md
@@ -149,7 +149,7 @@ The editor is a unified hybrid Markdown surface. Behavior is governed by renderi
- Strong, emphasis, inline code, and strikethrough render visually and expose editable local markers near the caret.
- Seamless source projection for strong, emphasis, strikethrough, inline code, links, autolinks, and footnote references is local to the active inline object. For mark-based content, a caret or text selection activates projection when it is contained within one exact, contiguous combination of supported inline marks. Editing a projected marker can change that object's inline style, but it does not automatically merge adjacent marked runs; broader reshaping is done with an explicit selection or formatting command.
- Inline-code projection uses a valid canonical backtick delimiter run rather than preserving the exact source delimiter length.
-- Link and autolink projection exposes their source directly in the document; links preserve their label, target, optional title, and compatible uniform outer inline formatting. A link remains one semantic projection owner. A caret or contained text selection anywhere in a supported label projects the complete link source, including labels with nested strong, emphasis, strikethrough, inline-code formatting, semantic soft line endings, an image, or a footnote reference. An image or footnote reference in a projected label becomes its own Markdown source and returns as its object when the label commits. Soft line endings remain one logical label; indentation follows Leafdown's canonical serialization. Valid edits rehydrate one link over the complete rich label; invalid or incomplete edits become exact literal text. Mixed-format and multiline labels do not fall back to fragmented projections for their nested content.
+- Link and autolink projection exposes their source directly in the document; links preserve their label, target, optional title, and compatible uniform outer inline formatting. An autolink keeps the form it was authored in, bare or angle-bracket, when it is projected and when it is saved. A link remains one semantic projection owner. A caret or contained text selection anywhere in a supported label projects the complete link source, including labels with nested strong, emphasis, strikethrough, inline-code formatting, semantic soft line endings, an image, or a footnote reference. An image or footnote reference in a projected label becomes its own Markdown source and returns as its object when the label commits. Soft line endings remain one logical label; indentation follows Leafdown's canonical serialization. Valid edits rehydrate one link over the complete rich label; invalid or incomplete edits become exact literal text. Mixed-format and multiline labels do not fall back to fragmented projections for their nested content.
- A link wrapped by one exact, contiguous supported mark combination belongs to that marked fragment. Entering from either side of the link projects one outer wrapper holding the link's complete source, such as `**bold [a b](./doc.md) tail**`, and a valid edit commits one mark around the link, its label, and its destination. A mark that stops at the link keeps its own projection, and logical links retain higher semantic ownership, so a caret inside the label still projects the link alone.
- A footnote reference within one exact, contiguous supported mark combination belongs to that marked fragment. Entering through its text, either reference boundary, or the atomic reference projects one outer wrapper such as `**archive note[^archive]**`; the complete compatible mark set applies to both text and reference nodes. Logical links retain higher semantic ownership, while standalone or otherwise ineligible references use the reference-only adapter.
- Standalone footnote references project their complete `[^label]` source as editable document text. A caret entering from the left starts at the beginning of the source, a caret entering from the right starts at the end, and selecting an atomic reference selects its label after projection. Valid edits in either projection rehydrate canonical Milkdown footnote-reference nodes. If a marked wrapper remains valid, incomplete reference-like content remains exact text inside its outer marks; if the outer wrapper becomes invalid, the complete projected source becomes exact unmarked literal text. Editing a reference label does not create, rename, delete, or modify any footnote definition.
diff --git a/src/features/editor/plugins/sourceProjection.test.tsx b/src/features/editor/plugins/sourceProjection.test.tsx
index 21c2294..e8e2262 100644
--- a/src/features/editor/plugins/sourceProjection.test.tsx
+++ b/src/features/editor/plugins/sourceProjection.test.tsx
@@ -195,6 +195,18 @@ describe("source projection", () => {
).toHaveTextContent("Link");
});
+ it.each([
+ { name: "bare", source: "tail https://example.com" },
+ { name: "angle-bracket", source: "tail " },
+ ])("projects a $name autolink as its authored source", async ({ source }) => {
+ const mounted = await mountProjectionEditor(source);
+
+ enterProjection(mounted, "a");
+
+ expect(getEditorTextContent(mounted)).toBe(source);
+ expect(mounted.getMarkdown()).toBe(`${source}\n`);
+ });
+
it("projects only the exact mark combination around the caret", async () => {
const mounted = await mountProjectionEditor("***Bold and italic***");
diff --git a/src/features/editor/tests/markdownCompatibility.test.tsx b/src/features/editor/tests/markdownCompatibility.test.tsx
index 1200195..5b60cd0 100644
--- a/src/features/editor/tests/markdownCompatibility.test.tsx
+++ b/src/features/editor/tests/markdownCompatibility.test.tsx
@@ -3,7 +3,11 @@ import { describe, expect, it, vi } from "vitest";
import { createMarkdownReferenceContext } from "@/test/factories/editor";
import { BASIC_TABLE_MARKDOWN } from "@/test/fixtures/editorMarkdown";
import { setupMilkdownEditorMount } from "@/test/utils/milkdown";
-import { setSelectionAtDocumentEnd, typeText } from "@/test/utils/prosemirror";
+import {
+ getEditorTextPosition,
+ setSelectionAtDocumentEnd,
+ typeText,
+} from "@/test/utils/prosemirror";
import { waitFor } from "@/test/utils/react";
import { mockTauriApiCommand } from "@/test/utils/tauriApi";
@@ -39,11 +43,11 @@ Footnote[^1]
[^1]: Footnote text`;
// Milkdown serializer defaults normalize several source markers:
-// unordered/task markers become `*`, thematic breaks become `***`, bare URLs
-// become autolink syntax, and serialized output includes a final newline.
+// unordered/task markers become `*`, thematic breaks become `***`, and
+// serialized output includes a final newline.
const supportedMarkdownExpected = `# Heading
-Paragraph with *emphasis*, **strong**, \`code\`, ~~strike~~, , and [link](docs/readme.md).
+Paragraph with *emphasis*, **strong**, \`code\`, ~~strike~~, https://example.com, and [link](docs/readme.md).
> Quote
@@ -177,6 +181,42 @@ describe("Markdown compatibility", () => {
expect(mounted.getMarkdown()).toBe(`${source}\n`);
});
+ it.each([
+ "https://example.com",
+ "",
+ "",
+ "www.example.com/path",
+ "testing@example.com and first.last+tag@example.co.uk",
+ "Mixed https://example.com and ",
+ "Parenthesis before the link: (www.example.com)",
+ "Visit https://example.com/one, https://example.com/two. and (https://example.com/three).",
+ "Balanced path: https://example.com/a(b)c and unmatched path: https://example.com/a(b)).",
+ "**https://example.com**",
+ "[https://example.com](https://leafdown.dev)",
+ ])("keeps the authored autolink form in %s", async (source) => {
+ const mounted = await mountEditor(source);
+
+ expect(mounted.getMarkdown()).toBe(`${source}\n`);
+ });
+
+ it("writes a bare autolink as a full link once its text stops spelling its target", async () => {
+ const mounted = await mountEditor("https://example.com");
+ const textPosition = getEditorTextPosition(mounted, "example.com");
+
+ mounted.view.dispatch(mounted.view.state.tr.insertText(" ", textPosition));
+
+ expect(mounted.getMarkdown()).toBe("[https:// example.com](https://example.com)\n");
+ });
+
+ it("writes a bare autolink with angle brackets once an edit closes text in on it", async () => {
+ const mounted = await mountEditor("tail https://example.com");
+ const spacePosition = getEditorTextPosition(mounted, " https://example.com");
+
+ mounted.view.dispatch(mounted.view.state.tr.delete(spacePosition, spacePosition + 1));
+
+ expect(mounted.getMarkdown()).toBe("tail\n");
+ });
+
it.each([
"[plain\nlabel](docs/readme.md)",
'[**bold** and\n*soft*](docs/readme.md "Title")',
diff --git a/src/features/editor/tests/nativeClipboard.test.tsx b/src/features/editor/tests/nativeClipboard.test.tsx
index 2a29481..f0ed6d5 100644
--- a/src/features/editor/tests/nativeClipboard.test.tsx
+++ b/src/features/editor/tests/nativeClipboard.test.tsx
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { TEXT_HTML_MIME_TYPE, TEXT_PLAIN_MIME_TYPE } from "@/lib/mime";
import { BOLD_PLAIN_MARKDOWN } from "@/test/fixtures/editorMarkdown";
-import { dispatchClipboardEvent, dispatchKeyDown } from "@/test/utils/events";
+import { createClipboardData, dispatchClipboardEvent, dispatchKeyDown } from "@/test/utils/events";
import { setupMilkdownEditorMount } from "@/test/utils/milkdown";
import {
getEditorDomElement,
@@ -41,6 +41,25 @@ describe("native editor clipboard events", () => {
expect(mounted.getMarkdown()).toBe("**Rich** text\n");
});
+ it.each([
+ { name: "bare", source: "https://example.com" },
+ { name: "angle-bracket", source: "" },
+ ])("carries the $name autolink form through a copy and a paste", async ({ source }) => {
+ const copied = await mountEditor(source);
+ const clipboardData = createClipboardData();
+
+ setTextSelection(copied.view, 1, copied.view.state.doc.content.size - 1);
+ dispatchClipboardEvent(copied.view.dom, "copy", clipboardData);
+
+ const pasted = await mountEditor("");
+
+ dispatchClipboardEvent(pasted.view.dom, "paste", {
+ [TEXT_HTML_MIME_TYPE]: clipboardData.getData(TEXT_HTML_MIME_TYPE),
+ });
+
+ expect(pasted.getMarkdown()).toBe(`${source}\n`);
+ });
+
it("preserves semantic HTML-only content outside source projection", async () => {
const mounted = await mountEditor("");
diff --git a/src/features/editor/tests/sourceProjectionMultilineLink.test.tsx b/src/features/editor/tests/sourceProjectionMultilineLink.test.tsx
index 72be399..afb4694 100644
--- a/src/features/editor/tests/sourceProjectionMultilineLink.test.tsx
+++ b/src/features/editor/tests/sourceProjectionMultilineLink.test.tsx
@@ -255,6 +255,7 @@ describe("multiline logical-link source projection", () => {
JSON.stringify({
href: "./article-navigator/01-overview.md",
title: "Calibration review",
+ isBareAutolink: false,
}),
]),
);
diff --git a/src/features/editor/utils/bareAutolinkMarkdown.ts b/src/features/editor/utils/bareAutolinkMarkdown.ts
new file mode 100644
index 0000000..6c634b9
--- /dev/null
+++ b/src/features/editor/utils/bareAutolinkMarkdown.ts
@@ -0,0 +1,145 @@
+import type { remarkStringifyOptionsCtx } from "@milkdown/kit/core";
+import type { TagParseRule } from "@milkdown/kit/prose/model";
+import type { MarkdownNode, MarkSchema } from "@milkdown/kit/transformer";
+
+type RemarkStringifyHandlers = NonNullable<
+ ReturnType["handlers"]
+>;
+
+export const BARE_AUTOLINK_MARKDOWN_TYPE = "leafdownBareAutolink";
+
+const LINK_MARKDOWN_TYPE = "link";
+const BARE_AUTOLINK_ATTRIBUTE_NAME = "isBareAutolink";
+const BARE_AUTOLINK_DOM_ATTRIBUTE_NAME = "data-bare-autolink";
+const HTTP_URL_PATTERN = /^https?:\/\//iu;
+const WWW_URL_PATTERN = /^www\./iu;
+const EMAIL_PATTERN = /^[^@]+@[^@]+\.[^@]+$/u;
+const PRECEDING_LETTER_PATTERN = /[A-Za-z]$/u;
+const TRIMMED_FOLLOWING_PATTERN = /^[\s!"'*,.:;\\\]_~]?$/u;
+
+const getBareAutolinkUrl = (value: string) => {
+ if (/[\s<]/u.test(value)) {
+ return null;
+ }
+
+ if (HTTP_URL_PATTERN.test(value)) {
+ return value;
+ }
+
+ if (WWW_URL_PATTERN.test(value)) {
+ return `http://${value}`;
+ }
+
+ return EMAIL_PATTERN.test(value) ? `mailto:${value}` : null;
+};
+
+const getBareAutolinkValue = (node: MarkdownNode) => {
+ const [child, ...rest] = node.children ?? [];
+
+ if (rest.length > 0 || child?.type !== "text" || typeof child.value !== "string" || node.title) {
+ return null;
+ }
+
+ return getBareAutolinkUrl(child.value) === node.url ? child.value : null;
+};
+
+const countCharacter = (value: string, character: string) => value.split(character).length - 1;
+
+const isReadableWhereItLands = (value: string, before: string, after: string) => {
+ const following = after.charAt(0);
+
+ return (
+ !PRECEDING_LETTER_PATTERN.test(before) &&
+ (TRIMMED_FOLLOWING_PATTERN.test(following) ||
+ // GFM leaves a trailing `)` out of the target only while the literal closes every
+ // parenthesis it opens.
+ (following === ")" && countCharacter(value, "(") <= countCharacter(value, ")")))
+ );
+};
+
+export const serializeBareAutolink: NonNullable = (
+ node,
+ parent,
+ state,
+ info,
+) => {
+ const value = getBareAutolinkValue(node as MarkdownNode);
+
+ return value !== null && isReadableWhereItLands(value, info.before, info.after)
+ ? value
+ : state.handle({ ...node, type: LINK_MARKDOWN_TYPE }, parent, state, info);
+};
+
+// Angle brackets sit outside the label, while a bare literal spans its target exactly.
+const isBareAutolinkNode = (node: MarkdownNode) => {
+ const start = node.position?.start.offset;
+
+ return start !== undefined && start === node.children?.[0]?.position?.start.offset;
+};
+
+export const withBareAutolinkForm = (schema: MarkSchema): MarkSchema => {
+ const { toDOM } = schema;
+
+ return {
+ ...schema,
+ attrs: {
+ ...schema.attrs,
+ [BARE_AUTOLINK_ATTRIBUTE_NAME]: { default: false, validate: "boolean" },
+ },
+ // The link mark matches anchors, so every rule it declares is a tag rule.
+ parseDOM: (schema.parseDOM as TagParseRule[] | undefined)?.map((rule) => ({
+ ...rule,
+ getAttrs: (dom: HTMLElement) => {
+ const attrs = rule.getAttrs?.(dom);
+
+ return attrs === false
+ ? false
+ : {
+ ...attrs,
+ [BARE_AUTOLINK_ATTRIBUTE_NAME]: dom.hasAttribute(BARE_AUTOLINK_DOM_ATTRIBUTE_NAME),
+ };
+ },
+ })),
+ toDOM:
+ toDOM &&
+ ((mark, inline) => {
+ const [tag, attributes, ...rest] = toDOM(mark, inline) as [
+ string,
+ Record,
+ ...unknown[],
+ ];
+ const { [BARE_AUTOLINK_ATTRIBUTE_NAME]: isBareAutolink, ...rendered } = attributes;
+
+ return [
+ tag,
+ isBareAutolink ? { ...rendered, [BARE_AUTOLINK_DOM_ATTRIBUTE_NAME]: "" } : rendered,
+ ...rest,
+ ];
+ }),
+ parseMarkdown: {
+ ...schema.parseMarkdown,
+ runner: (state, node, markType) => {
+ state.openMark(markType, {
+ href: node.url,
+ [BARE_AUTOLINK_ATTRIBUTE_NAME]: isBareAutolinkNode(node),
+ title: node.title,
+ });
+ state.next(node.children);
+ state.closeMark(markType);
+ },
+ },
+ toMarkdown: {
+ ...schema.toMarkdown,
+ runner: (state, mark) => {
+ state.withMark(
+ mark,
+ mark.attrs[BARE_AUTOLINK_ATTRIBUTE_NAME]
+ ? BARE_AUTOLINK_MARKDOWN_TYPE
+ : LINK_MARKDOWN_TYPE,
+ undefined,
+ { title: mark.attrs.title, url: mark.attrs.href },
+ );
+ },
+ },
+ };
+};
diff --git a/src/features/editor/utils/createMilkdownEditor.ts b/src/features/editor/utils/createMilkdownEditor.ts
index 1b2316b..86af4db 100644
--- a/src/features/editor/utils/createMilkdownEditor.ts
+++ b/src/features/editor/utils/createMilkdownEditor.ts
@@ -61,6 +61,11 @@ import {
import { createLeafdownTableKeyboardPlugin } from "../plugins/tableKeyboard";
import { createLeafdownTaskListCheckboxPlugin } from "../plugins/taskListCheckbox";
import { createLeafdownTrailingParagraphPlugin } from "../plugins/trailingParagraph";
+import {
+ BARE_AUTOLINK_MARKDOWN_TYPE,
+ serializeBareAutolink,
+ withBareAutolinkForm,
+} from "./bareAutolinkMarkdown";
import { normalizeProseMirrorClipboardHtml } from "./clipboardHtml";
import { createLeafdownHighlightParser } from "./highlighting";
import type { MarkdownLinkContext } from "./linkActivation";
@@ -190,14 +195,18 @@ export const createMilkdownEditor = async ({
});
ctx.update(remarkStringifyOptionsCtx, (options) => ({
...options,
- handlers: { ...options.handlers, text: serializeMarkdownText },
+ handlers: {
+ ...options.handlers,
+ [BARE_AUTOLINK_MARKDOWN_TYPE]: serializeBareAutolink,
+ text: serializeMarkdownText,
+ },
}));
ctx.update(hardbreakSchema.key, (getSchema) => (schemaCtx) => ({
...getSchema(schemaCtx),
linebreakReplacement: true,
}));
ctx.update(linkSchema.key, (getSchema) => (schemaCtx) => ({
- ...getSchema(schemaCtx),
+ ...withBareAutolinkForm(getSchema(schemaCtx)),
priority: LINK_MARK_PRIORITY,
}));
ctx.set(defaultValueCtx, initialMarkdown);
diff --git a/src/features/editor/utils/sourceProjectionLinkSyntax.ts b/src/features/editor/utils/sourceProjectionLinkSyntax.ts
index 7d0ae3b..a7341a8 100644
--- a/src/features/editor/utils/sourceProjectionLinkSyntax.ts
+++ b/src/features/editor/utils/sourceProjectionLinkSyntax.ts
@@ -205,20 +205,24 @@ const getLogicalLinkNode = (root: MarkdownNode, sourceLength: number) => {
const getLinkLabelBounds = (link: MarkdownNode) => {
const linkPosition = getMarkdownPosition(link);
+ const firstChild = link.children?.[0];
+ const firstChildPosition = firstChild ? getMarkdownPosition(firstChild) : null;
const lastChild = link.children?.at(-1);
const lastChildPosition = lastChild ? getMarkdownPosition(lastChild) : null;
if (
!linkPosition ||
+ !firstChildPosition ||
!lastChildPosition ||
lastChildPosition.to > linkPosition.to ||
- linkPosition.from + 1 > lastChildPosition.to
+ firstChildPosition.from < linkPosition.from ||
+ firstChildPosition.from > lastChildPosition.to
) {
return null;
}
return {
- from: linkPosition.from + 1,
+ from: firstChildPosition.from,
to: lastChildPosition.to,
};
};