Skip to content

Commit

Permalink
feat(extension-link): auto link improvements (#1625)
Browse files Browse the repository at this point in the history
* feat(prosemirror-paste-rules): add ability to invalidate match

* fix(link): tweak autolink regex to prevent ordered list interaction. Fixes #1585

* feat(link): add ability to restrict auto link TLDs
  • Loading branch information
whawker committed May 5, 2022
1 parent b7617a9 commit 755f9fc
Show file tree
Hide file tree
Showing 10 changed files with 410 additions and 15 deletions.
18 changes: 18 additions & 0 deletions .changeset/pretty-squids-bake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@remirror/extension-link': minor
---

Add support for `autoLinkAllowedTLDs` which enables the restriction of auto links to a set of Top Level Domains (TLDs). Defaults to the top 50 TLDs (as of May 2022).

For a more complete list, you could replace this with the `tlds` or `global-list-tlds` packages.

Or to extend the default list you could

```ts
import { LinkExtension, TOP_50_TLDS } from 'remirror/extensions';
const extensions = () => [
new LinkExtension({ autoLinkAllowedTLDs: [...TOP_50_TLDS, 'london', 'tech'] }),
];
```

Tweak auto link regex to prevent match of single digit domains (i.e. 1.com) and remove support for hostnames ending with "." i.e. "remirror.io."
5 changes: 5 additions & 0 deletions .changeset/twenty-ravens-greet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'prosemirror-paste-rules': minor
---

Allow `transformMatch` to invalidate a paste rule by explicitly returning `false`
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,40 @@ describe('pasteRules', () => {
});
});

it('can remove text matches', () => {
const plugin = pasteRules([
{
regexp: /(@[a-z]+)/,
markType: schema.marks.strong,
transformMatch: () => '',
type: 'mark',
},
]);
createEditor(doc(p('<cursor>')), { plugins: [plugin] })
.paste(doc(p('Some @test @content'), p('should @be amazing')))
.callback((content) => {
expect(content.doc).toEqualProsemirrorNode(doc(p('Some '), p('should amazing')));
});
});

it('can keep text but not add mark', () => {
const plugin = pasteRules([
{
regexp: /(@[a-z]+)/,
markType: schema.marks.strong,
transformMatch: () => false,
type: 'mark',
},
]);
createEditor(doc(p('<cursor>')), { plugins: [plugin] })
.paste(doc(p('Some @test @content'), p('should @be amazing')))
.callback((content) => {
expect(content.doc).toEqualProsemirrorNode(
doc(p('Some @test @content'), p('should @be amazing')),
);
});
});

it('should replace selection', () => {
const plugin1 = pasteRules([
{
Expand Down Expand Up @@ -256,6 +290,15 @@ describe('pasteRules', () => {
});
});

it('can remove text matches by returning false', () => {
const plugin = pasteRules([{ regexp: /(Hello)/, transformMatch: () => false, type: 'text' }]);
createEditor(doc(p('Hello <cursor>')), { plugins: [plugin] })
.paste('Hello')
.callback((content) => {
expect(content.doc).toEqualProsemirrorNode(doc(p('Hello ')));
});
});

it('can transform from matched regex', () => {
const plugin = pasteRules([{ regexp: /abc(Hello)abc/, type: 'text' }]);
createEditor(doc(p('<cursor>')), { plugins: [plugin] })
Expand Down
26 changes: 18 additions & 8 deletions packages/prosemirror-paste-rules/src/paste-rules-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,8 +269,10 @@ export interface MarkPasteRule extends BaseContentPasteRule {
* A function that transforms the match into the desired text value.
*
* Return an empty string to delete all content.
*
* Return `false` to invalidate the match.
*/
transformMatch?: (match: RegExpExecArray) => string | null | undefined;
transformMatch?: (match: RegExpExecArray) => string | null | undefined | false;
}

export interface NodePasteRule extends BaseContentPasteRule {
Expand Down Expand Up @@ -311,8 +313,10 @@ export interface TextPasteRule extends BaseRegexPasteRule {
* A function that transforms the match into the desired text value.
*
* Return an empty string to delete all content.
*
* Return `false` to invalidate the match.
*/
transformMatch?: (match: RegExpExecArray) => string | null | undefined;
transformMatch?: (match: RegExpExecArray) => string | null | undefined | false;
}

export type FileHandlerProps = FilePasteHandlerProps | FileDropHandlerProps;
Expand Down Expand Up @@ -492,17 +496,23 @@ function markRuleTransformer(props: TransformerProps<MarkPasteRule>) {
const { transformMatch, getAttributes, markType } = rule;
const attributes = isFunction(getAttributes) ? getAttributes(match, false) : getAttributes;

const text = textNode.text ?? '';
const mark = markType.create(attributes);
const transformedCapturedValue = transformMatch?.(match);

// remove the text if transformMatch return
// remove the text if transformMatch returns empty text
if (transformedCapturedValue === '') {
return;
}

const text = transformedCapturedValue ?? textNode.text ?? '';
const mark = markType.create(attributes);
// remove the mark if transformMatch returns false
if (transformedCapturedValue === false) {
nodes.push(schema.text(text, textNode.marks));
return;
}

const marks = mark.addToSet(textNode.marks);
nodes.push(schema.text(text, marks));
nodes.push(schema.text(transformedCapturedValue ?? text, marks));
}

/**
Expand All @@ -513,8 +523,8 @@ function textRuleTransformer(props: TransformerProps<TextPasteRule>) {
const { transformMatch } = rule;
const transformedCapturedValue = transformMatch?.(match);

// remove the text if transformMatch return
if (transformedCapturedValue === '') {
// remove the text if transformMatch returns empty string or false
if (transformedCapturedValue === '' || transformedCapturedValue === false) {
return;
}

Expand Down
173 changes: 173 additions & 0 deletions packages/remirror__extension-link/__tests__/link-extension.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
extractHref,
LinkExtension,
LinkOptions,
OrderedListExtension,
TOP_50_TLDS,
} from 'remirror/extensions';

extensionValidityTest(LinkExtension);
Expand Down Expand Up @@ -825,6 +827,177 @@ describe('autolinking', () => {

expect(editor.doc).toEqualRemirrorDocument(doc(p(bold('bold'))));
});

it('does not interfere with input rules for ordered lists', () => {
const editor = renderEditor([
new LinkExtension({ autoLink: true }),
new OrderedListExtension(),
]);
const { doc, p, orderedList: ol, listItem: li } = editor.nodes;

editor.add(doc(p('<cursor>Hello there friend and partner.'))).insertText('1.');

expect(editor.doc).toEqualRemirrorDocument(doc(p('1.Hello there friend and partner.')));

editor.insertText(' ');

expect(editor.doc).toEqualRemirrorDocument(doc(ol(li(p('Hello there friend and partner.')))));
});
});

describe('more auto link cases', () => {
let editor = create({ autoLink: true });
let { link } = editor.attributeMarks;
let { doc, p } = editor.nodes;

beforeEach(() => {
editor = create({ autoLink: true });
({
attributeMarks: { link },
nodes: { doc, p },
} = editor);
});

it.each([
{ input: 'google.com', expected: '//google.com' },
{ input: 'www.google.com', expected: '//www.google.com' },
{ input: 'http://github.com' },
{ input: 'https://github.com/remirror/remirror' },

// with port
{ input: 'time.google.com:123', expected: '//time.google.com:123' },

// with "#"
{ input: "https://en.wikipedia.org/wiki/The_Power_of_the_Powerless#Havel's_greengrocer" },

// with '(' and ')'
{ input: 'https://en.wikipedia.org/wiki/Specials_(Unicode_block)' },

// with "?"
{ input: 'https://www.google.com/search?q=test' },

// with everything
{
input:
'https://github.com:443/remirror/remirror/commits/main?after=4dc93317d4b62f2d155865f7d2e721f05ddfdd61+34&branch=main&path%5B%5D=readme.md#repo-content-pjax-container',
},
])('can auto link $input', ({ input, expected }) => {
editor.add(doc(p('<cursor>'))).insertText(input);

expect(editor.doc).toEqualRemirrorDocument(
doc(p(link({ auto: true, href: expected ?? input })(input))),
);
});
});

describe('autolinking with allowed TLDs', () => {
const autoLinkAllowedTLDs = ['com', 'net'];
let editor = create({ autoLink: true, autoLinkAllowedTLDs });
let { link } = editor.attributeMarks;
let { doc, p } = editor.nodes;

beforeEach(() => {
editor = create({ autoLink: true, autoLinkAllowedTLDs });
({
attributeMarks: { link },
nodes: { doc, p },
} = editor);
});

it('detects domains as auto link if the TLD is allowed', () => {
editor.add(doc(p('github<cursor>'))).insertText('.com');

expect(editor.doc).toEqualRemirrorDocument(
doc(p(link({ auto: true, href: '//github.com' })('github.com'))),
);
});

it('detects domains as auto link if the TLD is allowed (.net)', () => {
editor.add(doc(p('docusign<cursor>'))).insertText('.net');

expect(editor.doc).toEqualRemirrorDocument(
doc(p(link({ auto: true, href: '//docusign.net' })('docusign.net'))),
);
});

it('detects URLs without protocol as auto links if the TLD is allowed', () => {
editor.add(doc(p('<cursor>'))).insertText('github.com/remirror/remirror');

expect(editor.doc).toEqualRemirrorDocument(
doc(
p(
link({ auto: true, href: '//github.com/remirror/remirror' })(
'github.com/remirror/remirror',
),
),
),
);
});

it('detects URLs with protocol as auto links if the TLD is allowed', () => {
editor.add(doc(p('<cursor>'))).insertText('https://github.com/remirror/remirror');

expect(editor.doc).toEqualRemirrorDocument(
doc(
p(
link({ auto: true, href: 'https://github.com/remirror/remirror' })(
'https://github.com/remirror/remirror',
),
),
),
);
});

it('does NOT detect domains as auto link if the TLD is not allowed', () => {
editor.add(doc(p('remirror<cursor>'))).insertText('.io');

expect(editor.doc).toEqualRemirrorDocument(doc(p('remirror.io')));
});

it('does NOT detect URLs as auto link if the TLD is not allowed', () => {
editor.add(doc(p('<cursor>'))).insertText('https://en.wikipedia.org/wiki/TypeScript');

expect(editor.doc).toEqualRemirrorDocument(doc(p('https://en.wikipedia.org/wiki/TypeScript')));
});

it('detects only the TLD, not other parts of the URL', () => {
editor.add(doc(p('npmjs<cursor>'))).insertText('.org/package/asp.net');

expect(editor.doc).toEqualRemirrorDocument(doc(p('npmjs.org/package/asp.net')));
});

it('detects emails as auto link if the TLD is allowed', () => {
editor.add(doc(p('user@example<cursor>'))).insertText('.com');

expect(editor.doc).toEqualRemirrorDocument(
doc(p(link({ auto: true, href: 'mailto:user@example.com' })('user@example.com'))),
);
});

it('does NOT detect emails as auto link if the TLD is not allowed', () => {
editor.add(doc(p('user@example<cursor>'))).insertText('.org');

expect(editor.doc).toEqualRemirrorDocument(doc(p('user@example.org')));
});

it('can extend the default list with additional items', () => {
expect(TOP_50_TLDS).not.toInclude('london');

const editor = renderEditor([
new LinkExtension({ autoLink: true, autoLinkAllowedTLDs: [...TOP_50_TLDS, 'london'] }),
]);
const {
add,
nodes: { doc, p },
attributeMarks: { link },
} = editor;

add(doc(p('business<cursor>'))).insertText('.london');

expect(editor.doc).toEqualRemirrorDocument(
doc(p(link({ auto: true, href: '//business.london' })('business.london'))),
);
});
});

describe('onClick', () => {
Expand Down
6 changes: 4 additions & 2 deletions packages/remirror__extension-link/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,12 @@
"@babel/runtime": "^7.13.10",
"@remirror/core": "^1.4.3",
"@remirror/extension-events": "^1.1.1",
"@remirror/messages": "^1.0.6"
"@remirror/messages": "^1.0.6",
"extract-domain": "2.2.1"
},
"devDependencies": {
"@remirror/pm": "^1.0.17"
"@remirror/pm": "^1.0.17",
"@types/extract-domain": "2.2.0"
},
"peerDependencies": {
"@remirror/pm": "^1.0.10"
Expand Down
1 change: 1 addition & 0 deletions packages/remirror__extension-link/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export type {
ShortcutHandlerProps,
} from './link-extension';
export { extractHref, LinkExtension } from './link-extension';
export { TOP_50_TLDS } from './link-extension-utils';

1 comment on commit 755f9fc

@github-actions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉 Published on https://remirror.io as production
🚀 Deployed on https://6273f01044112073eb575f1c--remirror.netlify.app

Please sign in to comment.