New Research: Supply Chain Attack on Axios Pulls Malicious Dependency from npm.Details
Socket
Book a DemoSign in
Socket

@haprompt/link

Package Overview
Dependencies
Maintainers
2
Versions
3
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@haprompt/link - npm Package Compare versions

Comparing version
0.12.0
to
0.12.6
+500
HapromptLink.dev.js
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
'use strict';
var utils = require('@haprompt/utils');
var haprompt = require('haprompt');
/** @module @haprompt/link */
const SUPPORTED_URL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'sms:', 'tel:']);
/** @noInheritDoc */
class LinkNode extends haprompt.ElementNode {
/** @internal */
/** @internal */
/** @internal */
/** @internal */
static getType() {
return 'link';
}
static clone(node) {
return new LinkNode(node.__url, {
rel: node.__rel,
target: node.__target,
title: node.__title
}, node.__key);
}
constructor(url, attributes = {}, key) {
super(key);
const {
target = null,
rel = null,
title = null
} = attributes;
this.__url = url;
this.__target = target;
this.__rel = rel;
this.__title = title;
}
createDOM(config) {
const element = document.createElement('a');
element.href = this.sanitizeUrl(this.__url);
if (this.__target !== null) {
element.target = this.__target;
}
if (this.__rel !== null) {
element.rel = this.__rel;
}
if (this.__title !== null) {
element.title = this.__title;
}
utils.addClassNamesToElement(element, config.theme.link);
return element;
}
updateDOM(prevNode, anchor, config) {
const url = this.__url;
const target = this.__target;
const rel = this.__rel;
const title = this.__title;
if (url !== prevNode.__url) {
anchor.href = url;
}
if (target !== prevNode.__target) {
if (target) {
anchor.target = target;
} else {
anchor.removeAttribute('target');
}
}
if (rel !== prevNode.__rel) {
if (rel) {
anchor.rel = rel;
} else {
anchor.removeAttribute('rel');
}
}
if (title !== prevNode.__title) {
if (title) {
anchor.title = title;
} else {
anchor.removeAttribute('title');
}
}
return false;
}
static importDOM() {
return {
a: node => ({
conversion: convertAnchorElement,
priority: 1
})
};
}
static importJSON(serializedNode) {
const node = $createLinkNode(serializedNode.url, {
rel: serializedNode.rel,
target: serializedNode.target,
title: serializedNode.title
});
node.setFormat(serializedNode.format);
node.setIndent(serializedNode.indent);
node.setDirection(serializedNode.direction);
return node;
}
sanitizeUrl(url) {
try {
const parsedUrl = new URL(url); // eslint-disable-next-line no-script-url
if (!SUPPORTED_URL_PROTOCOLS.has(parsedUrl.protocol)) {
return 'about:blank';
}
} catch {
return url;
}
return url;
}
exportJSON() {
return { ...super.exportJSON(),
rel: this.getRel(),
target: this.getTarget(),
title: this.getTitle(),
type: 'link',
url: this.getURL(),
version: 1
};
}
getURL() {
return this.getLatest().__url;
}
setURL(url) {
const writable = this.getWritable();
writable.__url = url;
}
getTarget() {
return this.getLatest().__target;
}
setTarget(target) {
const writable = this.getWritable();
writable.__target = target;
}
getRel() {
return this.getLatest().__rel;
}
setRel(rel) {
const writable = this.getWritable();
writable.__rel = rel;
}
getTitle() {
return this.getLatest().__title;
}
setTitle(title) {
const writable = this.getWritable();
writable.__title = title;
}
insertNewAfter(selection, restoreSelection = true) {
const element = this.getParentOrThrow().insertNewAfter(selection, restoreSelection);
if (haprompt.$isElementNode(element)) {
const linkNode = $createLinkNode(this.__url, {
rel: this.__rel,
target: this.__target,
title: this.__title
});
element.append(linkNode);
return linkNode;
}
return null;
}
canInsertTextBefore() {
return false;
}
canInsertTextAfter() {
return false;
}
canBeEmpty() {
return false;
}
isInline() {
return true;
}
extractWithChild(child, selection, destination) {
if (!haprompt.$isRangeSelection(selection)) {
return false;
}
const anchorNode = selection.anchor.getNode();
const focusNode = selection.focus.getNode();
return this.isParentOf(anchorNode) && this.isParentOf(focusNode) && selection.getTextContent().length > 0;
}
}
function convertAnchorElement(domNode) {
let node = null;
if (utils.isHTMLAnchorElement(domNode)) {
const content = domNode.textContent;
if (content !== null && content !== '') {
node = $createLinkNode(domNode.getAttribute('href') || '', {
rel: domNode.getAttribute('rel'),
target: domNode.getAttribute('target'),
title: domNode.getAttribute('title')
});
}
}
return {
node
};
}
/**
* Takes a URL and creates a LinkNode.
* @param url - The URL the LinkNode should direct to.
* @param attributes - Optional HTML a tag attributes { target, rel, title }
* @returns The LinkNode.
*/
function $createLinkNode(url, attributes) {
return haprompt.$applyNodeReplacement(new LinkNode(url, attributes));
}
/**
* Determines if node is a LinkNode.
* @param node - The node to be checked.
* @returns true if node is a LinkNode, false otherwise.
*/
function $isLinkNode(node) {
return node instanceof LinkNode;
}
// Custom node type to override `canInsertTextAfter` that will
// allow typing within the link
class AutoLinkNode extends LinkNode {
static getType() {
return 'autolink';
}
static clone(node) {
return new AutoLinkNode(node.__url, {
rel: node.__rel,
target: node.__target,
title: node.__title
}, node.__key);
}
static importJSON(serializedNode) {
const node = $createAutoLinkNode(serializedNode.url, {
rel: serializedNode.rel,
target: serializedNode.target,
title: serializedNode.title
});
node.setFormat(serializedNode.format);
node.setIndent(serializedNode.indent);
node.setDirection(serializedNode.direction);
return node;
}
static importDOM() {
// TODO: Should link node should handle the import over autolink?
return null;
}
exportJSON() {
return { ...super.exportJSON(),
type: 'autolink',
version: 1
};
}
insertNewAfter(selection, restoreSelection = true) {
const element = this.getParentOrThrow().insertNewAfter(selection, restoreSelection);
if (haprompt.$isElementNode(element)) {
const linkNode = $createAutoLinkNode(this.__url, {
rel: this._rel,
target: this.__target,
title: this.__title
});
element.append(linkNode);
return linkNode;
}
return null;
}
}
/**
* Takes a URL and creates an AutoLinkNode. AutoLinkNodes are generally automatically generated
* during typing, which is especially useful when a button to generate a LinkNode is not practical.
* @param url - The URL the LinkNode should direct to.
* @param attributes - Optional HTML a tag attributes. { target, rel, title }
* @returns The LinkNode.
*/
function $createAutoLinkNode(url, attributes) {
return haprompt.$applyNodeReplacement(new AutoLinkNode(url, attributes));
}
/**
* Determines if node is an AutoLinkNode.
* @param node - The node to be checked.
* @returns true if node is an AutoLinkNode, false otherwise.
*/
function $isAutoLinkNode(node) {
return node instanceof AutoLinkNode;
}
const TOGGLE_LINK_COMMAND = haprompt.createCommand('TOGGLE_LINK_COMMAND');
/**
* Generates or updates a LinkNode. It can also delete a LinkNode if the URL is null,
* but saves any children and brings them up to the parent node.
* @param url - The URL the link directs to.
* @param attributes - Optional HTML a tag attributes. { target, rel, title }
*/
function toggleLink(url, attributes = {}) {
const {
target,
title
} = attributes;
const rel = attributes.rel === undefined ? 'noreferrer' : attributes.rel;
const selection = haprompt.$getSelection();
if (!haprompt.$isRangeSelection(selection)) {
return;
}
const nodes = selection.extract();
if (url === null) {
// Remove LinkNodes
nodes.forEach(node => {
const parent = node.getParent();
if ($isLinkNode(parent)) {
const children = parent.getChildren();
for (let i = 0; i < children.length; i++) {
parent.insertBefore(children[i]);
}
parent.remove();
}
});
} else {
// Add or merge LinkNodes
if (nodes.length === 1) {
const firstNode = nodes[0]; // if the first node is a LinkNode or if its
// parent is a LinkNode, we update the URL, target and rel.
const linkNode = $isLinkNode(firstNode) ? firstNode : $getLinkAncestor(firstNode);
if (linkNode !== null) {
linkNode.setURL(url);
if (target !== undefined) {
linkNode.setTarget(target);
}
if (rel !== null) {
linkNode.setRel(rel);
}
if (title !== undefined) {
linkNode.setTitle(title);
}
return;
}
}
let prevParent = null;
let linkNode = null;
nodes.forEach(node => {
const parent = node.getParent();
if (parent === linkNode || parent === null || haprompt.$isElementNode(node) && !node.isInline()) {
return;
}
if ($isLinkNode(parent)) {
linkNode = parent;
parent.setURL(url);
if (target !== undefined) {
parent.setTarget(target);
}
if (rel !== null) {
linkNode.setRel(rel);
}
if (title !== undefined) {
linkNode.setTitle(title);
}
return;
}
if (!parent.is(prevParent)) {
prevParent = parent;
linkNode = $createLinkNode(url, {
rel,
target
});
if ($isLinkNode(parent)) {
if (node.getPreviousSibling() === null) {
parent.insertBefore(linkNode);
} else {
parent.insertAfter(linkNode);
}
} else {
node.insertBefore(linkNode);
}
}
if ($isLinkNode(node)) {
if (node.is(linkNode)) {
return;
}
if (linkNode !== null) {
const children = node.getChildren();
for (let i = 0; i < children.length; i++) {
linkNode.append(children[i]);
}
}
node.remove();
return;
}
if (linkNode !== null) {
linkNode.append(node);
}
});
}
}
function $getLinkAncestor(node) {
return $getAncestor(node, $isLinkNode);
}
function $getAncestor(node, predicate) {
let parent = node;
while (parent !== null && (parent = parent.getParent()) !== null && !predicate(parent));
return parent;
}
exports.$createAutoLinkNode = $createAutoLinkNode;
exports.$createLinkNode = $createLinkNode;
exports.$isAutoLinkNode = $isAutoLinkNode;
exports.$isLinkNode = $isLinkNode;
exports.AutoLinkNode = AutoLinkNode;
exports.LinkNode = LinkNode;
exports.TOGGLE_LINK_COMMAND = TOGGLE_LINK_COMMAND;
exports.toggleLink = toggleLink;

Sorry, the diff of this file is not supported yet

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
'use strict';var k=require("@haprompt/utils"),n=require("haprompt");let p=new Set(["http:","https:","mailto:","sms:","tel:"]);
class q extends n.ElementNode{static getType(){return"link"}static clone(a){return new q(a.__url,{rel:a.__rel,target:a.__target,title:a.__title},a.__key)}constructor(a,b={},d){super(d);let {target:l=null,rel:h=null,title:e=null}=b;this.__url=a;this.__target=l;this.__rel=h;this.__title=e}createDOM(a){let b=document.createElement("a");b.href=this.sanitizeUrl(this.__url);null!==this.__target&&(b.target=this.__target);null!==this.__rel&&(b.rel=this.__rel);null!==this.__title&&(b.title=this.__title);k.addClassNamesToElement(b,
a.theme.link);return b}updateDOM(a,b){let d=this.__url,l=this.__target,h=this.__rel,e=this.__title;d!==a.__url&&(b.href=d);l!==a.__target&&(l?b.target=l:b.removeAttribute("target"));h!==a.__rel&&(h?b.rel=h:b.removeAttribute("rel"));e!==a.__title&&(e?b.title=e:b.removeAttribute("title"));return!1}static importDOM(){return{a:()=>({conversion:r,priority:1})}}static importJSON(a){let b=t(a.url,{rel:a.rel,target:a.target,title:a.title});b.setFormat(a.format);b.setIndent(a.indent);b.setDirection(a.direction);
return b}sanitizeUrl(a){try{let b=new URL(a);if(!p.has(b.protocol))return"about:blank"}catch{}return a}exportJSON(){return{...super.exportJSON(),rel:this.getRel(),target:this.getTarget(),title:this.getTitle(),type:"link",url:this.getURL(),version:1}}getURL(){return this.getLatest().__url}setURL(a){this.getWritable().__url=a}getTarget(){return this.getLatest().__target}setTarget(a){this.getWritable().__target=a}getRel(){return this.getLatest().__rel}setRel(a){this.getWritable().__rel=a}getTitle(){return this.getLatest().__title}setTitle(a){this.getWritable().__title=
a}insertNewAfter(a,b=!0){a=this.getParentOrThrow().insertNewAfter(a,b);return n.$isElementNode(a)?(b=t(this.__url,{rel:this.__rel,target:this.__target,title:this.__title}),a.append(b),b):null}canInsertTextBefore(){return!1}canInsertTextAfter(){return!1}canBeEmpty(){return!1}isInline(){return!0}extractWithChild(a,b){if(!n.$isRangeSelection(b))return!1;a=b.anchor.getNode();let d=b.focus.getNode();return this.isParentOf(a)&&this.isParentOf(d)&&0<b.getTextContent().length}}
function r(a){let b=null;if(k.isHTMLAnchorElement(a)){let d=a.textContent;null!==d&&""!==d&&(b=t(a.getAttribute("href")||"",{rel:a.getAttribute("rel"),target:a.getAttribute("target"),title:a.getAttribute("title")}))}return{node:b}}function t(a,b){return n.$applyNodeReplacement(new q(a,b))}function v(a){return a instanceof q}
class w extends q{static getType(){return"autolink"}static clone(a){return new w(a.__url,{rel:a.__rel,target:a.__target,title:a.__title},a.__key)}static importJSON(a){let b=x(a.url,{rel:a.rel,target:a.target,title:a.title});b.setFormat(a.format);b.setIndent(a.indent);b.setDirection(a.direction);return b}static importDOM(){return null}exportJSON(){return{...super.exportJSON(),type:"autolink",version:1}}insertNewAfter(a,b=!0){a=this.getParentOrThrow().insertNewAfter(a,b);return n.$isElementNode(a)?
(b=x(this.__url,{rel:this._rel,target:this.__target,title:this.__title}),a.append(b),b):null}}function x(a,b){return n.$applyNodeReplacement(new w(a,b))}let y=n.createCommand("TOGGLE_LINK_COMMAND");function z(a,b){for(;null!==a&&null!==(a=a.getParent())&&!b(a););return a}exports.$createAutoLinkNode=x;exports.$createLinkNode=t;exports.$isAutoLinkNode=function(a){return a instanceof w};exports.$isLinkNode=v;exports.AutoLinkNode=w;exports.LinkNode=q;exports.TOGGLE_LINK_COMMAND=y;
exports.toggleLink=function(a,b={}){let {target:d,title:l}=b,h=void 0===b.rel?"noreferrer":b.rel;b=n.$getSelection();if(n.$isRangeSelection(b))if(b=b.extract(),null===a)b.forEach(m=>{m=m.getParent();if(v(m)){let c=m.getChildren();for(let f=0;f<c.length;f++)m.insertBefore(c[f]);m.remove()}});else{if(1===b.length){var e=b[0];e=v(e)?e:z(e,v);if(null!==e){e.setURL(a);void 0!==d&&e.setTarget(d);null!==h&&e.setRel(h);void 0!==l&&e.setTitle(l);return}}let m=null,c=null;b.forEach(f=>{var g=f.getParent();
if(g!==c&&null!==g&&(!n.$isElementNode(f)||f.isInline()))if(v(g))c=g,g.setURL(a),void 0!==d&&g.setTarget(d),null!==h&&c.setRel(h),void 0!==l&&c.setTitle(l);else if(g.is(m)||(m=g,c=t(a,{rel:h,target:d}),v(g)?null===f.getPreviousSibling()?g.insertBefore(c):g.insertAfter(c):f.insertBefore(c)),v(f)){if(!f.is(c)){if(null!==c){g=f.getChildren();for(let u=0;u<g.length;u++)c.append(g[u])}f.remove()}}else null!==c&&c.append(f)})}}
/** @module @haprompt/link */
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import type { DOMConversionMap, EditorConfig, GridSelection, HapromptCommand, HapromptNode, NodeKey, NodeSelection, RangeSelection, SerializedElementNode } from 'haprompt';
import { ElementNode, Spread } from 'haprompt';
export type LinkAttributes = {
rel?: null | string;
target?: null | string;
title?: null | string;
};
export type SerializedLinkNode = Spread<{
url: string;
}, Spread<LinkAttributes, SerializedElementNode>>;
/** @noInheritDoc */
export declare class LinkNode extends ElementNode {
/** @internal */
__url: string;
/** @internal */
__target: null | string;
/** @internal */
__rel: null | string;
/** @internal */
__title: null | string;
static getType(): string;
static clone(node: LinkNode): LinkNode;
constructor(url: string, attributes?: LinkAttributes, key?: NodeKey);
createDOM(config: EditorConfig): HTMLAnchorElement;
updateDOM(prevNode: LinkNode, anchor: HTMLAnchorElement, config: EditorConfig): boolean;
static importDOM(): DOMConversionMap | null;
static importJSON(serializedNode: SerializedLinkNode | SerializedAutoLinkNode): LinkNode;
sanitizeUrl(url: string): string;
exportJSON(): SerializedLinkNode | SerializedAutoLinkNode;
getURL(): string;
setURL(url: string): void;
getTarget(): null | string;
setTarget(target: null | string): void;
getRel(): null | string;
setRel(rel: null | string): void;
getTitle(): null | string;
setTitle(title: null | string): void;
insertNewAfter(selection: RangeSelection, restoreSelection?: boolean): null | ElementNode;
canInsertTextBefore(): false;
canInsertTextAfter(): false;
canBeEmpty(): false;
isInline(): true;
extractWithChild(child: HapromptNode, selection: RangeSelection | NodeSelection | GridSelection, destination: 'clone' | 'html'): boolean;
}
/**
* Takes a URL and creates a LinkNode.
* @param url - The URL the LinkNode should direct to.
* @param attributes - Optional HTML a tag attributes { target, rel, title }
* @returns The LinkNode.
*/
export declare function $createLinkNode(url: string, attributes?: LinkAttributes): LinkNode;
/**
* Determines if node is a LinkNode.
* @param node - The node to be checked.
* @returns true if node is a LinkNode, false otherwise.
*/
export declare function $isLinkNode(node: HapromptNode | null | undefined): node is LinkNode;
export type SerializedAutoLinkNode = SerializedLinkNode;
export declare class AutoLinkNode extends LinkNode {
static getType(): string;
static clone(node: AutoLinkNode): AutoLinkNode;
static importJSON(serializedNode: SerializedAutoLinkNode): AutoLinkNode;
static importDOM(): null;
exportJSON(): SerializedAutoLinkNode;
insertNewAfter(selection: RangeSelection, restoreSelection?: boolean): null | ElementNode;
}
/**
* Takes a URL and creates an AutoLinkNode. AutoLinkNodes are generally automatically generated
* during typing, which is especially useful when a button to generate a LinkNode is not practical.
* @param url - The URL the LinkNode should direct to.
* @param attributes - Optional HTML a tag attributes. { target, rel, title }
* @returns The LinkNode.
*/
export declare function $createAutoLinkNode(url: string, attributes?: LinkAttributes): AutoLinkNode;
/**
* Determines if node is an AutoLinkNode.
* @param node - The node to be checked.
* @returns true if node is an AutoLinkNode, false otherwise.
*/
export declare function $isAutoLinkNode(node: HapromptNode | null | undefined): node is AutoLinkNode;
export declare const TOGGLE_LINK_COMMAND: HapromptCommand<string | ({
url: string;
} & LinkAttributes) | null>;
/**
* Generates or updates a LinkNode. It can also delete a LinkNode if the URL is null,
* but saves any children and brings them up to the parent node.
* @param url - The URL the link directs to.
* @param attributes - Optional HTML a tag attributes. { target, rel, title }
*/
export declare function toggleLink(url: null | string, attributes?: LinkAttributes): void;
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+3
-5

@@ -6,7 +6,5 @@ /**

* LICENSE file in the root directory of this source tree.
*
*/
'use strict';
module.exports = require('./dist/HapromptLink.js');
'use strict'
const HapromptLink = process.env.NODE_ENV === 'development' ? require('./HapromptLink.dev.js') : require('./HapromptLink.prod.js')
module.exports = HapromptLink;

@@ -11,9 +11,9 @@ {

"license": "MIT",
"version": "0.12.0",
"version": "0.12.6",
"main": "HapromptLink.js",
"peerDependencies": {
"haprompt": "0.12.0"
"haprompt": "0.12.6"
},
"dependencies": {
"@haprompt/utils": "0.12.0"
"@haprompt/utils": "0.12.6"
},

@@ -20,0 +20,0 @@ "repository": {

Sorry, the diff of this file is not supported yet

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {$createLinkNode, $isLinkNode, LinkNode} from '@haprompt/link';
import {initializeUnitTest} from 'haprompt/src/__tests__/utils';
const editorConfig = Object.freeze({
namespace: '',
theme: {
link: 'my-link-class',
text: {
bold: 'my-bold-class',
code: 'my-code-class',
hashtag: 'my-hashtag-class',
italic: 'my-italic-class',
strikethrough: 'my-strikethrough-class',
underline: 'my-underline-class',
underlineStrikethrough: 'my-underline-strikethrough-class',
},
},
});
describe('HapromptLinkNode tests', () => {
initializeUnitTest((testEnv) => {
test('LinkNode.constructor', async () => {
const {editor} = testEnv;
await editor.update(() => {
const linkNode = new LinkNode('/');
expect(linkNode.__type).toBe('link');
expect(linkNode.__url).toBe('/');
});
expect(() => new LinkNode('')).toThrow();
});
test('LineBreakNode.clone()', async () => {
const {editor} = testEnv;
await editor.update(() => {
const linkNode = new LinkNode('/');
const linkNodeClone = LinkNode.clone(linkNode);
expect(linkNodeClone).not.toBe(linkNode);
expect(linkNodeClone).toStrictEqual(linkNode);
});
});
test('LinkNode.getURL()', async () => {
const {editor} = testEnv;
await editor.update(() => {
const linkNode = new LinkNode('https://example.com/foo');
expect(linkNode.getURL()).toBe('https://example.com/foo');
});
});
test('LinkNode.setURL()', async () => {
const {editor} = testEnv;
await editor.update(() => {
const linkNode = new LinkNode('https://example.com/foo');
expect(linkNode.getURL()).toBe('https://example.com/foo');
linkNode.setURL('https://example.com/bar');
expect(linkNode.getURL()).toBe('https://example.com/bar');
});
});
test('LinkNode.getTarget()', async () => {
const {editor} = testEnv;
await editor.update(() => {
const linkNode = new LinkNode('https://example.com/foo', {
target: '_blank',
});
expect(linkNode.getTarget()).toBe('_blank');
});
});
test('LinkNode.setTarget()', async () => {
const {editor} = testEnv;
await editor.update(() => {
const linkNode = new LinkNode('https://example.com/foo', {
target: '_blank',
});
expect(linkNode.getTarget()).toBe('_blank');
linkNode.setTarget('_self');
expect(linkNode.getTarget()).toBe('_self');
});
});
test('LinkNode.getRel()', async () => {
const {editor} = testEnv;
await editor.update(() => {
const linkNode = new LinkNode('https://example.com/foo', {
rel: 'noopener noreferrer',
target: '_blank',
});
expect(linkNode.getRel()).toBe('noopener noreferrer');
});
});
test('LinkNode.setRel()', async () => {
const {editor} = testEnv;
await editor.update(() => {
const linkNode = new LinkNode('https://example.com/foo', {
rel: 'noopener',
target: '_blank',
});
expect(linkNode.getRel()).toBe('noopener');
linkNode.setRel('noopener noreferrer');
expect(linkNode.getRel()).toBe('noopener noreferrer');
});
});
test('LinkNode.getTitle()', async () => {
const {editor} = testEnv;
await editor.update(() => {
const linkNode = new LinkNode('https://example.com/foo', {
title: 'Hello world',
});
expect(linkNode.getTitle()).toBe('Hello world');
});
});
test('LinkNode.setTitle()', async () => {
const {editor} = testEnv;
await editor.update(() => {
const linkNode = new LinkNode('https://example.com/foo', {
title: 'Hello world',
});
expect(linkNode.getTitle()).toBe('Hello world');
linkNode.setTitle('World hello');
expect(linkNode.getTitle()).toBe('World hello');
});
});
test('LinkNode.createDOM()', async () => {
const {editor} = testEnv;
await editor.update(() => {
const linkNode = new LinkNode('https://example.com/foo');
expect(linkNode.createDOM(editorConfig).outerHTML).toBe(
'<a href="https://example.com/foo" class="my-link-class"></a>',
);
expect(
linkNode.createDOM({
namespace: '',
theme: {},
}).outerHTML,
).toBe('<a href="https://example.com/foo"></a>');
});
});
test('LinkNode.createDOM() with target, rel and title', async () => {
const {editor} = testEnv;
await editor.update(() => {
const linkNode = new LinkNode('https://example.com/foo', {
rel: 'noopener noreferrer',
target: '_blank',
title: 'Hello world',
});
expect(linkNode.createDOM(editorConfig).outerHTML).toBe(
'<a href="https://example.com/foo" target="_blank" rel="noopener noreferrer" title="Hello world" class="my-link-class"></a>',
);
expect(
linkNode.createDOM({
namespace: '',
theme: {},
}).outerHTML,
).toBe(
'<a href="https://example.com/foo" target="_blank" rel="noopener noreferrer" title="Hello world"></a>',
);
});
});
test('LinkNode.createDOM() sanitizes javascript: URLs', async () => {
const {editor} = testEnv;
await editor.update(() => {
// eslint-disable-next-line no-script-url
const linkNode = new LinkNode('javascript:alert(0)');
expect(linkNode.createDOM(editorConfig).outerHTML).toBe(
'<a href="about:blank" class="my-link-class"></a>',
);
});
});
test('LinkNode.updateDOM()', async () => {
const {editor} = testEnv;
await editor.update(() => {
const linkNode = new LinkNode('https://example.com/foo');
const domElement = linkNode.createDOM(editorConfig);
expect(linkNode.createDOM(editorConfig).outerHTML).toBe(
'<a href="https://example.com/foo" class="my-link-class"></a>',
);
const newLinkNode = new LinkNode('https://example.com/bar');
const result = newLinkNode.updateDOM(
linkNode,
domElement,
editorConfig,
);
expect(result).toBe(false);
expect(domElement.outerHTML).toBe(
'<a href="https://example.com/bar" class="my-link-class"></a>',
);
});
});
test('LinkNode.updateDOM() with target, rel and title', async () => {
const {editor} = testEnv;
await editor.update(() => {
const linkNode = new LinkNode('https://example.com/foo', {
rel: 'noopener noreferrer',
target: '_blank',
title: 'Hello world',
});
const domElement = linkNode.createDOM(editorConfig);
expect(linkNode.createDOM(editorConfig).outerHTML).toBe(
'<a href="https://example.com/foo" target="_blank" rel="noopener noreferrer" title="Hello world" class="my-link-class"></a>',
);
const newLinkNode = new LinkNode('https://example.com/bar', {
rel: 'noopener',
target: '_self',
title: 'World hello',
});
const result = newLinkNode.updateDOM(
linkNode,
domElement,
editorConfig,
);
expect(result).toBe(false);
expect(domElement.outerHTML).toBe(
'<a href="https://example.com/bar" target="_self" rel="noopener" title="World hello" class="my-link-class"></a>',
);
});
});
test('LinkNode.updateDOM() with undefined target, undefined rel and undefined title', async () => {
const {editor} = testEnv;
await editor.update(() => {
const linkNode = new LinkNode('https://example.com/foo', {
rel: 'noopener noreferrer',
target: '_blank',
title: 'Hello world',
});
const domElement = linkNode.createDOM(editorConfig);
expect(linkNode.createDOM(editorConfig).outerHTML).toBe(
'<a href="https://example.com/foo" target="_blank" rel="noopener noreferrer" title="Hello world" class="my-link-class"></a>',
);
const newLinkNode = new LinkNode('https://example.com/bar');
const result = newLinkNode.updateDOM(
linkNode,
domElement,
editorConfig,
);
expect(result).toBe(false);
expect(domElement.outerHTML).toBe(
'<a href="https://example.com/bar" class="my-link-class"></a>',
);
});
});
test('LinkNode.canInsertTextBefore()', async () => {
const {editor} = testEnv;
await editor.update(() => {
const linkNode = new LinkNode('https://example.com/foo');
expect(linkNode.canInsertTextBefore()).toBe(false);
});
});
test('LinkNode.canInsertTextAfter()', async () => {
const {editor} = testEnv;
await editor.update(() => {
const linkNode = new LinkNode('https://example.com/foo');
expect(linkNode.canInsertTextAfter()).toBe(false);
});
});
test('$createLinkNode()', async () => {
const {editor} = testEnv;
await editor.update(() => {
const linkNode = new LinkNode('https://example.com/foo');
const createdLinkNode = $createLinkNode('https://example.com/foo');
expect(linkNode.__type).toEqual(createdLinkNode.__type);
expect(linkNode.__parent).toEqual(createdLinkNode.__parent);
expect(linkNode.__url).toEqual(createdLinkNode.__url);
expect(linkNode.__key).not.toEqual(createdLinkNode.__key);
});
});
test('$createLinkNode() with target, rel and title', async () => {
const {editor} = testEnv;
await editor.update(() => {
const linkNode = new LinkNode('https://example.com/foo', {
rel: 'noopener noreferrer',
target: '_blank',
title: 'Hello world',
});
const createdLinkNode = $createLinkNode('https://example.com/foo', {
rel: 'noopener noreferrer',
target: '_blank',
title: 'Hello world',
});
expect(linkNode.__type).toEqual(createdLinkNode.__type);
expect(linkNode.__parent).toEqual(createdLinkNode.__parent);
expect(linkNode.__url).toEqual(createdLinkNode.__url);
expect(linkNode.__target).toEqual(createdLinkNode.__target);
expect(linkNode.__rel).toEqual(createdLinkNode.__rel);
expect(linkNode.__title).toEqual(createdLinkNode.__title);
expect(linkNode.__key).not.toEqual(createdLinkNode.__key);
});
});
test('$isLinkNode()', async () => {
const {editor} = testEnv;
await editor.update(() => {
const linkNode = new LinkNode('');
expect($isLinkNode(linkNode)).toBe(true);
});
});
});
});
/** @module @haprompt/link */
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import type {
DOMConversionMap,
DOMConversionOutput,
EditorConfig,
GridSelection,
HapromptCommand,
HapromptNode,
NodeKey,
NodeSelection,
RangeSelection,
SerializedElementNode,
} from 'haprompt';
import {addClassNamesToElement, isHTMLAnchorElement} from '@haprompt/utils';
import {
$applyNodeReplacement,
$getSelection,
$isElementNode,
$isRangeSelection,
createCommand,
ElementNode,
Spread,
} from 'haprompt';
export type LinkAttributes = {
rel?: null | string;
target?: null | string;
title?: null | string;
};
export type SerializedLinkNode = Spread<
{
url: string;
},
Spread<LinkAttributes, SerializedElementNode>
>;
const SUPPORTED_URL_PROTOCOLS = new Set([
'http:',
'https:',
'mailto:',
'sms:',
'tel:',
]);
/** @noInheritDoc */
export class LinkNode extends ElementNode {
/** @internal */
__url: string;
/** @internal */
__target: null | string;
/** @internal */
__rel: null | string;
/** @internal */
__title: null | string;
static getType(): string {
return 'link';
}
static clone(node: LinkNode): LinkNode {
return new LinkNode(
node.__url,
{rel: node.__rel, target: node.__target, title: node.__title},
node.__key,
);
}
constructor(url: string, attributes: LinkAttributes = {}, key?: NodeKey) {
super(key);
const {target = null, rel = null, title = null} = attributes;
this.__url = url;
this.__target = target;
this.__rel = rel;
this.__title = title;
}
createDOM(config: EditorConfig): HTMLAnchorElement {
const element = document.createElement('a');
element.href = this.sanitizeUrl(this.__url);
if (this.__target !== null) {
element.target = this.__target;
}
if (this.__rel !== null) {
element.rel = this.__rel;
}
if (this.__title !== null) {
element.title = this.__title;
}
addClassNamesToElement(element, config.theme.link);
return element;
}
updateDOM(
prevNode: LinkNode,
anchor: HTMLAnchorElement,
config: EditorConfig,
): boolean {
const url = this.__url;
const target = this.__target;
const rel = this.__rel;
const title = this.__title;
if (url !== prevNode.__url) {
anchor.href = url;
}
if (target !== prevNode.__target) {
if (target) {
anchor.target = target;
} else {
anchor.removeAttribute('target');
}
}
if (rel !== prevNode.__rel) {
if (rel) {
anchor.rel = rel;
} else {
anchor.removeAttribute('rel');
}
}
if (title !== prevNode.__title) {
if (title) {
anchor.title = title;
} else {
anchor.removeAttribute('title');
}
}
return false;
}
static importDOM(): DOMConversionMap | null {
return {
a: (node: Node) => ({
conversion: convertAnchorElement,
priority: 1,
}),
};
}
static importJSON(
serializedNode: SerializedLinkNode | SerializedAutoLinkNode,
): LinkNode {
const node = $createLinkNode(serializedNode.url, {
rel: serializedNode.rel,
target: serializedNode.target,
title: serializedNode.title,
});
node.setFormat(serializedNode.format);
node.setIndent(serializedNode.indent);
node.setDirection(serializedNode.direction);
return node;
}
sanitizeUrl(url: string): string {
try {
const parsedUrl = new URL(url);
// eslint-disable-next-line no-script-url
if (!SUPPORTED_URL_PROTOCOLS.has(parsedUrl.protocol)) {
return 'about:blank';
}
} catch {
return url;
}
return url;
}
exportJSON(): SerializedLinkNode | SerializedAutoLinkNode {
return {
...super.exportJSON(),
rel: this.getRel(),
target: this.getTarget(),
title: this.getTitle(),
type: 'link',
url: this.getURL(),
version: 1,
};
}
getURL(): string {
return this.getLatest().__url;
}
setURL(url: string): void {
const writable = this.getWritable();
writable.__url = url;
}
getTarget(): null | string {
return this.getLatest().__target;
}
setTarget(target: null | string): void {
const writable = this.getWritable();
writable.__target = target;
}
getRel(): null | string {
return this.getLatest().__rel;
}
setRel(rel: null | string): void {
const writable = this.getWritable();
writable.__rel = rel;
}
getTitle(): null | string {
return this.getLatest().__title;
}
setTitle(title: null | string): void {
const writable = this.getWritable();
writable.__title = title;
}
insertNewAfter(
selection: RangeSelection,
restoreSelection = true,
): null | ElementNode {
const element = this.getParentOrThrow().insertNewAfter(
selection,
restoreSelection,
);
if ($isElementNode(element)) {
const linkNode = $createLinkNode(this.__url, {
rel: this.__rel,
target: this.__target,
title: this.__title,
});
element.append(linkNode);
return linkNode;
}
return null;
}
canInsertTextBefore(): false {
return false;
}
canInsertTextAfter(): false {
return false;
}
canBeEmpty(): false {
return false;
}
isInline(): true {
return true;
}
extractWithChild(
child: HapromptNode,
selection: RangeSelection | NodeSelection | GridSelection,
destination: 'clone' | 'html',
): boolean {
if (!$isRangeSelection(selection)) {
return false;
}
const anchorNode = selection.anchor.getNode();
const focusNode = selection.focus.getNode();
return (
this.isParentOf(anchorNode) &&
this.isParentOf(focusNode) &&
selection.getTextContent().length > 0
);
}
}
function convertAnchorElement(domNode: Node): DOMConversionOutput {
let node = null;
if (isHTMLAnchorElement(domNode)) {
const content = domNode.textContent;
if (content !== null && content !== '') {
node = $createLinkNode(domNode.getAttribute('href') || '', {
rel: domNode.getAttribute('rel'),
target: domNode.getAttribute('target'),
title: domNode.getAttribute('title'),
});
}
}
return {node};
}
/**
* Takes a URL and creates a LinkNode.
* @param url - The URL the LinkNode should direct to.
* @param attributes - Optional HTML a tag attributes { target, rel, title }
* @returns The LinkNode.
*/
export function $createLinkNode(
url: string,
attributes?: LinkAttributes,
): LinkNode {
return $applyNodeReplacement(new LinkNode(url, attributes));
}
/**
* Determines if node is a LinkNode.
* @param node - The node to be checked.
* @returns true if node is a LinkNode, false otherwise.
*/
export function $isLinkNode(
node: HapromptNode | null | undefined,
): node is LinkNode {
return node instanceof LinkNode;
}
export type SerializedAutoLinkNode = SerializedLinkNode;
// Custom node type to override `canInsertTextAfter` that will
// allow typing within the link
export class AutoLinkNode extends LinkNode {
static getType(): string {
return 'autolink';
}
static clone(node: AutoLinkNode): AutoLinkNode {
return new AutoLinkNode(
node.__url,
{rel: node.__rel, target: node.__target, title: node.__title},
node.__key,
);
}
static importJSON(serializedNode: SerializedAutoLinkNode): AutoLinkNode {
const node = $createAutoLinkNode(serializedNode.url, {
rel: serializedNode.rel,
target: serializedNode.target,
title: serializedNode.title,
});
node.setFormat(serializedNode.format);
node.setIndent(serializedNode.indent);
node.setDirection(serializedNode.direction);
return node;
}
static importDOM(): null {
// TODO: Should link node should handle the import over autolink?
return null;
}
exportJSON(): SerializedAutoLinkNode {
return {
...super.exportJSON(),
type: 'autolink',
version: 1,
};
}
insertNewAfter(
selection: RangeSelection,
restoreSelection = true,
): null | ElementNode {
const element = this.getParentOrThrow().insertNewAfter(
selection,
restoreSelection,
);
if ($isElementNode(element)) {
const linkNode = $createAutoLinkNode(this.__url, {
rel: this._rel,
target: this.__target,
title: this.__title,
});
element.append(linkNode);
return linkNode;
}
return null;
}
}
/**
* Takes a URL and creates an AutoLinkNode. AutoLinkNodes are generally automatically generated
* during typing, which is especially useful when a button to generate a LinkNode is not practical.
* @param url - The URL the LinkNode should direct to.
* @param attributes - Optional HTML a tag attributes. { target, rel, title }
* @returns The LinkNode.
*/
export function $createAutoLinkNode(
url: string,
attributes?: LinkAttributes,
): AutoLinkNode {
return $applyNodeReplacement(new AutoLinkNode(url, attributes));
}
/**
* Determines if node is an AutoLinkNode.
* @param node - The node to be checked.
* @returns true if node is an AutoLinkNode, false otherwise.
*/
export function $isAutoLinkNode(
node: HapromptNode | null | undefined,
): node is AutoLinkNode {
return node instanceof AutoLinkNode;
}
export const TOGGLE_LINK_COMMAND: HapromptCommand<
string | ({url: string} & LinkAttributes) | null
> = createCommand('TOGGLE_LINK_COMMAND');
/**
* Generates or updates a LinkNode. It can also delete a LinkNode if the URL is null,
* but saves any children and brings them up to the parent node.
* @param url - The URL the link directs to.
* @param attributes - Optional HTML a tag attributes. { target, rel, title }
*/
export function toggleLink(
url: null | string,
attributes: LinkAttributes = {},
): void {
const {target, title} = attributes;
const rel = attributes.rel === undefined ? 'noreferrer' : attributes.rel;
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return;
}
const nodes = selection.extract();
if (url === null) {
// Remove LinkNodes
nodes.forEach((node) => {
const parent = node.getParent();
if ($isLinkNode(parent)) {
const children = parent.getChildren();
for (let i = 0; i < children.length; i++) {
parent.insertBefore(children[i]);
}
parent.remove();
}
});
} else {
// Add or merge LinkNodes
if (nodes.length === 1) {
const firstNode = nodes[0];
// if the first node is a LinkNode or if its
// parent is a LinkNode, we update the URL, target and rel.
const linkNode = $isLinkNode(firstNode)
? firstNode
: $getLinkAncestor(firstNode);
if (linkNode !== null) {
linkNode.setURL(url);
if (target !== undefined) {
linkNode.setTarget(target);
}
if (rel !== null) {
linkNode.setRel(rel);
}
if (title !== undefined) {
linkNode.setTitle(title);
}
return;
}
}
let prevParent: ElementNode | LinkNode | null = null;
let linkNode: LinkNode | null = null;
nodes.forEach((node) => {
const parent = node.getParent();
if (
parent === linkNode ||
parent === null ||
($isElementNode(node) && !node.isInline())
) {
return;
}
if ($isLinkNode(parent)) {
linkNode = parent;
parent.setURL(url);
if (target !== undefined) {
parent.setTarget(target);
}
if (rel !== null) {
linkNode.setRel(rel);
}
if (title !== undefined) {
linkNode.setTitle(title);
}
return;
}
if (!parent.is(prevParent)) {
prevParent = parent;
linkNode = $createLinkNode(url, {rel, target});
if ($isLinkNode(parent)) {
if (node.getPreviousSibling() === null) {
parent.insertBefore(linkNode);
} else {
parent.insertAfter(linkNode);
}
} else {
node.insertBefore(linkNode);
}
}
if ($isLinkNode(node)) {
if (node.is(linkNode)) {
return;
}
if (linkNode !== null) {
const children = node.getChildren();
for (let i = 0; i < children.length; i++) {
linkNode.append(children[i]);
}
}
node.remove();
return;
}
if (linkNode !== null) {
linkNode.append(node);
}
});
}
}
function $getLinkAncestor(node: HapromptNode): null | HapromptNode {
return $getAncestor(node, $isLinkNode);
}
function $getAncestor<NodeType extends HapromptNode = HapromptNode>(
node: HapromptNode,
predicate: (ancestor: HapromptNode) => ancestor is NodeType,
): null | HapromptNode {
let parent: null | HapromptNode = node;
while (
parent !== null &&
(parent = parent.getParent()) !== null &&
!predicate(parent)
);
return parent;
}