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
91 changes: 0 additions & 91 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,6 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-shadow-dom-retarget-events": "^1.1.0",
"rehype-external-links": "^3.0.0",
"rehype-parse": "^9.0.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
Expand Down
35 changes: 35 additions & 0 deletions src/components/markdown/link-markdown-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { visit } from 'unist-util-visit';
import { Node } from 'unist';
import { Plugin, Transformer } from 'unified';
import { getLinkAttributes } from '../text-editor/prosemirror-adapter/plugins/link/utils';

/**
* Creates a unified.js plugin that transforms link elements
* to add target, rel, and referrerpolicy attributes.
*
* @returns A unified.js plugin function
*/
export function createLinksPlugin(): Plugin {
return (): Transformer => {
return (tree: Node) => {
visit(tree, 'element', (node: any) => {
if (node.tagName === 'a') {
const href = node.properties?.href;
const title = node.properties?.title;

if (!href) {
return;
}

const attributes = getLinkAttributes(href, title);

node.properties.target = attributes.target;
node.properties.rel = attributes.rel;
node.properties.referrerpolicy = attributes.referrerpolicy;
}
});

return tree;
};
};
}
5 changes: 3 additions & 2 deletions src/components/markdown/markdown-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import remarkGfm from 'remark-gfm';
import rehypeParse from 'rehype-parse';
import rehypeExternalLinks from 'rehype-external-links';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import rehypeStringify from 'rehype-stringify';
import rehypeRaw from 'rehype-raw';
Expand All @@ -13,6 +12,7 @@ import { Node } from 'unist';
import { Schema } from 'rehype-sanitize/lib';
import { createLazyLoadImagesPlugin } from './image-markdown-plugin';
import { CustomElementDefinition } from '../../global/shared-types/custom-element.types';
import { createLinksPlugin } from './link-markdown-plugin';

/**
* Takes a string as input and returns a new string
Expand Down Expand Up @@ -40,8 +40,8 @@ export async function markdownToHTML(
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeExternalLinks, { target: '_blank' })
.use(rehypeRaw)
.use(createLinksPlugin())
.use(rehypeSanitize, {
...getWhiteList(options?.whitelist ?? []),
})
Expand Down Expand Up @@ -107,6 +107,7 @@ function getWhiteList(allowedComponents: CustomElementDefinition[]): Schema {
...(defaultSchema.attributes.p ?? []),
['className', 'MsoNormal'],
], // Allow the class 'MsoNormal' on <p> elements
a: [...(defaultSchema.attributes.a ?? []), 'referrerpolicy'], // Allow referrerpolicy on <a> elements
'*': asteriskAttributeWhitelist,
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Schema, MarkType, NodeType, Attrs } from 'prosemirror-model';
import { findWrapping, liftTarget } from 'prosemirror-transform';
import { Command, EditorState, TextSelection } from 'prosemirror-state';
import { EditorMenuTypes, EditorTextLink, LevelMapping } from './types';
import { getLinkAttributes } from '../plugins/link/utils';

type CommandFunction = (
schema: Schema,
Expand Down Expand Up @@ -82,23 +83,17 @@ const createInsertLinkCommand: CommandFunction = (
): CommandWithActive => {
const command: Command = (state, dispatch) => {
const { from, to } = state.selection;
const linkMark = schema.marks.link.create(
getLinkAttributes(link.href, link.href),
);

if (from === to) {
// If no text is selected, insert new text with link
const linkMark = schema.marks.link.create({
href: link.href,
title: link.href,
target: isExternalLink(link.href) ? '_blank' : null,
});
const linkText = link.text || link.href;
const newLink = schema.text(linkText, [linkMark]);
dispatch(state.tr.insert(from, newLink));
} else {
// If text is selected, replace selected text with link text
const linkMark = schema.marks.link.create({
href: link.href,
title: link.href,
target: isExternalLink(link.href) ? '_blank' : null,
});
const selectedText = state.doc.textBetween(from, to, ' ');
const newLink = schema.text(link.text || selectedText, [linkMark]);
dispatch(state.tr.replaceWith(from, to, newLink));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { MarkSpec, DOMOutputSpec } from 'prosemirror-model';

export interface LinkMarkAttrs {
href: string;
title: string | null;
target: string | null;
rel: string | null;
referrerpolicy: string | null;
}

export const linkMarkSpec: MarkSpec = {
attrs: {
href: { default: '' },
title: { default: null },
target: { default: null },
rel: { default: null },
referrerpolicy: { default: null },
},
inclusive: false,
parseDOM: [
{
tag: 'a[href]',
getAttrs: (dom: HTMLElement): LinkMarkAttrs => {
return {
href: dom.getAttribute('href') || '',
title: dom.getAttribute('title'),
target: dom.getAttribute('target'),
rel: dom.getAttribute('rel'),
referrerpolicy: dom.getAttribute('referrerpolicy'),
};
},
},
],
toDOM: (mark): DOMOutputSpec => {
const target = mark.attrs.target || null;

const securityAttrs = {
rel: target === '_blank' ? 'noopener noreferrer' : null,
referrerpolicy: target === '_blank' ? 'noreferrer' : null,
};

return ['a', { ...mark.attrs, ...securityAttrs }, 0];
},
};
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Plugin, PluginKey, TextSelection } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { Mark, Fragment, Node, Schema } from 'prosemirror-model';
import { isExternalLink } from '../menu/menu-commands';
import { EditorMenuTypes, MouseButtons } from '../menu/types';
import { EditorLink } from '../../text-editor.types';
import { EditorMenuTypes, MouseButtons } from '../../menu/types';
import { EditorLink } from '../../../text-editor.types';
import { getLinkAttributes } from './utils';

export const linkPluginKey = new PluginKey('linkPlugin');

Expand Down Expand Up @@ -198,12 +198,7 @@ const createTextNode = (schema: Schema, content: string): Node => {
* Creates a link node with the provided URL
*/
const createLinkNode = (schema: Schema, url: string): Node => {
const linkMark = schema.marks.link.create({
href: url,
title: url,
// Only set _blank for http/https links, not for mailto/tel
target: url.startsWith('http') && isExternalLink(url) ? '_blank' : null,
});
const linkMark = schema.marks.link.create(getLinkAttributes(url, url));

return schema.text(url, [linkMark]);
};
Expand Down
Loading