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 @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<br />` and deletes every `<br>` 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 `<https://…>`, 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 `<https://…>`, 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
Expand Down
2 changes: 1 addition & 1 deletion docs/specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions src/features/editor/plugins/sourceProjection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,18 @@ describe("source projection", () => {
).toHaveTextContent("Link");
});

it.each([
{ name: "bare", source: "tail https://example.com" },
{ name: "angle-bracket", source: "tail <https://example.com>" },
])("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***");

Expand Down
48 changes: 44 additions & 4 deletions src/features/editor/tests/markdownCompatibility.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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~~, <https://example.com>, and [link](docs/readme.md).
Paragraph with *emphasis*, **strong**, \`code\`, ~~strike~~, https://example.com, and [link](docs/readme.md).

> Quote

Expand Down Expand Up @@ -177,6 +181,42 @@ describe("Markdown compatibility", () => {
expect(mounted.getMarkdown()).toBe(`${source}\n`);
});

it.each([
"https://example.com",
"<https://example.com>",
"<a@b.com>",
"www.example.com/path",
"testing@example.com and first.last+tag@example.co.uk",
"Mixed https://example.com and <https://leafdown.dev>",
"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<https://example.com>\n");
});

it.each([
"[plain\nlabel](docs/readme.md)",
'[**bold** and\n*soft*](docs/readme.md "Title")',
Expand Down
21 changes: 20 additions & 1 deletion src/features/editor/tests/nativeClipboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: "<https://example.com>" },
])("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("");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ describe("multiline logical-link source projection", () => {
JSON.stringify({
href: "./article-navigator/01-overview.md",
title: "Calibration review",
isBareAutolink: false,
}),
]),
);
Expand Down
145 changes: 145 additions & 0 deletions src/features/editor/utils/bareAutolinkMarkdown.ts
Original file line number Diff line number Diff line change
@@ -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<typeof remarkStringifyOptionsCtx._typeInfo>["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<RemarkStringifyHandlers["link"]> = (
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<string, unknown>,
...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 },
);
},
},
};
};
13 changes: 11 additions & 2 deletions src/features/editor/utils/createMilkdownEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 6 additions & 2 deletions src/features/editor/utils/sourceProjectionLinkSyntax.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
};
Expand Down