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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## 2026-09-30

### Fixed
- URL rendering in a note.

---

## 2026-09-29

### Changed
- Email sending migrated from Mailgun (named templates) to Resend (HTML bodies from local templates). The `MAILGUN_APIKEY` environment variable was replaced by `RESEND_APIKEY`.

---

## 2026-09-28

### Changed
Expand Down
56 changes: 56 additions & 0 deletions client/src/__test__/components/linkInputRule.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { Schema } from '@milkdown/kit/prose/model';
import { EditorState } from '@milkdown/kit/prose/state';
import { LINK_INPUT_REGEX, createLinkInputRule } from '../../components/MarkdownEditor/linkInputRule';

const schema = new Schema({
nodes: {
doc: { content: 'paragraph+' },
paragraph: { content: 'text*', group: 'block' },
text: {}
},
marks: {
link: {
attrs: { href: { default: '' }, title: { default: null } },
toDOM: mark => ['a', mark.attrs, 0],
parseDOM: [{ tag: 'a[href]' }]
}
}
});

const buildState = (typed: string) => {
const state = EditorState.create({ schema });
return state.apply(state.tr.insertText(typed));
};

describe('createLinkInputRule', () => {
it('should convert typed markdown link syntax into a link mark', () => {
const typed = '[text](https://my-link.com)';
const state = buildState(typed);
const match = typed.match(LINK_INPUT_REGEX);
expect(match).not.toBeNull();

const rule = createLinkInputRule(schema.marks.link);
const tr = rule.handler(state, match!, 1, 1 + typed.length);
expect(tr).not.toBeNull();

const result = state.apply(tr!);
const textNode = result.doc.firstChild!.firstChild!;
expect(textNode.text).toBe('text');
const link = textNode.marks.find(mark => mark.type === schema.marks.link);
expect(link?.attrs.href).toBe('https://my-link.com');
});

it('should match only the link suffix of the text before the cursor', () => {
const typed = 'see [docs](https://example.com/docs)';
const match = typed.match(LINK_INPUT_REGEX);
expect(match?.groups?.text).toBe('docs');
expect(match?.groups?.href).toBe('https://example.com/docs');
});

it('should not match plain text or incomplete link syntax', () => {
expect('just some text'.match(LINK_INPUT_REGEX)).toBeNull();
expect('[text](https://my-link.com'.match(LINK_INPUT_REGEX)).toBeNull();
expect('[text] (https://my-link.com)'.match(LINK_INPUT_REGEX)).toBeNull();
});
});
2 changes: 2 additions & 0 deletions client/src/components/MarkdownEditor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { commonmark } from '@milkdown/kit/preset/commonmark';
import { gfm } from '@milkdown/kit/preset/gfm';
import { history } from '@milkdown/kit/plugin/history';
import { listener, listenerCtx } from '@milkdown/kit/plugin/listener';
import { insertLinkInputRule } from './linkInputRule';
import '@milkdown/kit/prose/view/style/prosemirror.css';
import '@milkdown/kit/prose/tables/style/tables.css';

Expand Down Expand Up @@ -36,6 +37,7 @@ const MarkdownEditorInner = forwardRef<MarkdownEditorHandle, MarkdownEditorProps
.use(gfm)
.use(listener)
.use(history)
.use(insertLinkInputRule)
);

useImperativeHandle(ref, () => ({
Expand Down
28 changes: 28 additions & 0 deletions client/src/components/MarkdownEditor/linkInputRule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { InputRule } from '@milkdown/kit/prose/inputrules';
import type { MarkType } from '@milkdown/kit/prose/model';
import { linkSchema } from '@milkdown/kit/preset/commonmark';
import { $inputRule } from '@milkdown/kit/utils';

export const LINK_INPUT_REGEX = /\[(?<text>[^\]]+)\]\((?<href>[^)\s]+)\)$/;

/**
* Creates a ProseMirror input rule that converts typed markdown link syntax
* (`[text](url)`) into a link mark as soon as the closing parenthesis is typed.
*
* @param {MarkType} linkType - The link mark type from the editor schema.
* @returns {InputRule} The input rule for markdown links.
*/
export const createLinkInputRule = (linkType: MarkType): InputRule =>
new InputRule(LINK_INPUT_REGEX, (state, match, start, end) => {
const text = match.groups?.text;
const href = match.groups?.href;
if (!text || !href) return null;
const tr = state.tr.insertText(text, start, end);
tr.addMark(start, start + text.length, linkType.create({ href }));
return tr;
});

/**
* Milkdown plugin wiring the markdown link input rule into the editor schema.
*/
export const insertLinkInputRule = $inputRule(ctx => createLinkInputRule(linkSchema.type(ctx)));
Loading