diff --git a/CHANGELOG.md b/CHANGELOG.md index 634a388..f35e7a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/client/src/__test__/components/linkInputRule.test.ts b/client/src/__test__/components/linkInputRule.test.ts new file mode 100644 index 0000000..0e36e05 --- /dev/null +++ b/client/src/__test__/components/linkInputRule.test.ts @@ -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(); + }); +}); diff --git a/client/src/components/MarkdownEditor/index.tsx b/client/src/components/MarkdownEditor/index.tsx index c11d518..76c4e53 100644 --- a/client/src/components/MarkdownEditor/index.tsx +++ b/client/src/components/MarkdownEditor/index.tsx @@ -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'; @@ -36,6 +37,7 @@ const MarkdownEditorInner = forwardRef ({ diff --git a/client/src/components/MarkdownEditor/linkInputRule.ts b/client/src/components/MarkdownEditor/linkInputRule.ts new file mode 100644 index 0000000..331c647 --- /dev/null +++ b/client/src/components/MarkdownEditor/linkInputRule.ts @@ -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 = /\[(?[^\]]+)\]\((?[^)\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)));