feat: link in shortcode (#707)

This commit is contained in:
Daniel Lautzenheiser
2023-04-19 00:19:38 -04:00
committed by GitHub
parent 6be11c749e
commit 0fae2ce73d
12 changed files with 664 additions and 4682 deletions

View File

@ -9,7 +9,10 @@ import { markdownToSlate } from '../useMarkdownToSlate';
import type { SerializationTestData } from '../../tests-util/serializationTests.util';
import type { UseMarkdownToSlateOptions } from '../useMarkdownToSlate';
jest.unmock('remark-gfm');
jest.unmock('mdast-util-gfm-footnote');
jest.unmock('mdast-util-gfm-table');
jest.unmock('mdast-util-gfm-task-list-item');
jest.unmock('micromark-extension-gfm');
jest.unmock('remark-mdx');
jest.unmock('remark-parse');
jest.unmock('unified');

View File

@ -1,11 +1,11 @@
import { ELEMENT_PARAGRAPH } from '@udecode/plate';
import { useEffect, useState } from 'react';
import gfm from 'remark-gfm';
import mdx from 'remark-mdx';
import markdown from 'remark-parse';
import { unified } from 'unified';
import { getShortcodes } from '../../../../lib/registry';
import gfm from '../serialization/gfm';
import toSlatePlugin from '../serialization/slate/toSlatePlugin';
import type { ShortcodeConfig } from '../../../../interface';

View File

@ -0,0 +1,61 @@
import { gfmFootnoteFromMarkdown, gfmFootnoteToMarkdown } from 'mdast-util-gfm-footnote';
import {
gfmStrikethroughFromMarkdown,
gfmStrikethroughToMarkdown,
} from 'mdast-util-gfm-strikethrough';
import { gfmTableFromMarkdown, gfmTableToMarkdown } from 'mdast-util-gfm-table';
import {
gfmTaskListItemFromMarkdown,
gfmTaskListItemToMarkdown,
} from 'mdast-util-gfm-task-list-item';
import { gfmFootnote } from 'micromark-extension-gfm-footnote';
import { gfmStrikethrough } from 'micromark-extension-gfm-strikethrough';
import { gfmTable } from 'micromark-extension-gfm-table';
import { gfmTaskListItem } from 'micromark-extension-gfm-task-list-item';
import { combineExtensions } from 'micromark-util-combine-extensions';
import type { Root } from 'mdast';
import type { Plugin, Processor } from 'unified';
function gfmFromMarkdown() {
return [
gfmFootnoteFromMarkdown(),
gfmStrikethroughFromMarkdown,
gfmTableFromMarkdown,
gfmTaskListItemFromMarkdown,
];
}
function gfmToMarkdown() {
return {
extensions: [
gfmFootnoteToMarkdown(),
gfmStrikethroughToMarkdown,
gfmTableToMarkdown({}),
gfmTaskListItemToMarkdown,
],
};
}
function gfm() {
return combineExtensions([gfmFootnote(), gfmStrikethrough({}), gfmTable, gfmTaskListItem]);
}
/**
* Plugin to support GFM (footnotes, strikethrough, tables, tasklists).
*/
const remarkGfm: Plugin<void[], Root> = function (this: Processor) {
const data = this.data();
add('micromarkExtensions', gfm());
add('fromMarkdownExtensions', gfmFromMarkdown());
add('toMarkdownExtensions', gfmToMarkdown());
function add(field: string, value: unknown) {
const list = (data[field] ? data[field] : (data[field] = [])) as unknown[];
list.push(value);
}
};
export default remarkGfm;

View File

@ -0,0 +1,166 @@
import { autoLinkToSlate } from '../autoLinkUrls';
import type { MdastNode } from '../ast-types';
describe('processShortcodeConfig', () => {
describe('autoLinkToSlate', () => {
it('converts url to anchor node', () => {
const nodes: MdastNode[] = [
{ type: 'text', value: 'https://www.youtube.com/watch?v=p6h-rYSVX90' },
];
const slate: MdastNode[] = [
{
type: 'a',
url: 'https://www.youtube.com/watch?v=p6h-rYSVX90',
children: [{ text: 'https://www.youtube.com/watch?v=p6h-rYSVX90' }],
},
];
expect(autoLinkToSlate(nodes)).toEqual(slate);
});
it('does not convert url in shortcode node', () => {
const nodes: MdastNode[] = [
{
type: 'shortcode',
shortcode: 'youtube',
args: ['https://www.youtube.com/watch?v=p6h-rYSVX90'],
children: [{ text: '' }],
},
{ type: 'text', value: 'https://www.youtube.com/watch?v=p6h-rYSVX90' },
];
const slate: MdastNode[] = [
{
type: 'shortcode',
shortcode: 'youtube',
args: ['https://www.youtube.com/watch?v=p6h-rYSVX90'],
children: [{ text: '' }],
},
{
type: 'a',
url: 'https://www.youtube.com/watch?v=p6h-rYSVX90',
children: [{ text: 'https://www.youtube.com/watch?v=p6h-rYSVX90' }],
},
];
expect(autoLinkToSlate(nodes)).toEqual(slate);
});
it('converts url with text before', () => {
const nodes: MdastNode[] = [
{ type: 'text', value: 'Text before https://www.youtube.com/watch?v=p6h-rYSVX90' },
];
const slate: MdastNode[] = [
{
type: 'text',
value: 'Text before ',
},
{
type: 'a',
url: 'https://www.youtube.com/watch?v=p6h-rYSVX90',
children: [{ text: 'https://www.youtube.com/watch?v=p6h-rYSVX90' }],
},
];
expect(autoLinkToSlate(nodes)).toEqual(slate);
});
it('converts url with text after', () => {
const nodes: MdastNode[] = [
{ type: 'text', value: 'https://www.youtube.com/watch?v=p6h-rYSVX90 and text after' },
];
const slate: MdastNode[] = [
{
type: 'a',
url: 'https://www.youtube.com/watch?v=p6h-rYSVX90',
children: [{ text: 'https://www.youtube.com/watch?v=p6h-rYSVX90' }],
},
{
type: 'text',
value: ' and text after',
},
];
expect(autoLinkToSlate(nodes)).toEqual(slate);
});
it('converts url with text before and after', () => {
const nodes: MdastNode[] = [
{
type: 'text',
value: 'Text before https://www.youtube.com/watch?v=p6h-rYSVX90 and text after',
},
];
const slate: MdastNode[] = [
{
type: 'text',
value: 'Text before ',
},
{
type: 'a',
url: 'https://www.youtube.com/watch?v=p6h-rYSVX90',
children: [{ text: 'https://www.youtube.com/watch?v=p6h-rYSVX90' }],
},
{
type: 'text',
value: ' and text after',
},
];
expect(autoLinkToSlate(nodes)).toEqual(slate);
});
it('converts multiple urls', () => {
const nodes: MdastNode[] = [
{
type: 'text',
value:
'Text before https://www.youtube.com/watch?v=p6h-rYSVX90 and https://www.youtube.com/watch?v=p6h-rYSVX90 text after',
},
];
const slate: MdastNode[] = [
{
type: 'text',
value: 'Text before ',
},
{
type: 'a',
url: 'https://www.youtube.com/watch?v=p6h-rYSVX90',
children: [{ text: 'https://www.youtube.com/watch?v=p6h-rYSVX90' }],
},
{
type: 'text',
value: ' and ',
},
{
type: 'a',
url: 'https://www.youtube.com/watch?v=p6h-rYSVX90',
children: [{ text: 'https://www.youtube.com/watch?v=p6h-rYSVX90' }],
},
{
type: 'text',
value: ' text after',
},
];
expect(autoLinkToSlate(nodes)).toEqual(slate);
});
it('does not convert plain text', () => {
const nodes: MdastNode[] = [
{
type: 'text',
value: 'Some text about something going on somewhere',
},
];
const slate: MdastNode[] = [
{
type: 'text',
value: 'Some text about something going on somewhere',
},
];
expect(autoLinkToSlate(nodes)).toEqual(slate);
});
});
});

View File

@ -1,7 +1,265 @@
import { processShortcodeConfigToMdx } from '../processShortcodeConfig';
import {
processShortcodeConfigToMdx,
processShortcodeConfigsToSlate,
} from '../processShortcodeConfig';
import { testShortcodeConfigs } from '../../../tests-util/serializationTests.util';
import type { MdastNode } from '../ast-types';
describe('processShortcodeConfig', () => {
describe('processShortcodeConfigsToSlate', () => {
it('converts shortcode', () => {
const nodes: MdastNode[] = [{ type: 'text', value: '[youtube|p6h-rYSVX90]' }];
const slate: MdastNode[] = [
{
type: 'shortcode',
shortcode: 'youtube',
args: ['p6h-rYSVX90'],
children: [{ text: '' }],
},
];
expect(processShortcodeConfigsToSlate(testShortcodeConfigs, nodes)).toEqual(slate);
});
it('converts shortcode with no args', () => {
const nodes: MdastNode[] = [{ type: 'text', value: '[youtube]' }];
const slate: MdastNode[] = [
{
type: 'shortcode',
shortcode: 'youtube',
args: [],
children: [{ text: '' }],
},
];
expect(processShortcodeConfigsToSlate(testShortcodeConfigs, nodes)).toEqual(slate);
});
it('converts shortcode with multiple args', () => {
const nodes: MdastNode[] = [
{ type: 'text', value: '[youtube|p6h-rYSVX90|somethingElse|andOneMore]' },
];
const slate: MdastNode[] = [
{
type: 'shortcode',
shortcode: 'youtube',
args: ['p6h-rYSVX90', 'somethingElse', 'andOneMore'],
children: [{ text: '' }],
},
];
expect(processShortcodeConfigsToSlate(testShortcodeConfigs, nodes)).toEqual(slate);
});
it('converts shortcode with text before', () => {
const nodes: MdastNode[] = [{ type: 'text', value: 'Text before [youtube|p6h-rYSVX90]' }];
const slate: MdastNode[] = [
{
type: 'text',
value: 'Text before ',
},
{
type: 'shortcode',
shortcode: 'youtube',
args: ['p6h-rYSVX90'],
children: [{ text: '' }],
},
];
expect(processShortcodeConfigsToSlate(testShortcodeConfigs, nodes)).toEqual(slate);
});
it('converts shortcode with text after', () => {
const nodes: MdastNode[] = [{ type: 'text', value: '[youtube|p6h-rYSVX90] and text after' }];
const slate: MdastNode[] = [
{
type: 'shortcode',
shortcode: 'youtube',
args: ['p6h-rYSVX90'],
children: [{ text: '' }],
},
{
type: 'text',
value: ' and text after',
},
];
expect(processShortcodeConfigsToSlate(testShortcodeConfigs, nodes)).toEqual(slate);
});
it('converts shortcode with text before and after', () => {
const nodes: MdastNode[] = [
{ type: 'text', value: 'Text before [youtube|p6h-rYSVX90] and text after' },
];
const slate: MdastNode[] = [
{
type: 'text',
value: 'Text before ',
},
{
type: 'shortcode',
shortcode: 'youtube',
args: ['p6h-rYSVX90'],
children: [{ text: '' }],
},
{
type: 'text',
value: ' and text after',
},
];
expect(processShortcodeConfigsToSlate(testShortcodeConfigs, nodes)).toEqual(slate);
});
it('converts multiple shortcodes', () => {
const nodes: MdastNode[] = [
{
type: 'text',
value: 'Text before [youtube|p6h-rYSVX90] and {{< twitter 917359331535966209 >}}',
},
];
const slate: MdastNode[] = [
{
type: 'text',
value: 'Text before ',
},
{
type: 'shortcode',
shortcode: 'youtube',
args: ['p6h-rYSVX90'],
children: [{ text: '' }],
},
{
type: 'text',
value: ' and ',
},
{
type: 'shortcode',
shortcode: 'twitter',
args: ['917359331535966209'],
children: [{ text: '' }],
},
];
expect(processShortcodeConfigsToSlate(testShortcodeConfigs, nodes)).toEqual(slate);
});
it('converts multiple of the same shortcodes', () => {
const nodes: MdastNode[] = [
{
type: 'text',
value:
'Text before [youtube|p6h-rYSVX90], [youtube|p6h-rYSVX90], {{< twitter 917359331535966209 >}} and [youtube|p6h-rYSVX90]',
},
];
const slate: MdastNode[] = [
{
type: 'text',
value: 'Text before ',
},
{
type: 'shortcode',
shortcode: 'youtube',
args: ['p6h-rYSVX90'],
children: [{ text: '' }],
},
{
type: 'text',
value: ', ',
},
{
type: 'shortcode',
shortcode: 'youtube',
args: ['p6h-rYSVX90'],
children: [{ text: '' }],
},
{
type: 'text',
value: ', ',
},
{
type: 'shortcode',
shortcode: 'twitter',
args: ['917359331535966209'],
children: [{ text: '' }],
},
{
type: 'text',
value: ' and ',
},
{
type: 'shortcode',
shortcode: 'youtube',
args: ['p6h-rYSVX90'],
children: [{ text: '' }],
},
];
expect(processShortcodeConfigsToSlate(testShortcodeConfigs, nodes)).toEqual(slate);
});
it('does not convert unrecognized shortcode', () => {
const nodes: MdastNode[] = [{ type: 'text', value: '[someOtherShortcode|andstuff]' }];
const slate: MdastNode[] = [
{
type: 'text',
value: '[someOtherShortcode|andstuff]',
},
];
expect(processShortcodeConfigsToSlate(testShortcodeConfigs, nodes)).toEqual(slate);
});
it('does not convert unrecognized shortcode surrounded by recognized shortcodes', () => {
const nodes: MdastNode[] = [
{
type: 'text',
value:
'Text before [youtube|p6h-rYSVX90], [someOtherShortcode|andstuff] and {{< twitter 917359331535966209 >}}',
},
];
const slate: MdastNode[] = [
{
type: 'text',
value: 'Text before ',
},
{
type: 'shortcode',
shortcode: 'youtube',
args: ['p6h-rYSVX90'],
children: [{ text: '' }],
},
{
type: 'text',
value: ', [someOtherShortcode|andstuff] and ',
},
{
type: 'shortcode',
shortcode: 'twitter',
args: ['917359331535966209'],
children: [{ text: '' }],
},
];
expect(processShortcodeConfigsToSlate(testShortcodeConfigs, nodes)).toEqual(slate);
});
it('does not convert plain text', () => {
const nodes: MdastNode[] = [
{ type: 'text', value: 'Some text about something going on somewhere' },
];
const slate: MdastNode[] = [
{
type: 'text',
value: 'Some text about something going on somewhere',
},
];
expect(processShortcodeConfigsToSlate(testShortcodeConfigs, nodes)).toEqual(slate);
});
});
describe('processShortcodeConfigToMdx', () => {
it('converts to mdx', () => {
const markdown = '[youtube|p6h-rYSVX90]';
@ -25,21 +283,21 @@ describe('processShortcodeConfig', () => {
expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
});
it('shortcode with text before', () => {
it('converts shortcode with text before', () => {
const markdown = 'Text before [youtube|p6h-rYSVX90]';
const mdx = 'Text before <Shortcode shortcode="youtube" args={[\'p6h-rYSVX90\']} />';
expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
});
it('shortcode with text after', () => {
it('converts shortcode with text after', () => {
const markdown = '[youtube|p6h-rYSVX90] and text after';
const mdx = '<Shortcode shortcode="youtube" args={[\'p6h-rYSVX90\']} /> and text after';
expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
});
it('shortcode with text before and after', () => {
it('converts shortcode with text before and after', () => {
const markdown = 'Text before [youtube|p6h-rYSVX90] and text after';
const mdx =
'Text before <Shortcode shortcode="youtube" args={[\'p6h-rYSVX90\']} /> and text after';
@ -47,7 +305,7 @@ describe('processShortcodeConfig', () => {
expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
});
it('multiple shortcodes', () => {
it('converts multiple shortcodes', () => {
const markdown = 'Text before [youtube|p6h-rYSVX90] and {{< twitter 917359331535966209 >}}';
const mdx =
'Text before <Shortcode shortcode="youtube" args={[\'p6h-rYSVX90\']} /> and <Shortcode shortcode="twitter" args={[\'917359331535966209\']} />';
@ -55,7 +313,7 @@ describe('processShortcodeConfig', () => {
expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
});
it('multiple of the same shortcodes', () => {
it('converts multiple of the same shortcodes', () => {
const markdown =
'Text before [youtube|p6h-rYSVX90], [youtube|p6h-rYSVX90], {{< twitter 917359331535966209 >}} and [youtube|p6h-rYSVX90]';
const mdx =
@ -64,14 +322,14 @@ describe('processShortcodeConfig', () => {
expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
});
it('unrecognized shortcode', () => {
it('does not convert unrecognized shortcode', () => {
const markdown = '[someOtherShortcode|andstuff]';
const mdx = '[someOtherShortcode|andstuff]';
expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
});
it('unrecognized shortcode surrounded by recognized shortcodes', () => {
it('does not convert unrecognized shortcode surrounded by recognized shortcodes', () => {
const markdown =
'Text before [youtube|p6h-rYSVX90], [someOtherShortcode|andstuff] and {{< twitter 917359331535966209 >}}';
const mdx =
@ -80,7 +338,7 @@ describe('processShortcodeConfig', () => {
expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
});
it('plain text', () => {
it('does not convert plain text', () => {
const markdown = 'Some text about something going on somewhere';
const mdx = 'Some text about something going on somewhere';

View File

@ -1,31 +0,0 @@
import {
deserializationOnlyTestData,
runSerializationTests,
testShortcodeConfigs as shortcodeConfigs,
} from '../../../tests-util/serializationTests.util';
import { slateCompiler } from '../toSlatePlugin';
import type { SerializationTestData } from '../../../tests-util/serializationTests.util';
import type { MdastNode } from '../ast-types';
async function expectNodes(
mdast: MdastNode,
useMdx: boolean,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children: any[],
) {
const compiler = slateCompiler({ useMdx, shortcodeConfigs });
expect(compiler(mdast)).toEqual(children);
}
function testRunner(key: string, mode: 'markdown' | 'mdx' | 'both', data: SerializationTestData) {
it(`deserializes ${key}`, async () => {
await expectNodes(data.mdast, mode === 'mdx', data.slate);
});
}
describe('markdownToSlate', () => {
runSerializationTests(testRunner);
runSerializationTests(testRunner, deserializationOnlyTestData);
});

View File

@ -0,0 +1,45 @@
/* eslint-disable import/prefer-default-export */
import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
import { NodeTypes } from './ast-types';
import type { BaseMdastNode, MdastNode } from './ast-types';
export function autoLinkToSlate(nodes: BaseMdastNode[]) {
const output: MdastNode[] = [];
for (const node of nodes) {
if (node.type === 'text' && node.value) {
const regex =
/([\w\W]*?)((?:http(?:s)?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\b(?:[-a-zA-Z0-9@:%_+.~#?&//=]*))([\w\W]*)/g;
let matches: RegExpExecArray | null;
let rest = node.value;
while (isNotEmpty(rest) && (matches = regex.exec(rest)) !== null && matches.length === 4) {
if (isNotEmpty(matches[1])) {
output.push({
type: 'text',
value: matches[1],
});
}
output.push({
type: NodeTypes.link,
url: matches[2],
children: [{ text: matches[2] }],
});
rest = matches[3];
regex.lastIndex = 0;
}
if (isNotEmpty(rest)) {
output.push({
type: 'text',
value: rest,
});
}
continue;
}
output.push(node);
}
return output;
}

View File

@ -2,7 +2,8 @@
import { ELEMENT_PARAGRAPH } from '@udecode/plate';
import { LIST_TYPES, MarkNodeTypes, NodeTypes } from './ast-types';
import { processShortcodeConfigToSlate } from './processShortcodeConfig';
import { autoLinkToSlate } from './autoLinkUrls';
import { processShortcodeConfigsToSlate } from './processShortcodeConfig';
import type { ShortcodeConfig } from '@staticcms/core/interface';
import type { MdBlockElement } from '@staticcms/markdown';
@ -350,11 +351,7 @@ export default function deserializeMarkdown(node: MdastNode, options: Options) {
return { text: '' };
}
let nodes: MdastNode[] = [node];
for (const shortcode in shortcodeConfigs) {
nodes = processShortcodeConfigToSlate(shortcode, shortcodeConfigs[shortcode], nodes);
}
const nodes = autoLinkToSlate(processShortcodeConfigsToSlate(shortcodeConfigs, [node]));
return nodes.map(node => (node.type === 'text' ? { text: node.value ?? '' } : node));

View File

@ -19,7 +19,7 @@ function createShortcodeRegex(name: string, config: ShortcodeConfig) {
)}?([\\w\\W]*?)${cleanRegex(config.closeTag)}`;
}
export function processShortcodeConfigToSlate(
function processShortcodeConfigToSlate(
name: string,
config: ShortcodeConfig,
nodes: BaseMdastNode[],
@ -68,6 +68,19 @@ export function processShortcodeConfigToSlate(
return output;
}
export function processShortcodeConfigsToSlate(
configs: Record<string, ShortcodeConfig>,
nodes: BaseMdastNode[],
) {
let finalNodes: MdastNode[] = nodes;
for (const shortcode in configs) {
finalNodes = processShortcodeConfigToSlate(shortcode, configs[shortcode], finalNodes);
}
return finalNodes;
}
export function processShortcodeConfigToMdx(
configs: Record<string, ShortcodeConfig>,
markdown: string,