fix: resolve TypeScript errors in frontend build

This commit is contained in:
Hiro
2026-03-30 23:16:07 +00:00
parent b733306773
commit 24925e1acb
2941 changed files with 418042 additions and 49 deletions

View File

@@ -0,0 +1,159 @@
import type { NodeWithPos } from '@tiptap/core'
import { combineTransactionSteps, findChildrenInRange, getChangedRanges, getMarksBetween } from '@tiptap/core'
import type { MarkType } from '@tiptap/pm/model'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import type { MultiToken } from 'linkifyjs'
import { tokenize } from 'linkifyjs'
import { UNICODE_WHITESPACE_REGEX, UNICODE_WHITESPACE_REGEX_END } from './whitespace.js'
/**
* Check if the provided tokens form a valid link structure, which can either be a single link token
* or a link token surrounded by parentheses or square brackets.
*
* This ensures that only complete and valid text is hyperlinked, preventing cases where a valid
* top-level domain (TLD) is immediately followed by an invalid character, like a number. For
* example, with the `find` method from Linkify, entering `example.com1` would result in
* `example.com` being linked and the trailing `1` left as plain text. By using the `tokenize`
* method, we can perform more comprehensive validation on the input text.
*/
function isValidLinkStructure(tokens: Array<ReturnType<MultiToken['toObject']>>) {
if (tokens.length === 1) {
return tokens[0].isLink
}
if (tokens.length === 3 && tokens[1].isLink) {
return ['()', '[]'].includes(tokens[0].value + tokens[2].value)
}
return false
}
type AutolinkOptions = {
type: MarkType
defaultProtocol: string
validate: (url: string) => boolean
shouldAutoLink: (url: string) => boolean
}
/**
* This plugin allows you to automatically add links to your editor.
* @param options The plugin options
* @returns The plugin instance
*/
export function autolink(options: AutolinkOptions): Plugin {
return new Plugin({
key: new PluginKey('autolink'),
appendTransaction: (transactions, oldState, newState) => {
/**
* Does the transaction change the document?
*/
const docChanges = transactions.some(transaction => transaction.docChanged) && !oldState.doc.eq(newState.doc)
/**
* Prevent autolink if the transaction is not a document change or if the transaction has the meta `preventAutolink`.
*/
const preventAutolink = transactions.some(transaction => transaction.getMeta('preventAutolink'))
/**
* Prevent autolink if the transaction is not a document change
* or if the transaction has the meta `preventAutolink`.
*/
if (!docChanges || preventAutolink) {
return
}
const { tr } = newState
const transform = combineTransactionSteps(oldState.doc, [...transactions])
const changes = getChangedRanges(transform)
changes.forEach(({ newRange }) => {
// Now lets see if we can add new links.
const nodesInChangedRanges = findChildrenInRange(newState.doc, newRange, node => node.isTextblock)
let textBlock: NodeWithPos | undefined
let textBeforeWhitespace: string | undefined
if (nodesInChangedRanges.length > 1) {
// Grab the first node within the changed ranges (ex. the first of two paragraphs when hitting enter).
textBlock = nodesInChangedRanges[0]
textBeforeWhitespace = newState.doc.textBetween(
textBlock.pos,
textBlock.pos + textBlock.node.nodeSize,
undefined,
' ',
)
} else if (nodesInChangedRanges.length) {
const endText = newState.doc.textBetween(newRange.from, newRange.to, ' ', ' ')
if (!UNICODE_WHITESPACE_REGEX_END.test(endText)) {
return
}
textBlock = nodesInChangedRanges[0]
textBeforeWhitespace = newState.doc.textBetween(textBlock.pos, newRange.to, undefined, ' ')
}
if (textBlock && textBeforeWhitespace) {
const wordsBeforeWhitespace = textBeforeWhitespace.split(UNICODE_WHITESPACE_REGEX).filter(Boolean)
if (wordsBeforeWhitespace.length <= 0) {
return false
}
const lastWordBeforeSpace = wordsBeforeWhitespace[wordsBeforeWhitespace.length - 1]
const lastWordAndBlockOffset = textBlock.pos + textBeforeWhitespace.lastIndexOf(lastWordBeforeSpace)
if (!lastWordBeforeSpace) {
return false
}
const linksBeforeSpace = tokenize(lastWordBeforeSpace).map(t => t.toObject(options.defaultProtocol))
if (!isValidLinkStructure(linksBeforeSpace)) {
return false
}
linksBeforeSpace
.filter(link => link.isLink)
// Calculate link position.
.map(link => ({
...link,
from: lastWordAndBlockOffset + link.start + 1,
to: lastWordAndBlockOffset + link.end + 1,
}))
// ignore link inside code mark
.filter(link => {
if (!newState.schema.marks.code) {
return true
}
return !newState.doc.rangeHasMark(link.from, link.to, newState.schema.marks.code)
})
// validate link
.filter(link => options.validate(link.value))
// check whether should autolink
.filter(link => options.shouldAutoLink(link.value))
// Add link mark.
.forEach(link => {
if (getMarksBetween(link.from, link.to, newState.doc).some(item => item.mark.type === options.type)) {
return
}
tr.addMark(
link.from,
link.to,
options.type.create({
href: link.href,
}),
)
})
}
})
if (!tr.steps.length) {
return
}
return tr
},
})
}

View File

@@ -0,0 +1,73 @@
import type { Editor } from '@tiptap/core'
import { getAttributes } from '@tiptap/core'
import type { MarkType } from '@tiptap/pm/model'
import { Plugin, PluginKey } from '@tiptap/pm/state'
type ClickHandlerOptions = {
type: MarkType
editor: Editor
openOnClick?: boolean
enableClickSelection?: boolean
}
export function clickHandler(options: ClickHandlerOptions): Plugin {
return new Plugin({
key: new PluginKey('handleClickLink'),
props: {
handleClick: (view, pos, event) => {
if (event.button !== 0) {
return false
}
if (!view.editable) {
return false
}
let link: HTMLAnchorElement | null = null
if (event.target instanceof HTMLAnchorElement) {
link = event.target
} else {
const target = event.target as HTMLElement | null
if (!target) {
return false
}
const root = options.editor.view.dom
// Tntentionally limit the lookup to the editor root.
// Using tag names like DIV as boundaries breaks with custom NodeViews,
link = target.closest<HTMLAnchorElement>('a')
if (link && !root.contains(link)) {
link = null
}
}
if (!link) {
return false
}
let handled = false
if (options.enableClickSelection) {
const commandResult = options.editor.commands.extendMarkRange(options.type.name)
handled = commandResult
}
if (options.openOnClick) {
const attrs = getAttributes(view.state, options.type.name)
const href = link.href ?? attrs.href
const target = link.target ?? attrs.target
if (href) {
window.open(href, target)
handled = true
}
}
return handled
},
},
})
}

View File

@@ -0,0 +1,49 @@
import type { Editor } from '@tiptap/core'
import type { MarkType } from '@tiptap/pm/model'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { find } from 'linkifyjs'
import type { LinkOptions } from '../link.js'
type PasteHandlerOptions = {
editor: Editor
defaultProtocol: string
type: MarkType
shouldAutoLink?: LinkOptions['shouldAutoLink']
}
export function pasteHandler(options: PasteHandlerOptions): Plugin {
return new Plugin({
key: new PluginKey('handlePasteLink'),
props: {
handlePaste: (view, _event, slice) => {
const { shouldAutoLink } = options
const { state } = view
const { selection } = state
const { empty } = selection
if (empty) {
return false
}
let textContent = ''
slice.content.forEach(node => {
textContent += node.textContent
})
const link = find(textContent, { defaultProtocol: options.defaultProtocol }).find(
item => item.isLink && item.value === textContent,
)
if (!textContent || !link || (shouldAutoLink !== undefined && !shouldAutoLink(link.value))) {
return false
}
return options.editor.commands.setMark(options.type, {
href: link.href,
})
},
},
})
}

View File

@@ -0,0 +1,7 @@
// From DOMPurify
// https://github.com/cure53/DOMPurify/blob/main/src/regexp.ts
export const UNICODE_WHITESPACE_PATTERN = '[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]'
export const UNICODE_WHITESPACE_REGEX = new RegExp(UNICODE_WHITESPACE_PATTERN)
export const UNICODE_WHITESPACE_REGEX_END = new RegExp(`${UNICODE_WHITESPACE_PATTERN}$`)
export const UNICODE_WHITESPACE_REGEX_GLOBAL = new RegExp(UNICODE_WHITESPACE_PATTERN, 'g')

5
node_modules/@tiptap/extension-link/src/index.ts generated vendored Normal file
View File

@@ -0,0 +1,5 @@
import { Link } from './link.js'
export * from './link.js'
export default Link

489
node_modules/@tiptap/extension-link/src/link.ts generated vendored Normal file
View File

@@ -0,0 +1,489 @@
import type { PasteRuleMatch } from '@tiptap/core'
import { Mark, markPasteRule, mergeAttributes } from '@tiptap/core'
import type { Plugin } from '@tiptap/pm/state'
import { find, registerCustomProtocol, reset } from 'linkifyjs'
import { autolink } from './helpers/autolink.js'
import { clickHandler } from './helpers/clickHandler.js'
import { pasteHandler } from './helpers/pasteHandler.js'
import { UNICODE_WHITESPACE_REGEX_GLOBAL } from './helpers/whitespace.js'
export interface LinkProtocolOptions {
/**
* The protocol scheme to be registered.
* @default '''
* @example 'ftp'
* @example 'git'
*/
scheme: string
/**
* If enabled, it allows optional slashes after the protocol.
* @default false
* @example true
*/
optionalSlashes?: boolean
}
export const pasteRegex =
/https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z]{2,}\b(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)/gi
/**
* @deprecated The default behavior is now to open links when the editor is not editable.
*/
type DeprecatedOpenWhenNotEditable = 'whenNotEditable'
export interface LinkOptions {
/**
* If enabled, the extension will automatically add links as you type.
* @default true
* @example false
*/
autolink: boolean
/**
* An array of custom protocols to be registered with linkifyjs.
* @default []
* @example ['ftp', 'git']
*/
protocols: Array<LinkProtocolOptions | string>
/**
* Default protocol to use when no protocol is specified.
* @default 'http'
*/
defaultProtocol: string
/**
* If enabled, links will be opened on click.
* @default true
* @example false
*/
openOnClick: boolean | DeprecatedOpenWhenNotEditable
/**
* If enabled, the link will be selected when clicked.
* @default false
* @example true
*/
enableClickSelection: boolean
/**
* Adds a link to the current selection if the pasted content only contains an url.
* @default true
* @example false
*/
linkOnPaste: boolean
/**
* HTML attributes to add to the link element.
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>
/**
* @deprecated Use the `shouldAutoLink` option instead.
* A validation function that modifies link verification for the auto linker.
* @param url - The url to be validated.
* @returns - True if the url is valid, false otherwise.
*/
validate: (url: string) => boolean
/**
* A validation function which is used for configuring link verification for preventing XSS attacks.
* Only modify this if you know what you're doing.
*
* @returns {boolean} `true` if the URL is valid, `false` otherwise.
*
* @example
* isAllowedUri: (url, { defaultValidate, protocols, defaultProtocol }) => {
* return url.startsWith('./') || defaultValidate(url)
* }
*/
isAllowedUri: (
/**
* The URL to be validated.
*/
url: string,
ctx: {
/**
* The default validation function.
*/
defaultValidate: (url: string) => boolean
/**
* An array of allowed protocols for the URL (e.g., "http", "https"). As defined in the `protocols` option.
*/
protocols: Array<LinkProtocolOptions | string>
/**
* A string that represents the default protocol (e.g., 'http'). As defined in the `defaultProtocol` option.
*/
defaultProtocol: string
},
) => boolean
/**
* Determines whether a valid link should be automatically linked in the content.
*
* @param {string} url - The URL that has already been validated.
* @returns {boolean} - True if the link should be auto-linked; false if it should not be auto-linked.
*/
shouldAutoLink: (url: string) => boolean
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
link: {
/**
* Set a link mark
* @param attributes The link attributes
* @example editor.commands.setLink({ href: 'https://tiptap.dev' })
*/
setLink: (attributes: {
href: string
target?: string | null
rel?: string | null
class?: string | null
title?: string | null
}) => ReturnType
/**
* Toggle a link mark
* @param attributes The link attributes
* @example editor.commands.toggleLink({ href: 'https://tiptap.dev' })
*/
toggleLink: (attributes?: {
href: string
target?: string | null
rel?: string | null
class?: string | null
title?: string | null
}) => ReturnType
/**
* Unset a link mark
* @example editor.commands.unsetLink()
*/
unsetLink: () => ReturnType
}
}
}
export function isAllowedUri(uri: string | undefined, protocols?: LinkOptions['protocols']) {
const allowedProtocols: string[] = ['http', 'https', 'ftp', 'ftps', 'mailto', 'tel', 'callto', 'sms', 'cid', 'xmpp']
if (protocols) {
protocols.forEach(protocol => {
const nextProtocol = typeof protocol === 'string' ? protocol : protocol.scheme
if (nextProtocol) {
allowedProtocols.push(nextProtocol)
}
})
}
return (
!uri ||
uri.replace(UNICODE_WHITESPACE_REGEX_GLOBAL, '').match(
new RegExp(
// eslint-disable-next-line no-useless-escape
`^(?:(?:${allowedProtocols.join('|')}):|[^a-z]|[a-z0-9+.\-]+(?:[^a-z+.\-:]|$))`,
'i',
),
)
)
}
/**
* This extension allows you to create links.
* @see https://www.tiptap.dev/api/marks/link
*/
export const Link = Mark.create<LinkOptions>({
name: 'link',
priority: 1000,
keepOnSplit: false,
exitable: true,
onCreate() {
// TODO: v4 - remove validate option
if (this.options.validate && !this.options.shouldAutoLink) {
// Copy the validate function to the shouldAutoLink option
this.options.shouldAutoLink = this.options.validate
console.warn('The `validate` option is deprecated. Rename to the `shouldAutoLink` option instead.')
}
this.options.protocols.forEach(protocol => {
if (typeof protocol === 'string') {
registerCustomProtocol(protocol)
return
}
registerCustomProtocol(protocol.scheme, protocol.optionalSlashes)
})
},
onDestroy() {
reset()
},
inclusive() {
return this.options.autolink
},
addOptions() {
return {
openOnClick: true,
enableClickSelection: false,
linkOnPaste: true,
autolink: true,
protocols: [],
defaultProtocol: 'http',
HTMLAttributes: {
target: '_blank',
rel: 'noopener noreferrer nofollow',
class: null,
},
isAllowedUri: (url, ctx) => !!isAllowedUri(url, ctx.protocols),
validate: url => !!url,
shouldAutoLink: url => {
// URLs with explicit protocols (e.g., https://) should be auto-linked
// But not if @ appears before :// (that would be userinfo like user:pass@host)
const hasProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(url)
const hasMaybeProtocol = /^[a-z][a-z0-9+.-]*:/i.test(url)
if (hasProtocol || (hasMaybeProtocol && !url.includes('@'))) {
return true
}
// Strip userinfo (user:pass@) if present, then extract hostname
const urlWithoutUserinfo = url.includes('@') ? url.split('@').pop()! : url
const hostname = urlWithoutUserinfo.split(/[/?#:]/)[0]
// Don't auto-link IP addresses without protocol
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) {
return false
}
// Don't auto-link single-word hostnames without TLD (e.g., "localhost")
if (!/\./.test(hostname)) {
return false
}
return true
},
}
},
addAttributes() {
return {
href: {
default: null,
parseHTML(element) {
return element.getAttribute('href')
},
},
target: {
default: this.options.HTMLAttributes.target,
},
rel: {
default: this.options.HTMLAttributes.rel,
},
class: {
default: this.options.HTMLAttributes.class,
},
title: {
default: null,
},
}
},
parseHTML() {
return [
{
tag: 'a[href]',
getAttrs: dom => {
const href = (dom as HTMLElement).getAttribute('href')
// prevent XSS attacks
if (
!href ||
!this.options.isAllowedUri(href, {
defaultValidate: url => !!isAllowedUri(url, this.options.protocols),
protocols: this.options.protocols,
defaultProtocol: this.options.defaultProtocol,
})
) {
return false
}
return null
},
},
]
},
renderHTML({ HTMLAttributes }) {
// prevent XSS attacks
if (
!this.options.isAllowedUri(HTMLAttributes.href, {
defaultValidate: href => !!isAllowedUri(href, this.options.protocols),
protocols: this.options.protocols,
defaultProtocol: this.options.defaultProtocol,
})
) {
// strip out the href
return ['a', mergeAttributes(this.options.HTMLAttributes, { ...HTMLAttributes, href: '' }), 0]
}
return ['a', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
},
markdownTokenName: 'link',
parseMarkdown: (token, helpers) => {
return helpers.applyMark('link', helpers.parseInline(token.tokens || []), {
href: token.href,
title: token.title || null,
})
},
renderMarkdown: (node, h) => {
const href = node.attrs?.href ?? ''
const title = node.attrs?.title ?? ''
const text = h.renderChildren(node)
return title ? `[${text}](${href} "${title}")` : `[${text}](${href})`
},
addCommands() {
return {
setLink:
attributes =>
({ chain }) => {
const { href } = attributes
if (
!this.options.isAllowedUri(href, {
defaultValidate: url => !!isAllowedUri(url, this.options.protocols),
protocols: this.options.protocols,
defaultProtocol: this.options.defaultProtocol,
})
) {
return false
}
return chain().setMark(this.name, attributes).setMeta('preventAutolink', true).run()
},
toggleLink:
attributes =>
({ chain }) => {
const { href } = attributes || {}
if (
href &&
!this.options.isAllowedUri(href, {
defaultValidate: url => !!isAllowedUri(url, this.options.protocols),
protocols: this.options.protocols,
defaultProtocol: this.options.defaultProtocol,
})
) {
return false
}
return chain()
.toggleMark(this.name, attributes, { extendEmptyMarkRange: true })
.setMeta('preventAutolink', true)
.run()
},
unsetLink:
() =>
({ chain }) => {
return chain().unsetMark(this.name, { extendEmptyMarkRange: true }).setMeta('preventAutolink', true).run()
},
}
},
addPasteRules() {
return [
markPasteRule({
find: text => {
const foundLinks: PasteRuleMatch[] = []
if (text) {
const { protocols, defaultProtocol } = this.options
const links = find(text).filter(
item =>
item.isLink &&
this.options.isAllowedUri(item.value, {
defaultValidate: href => !!isAllowedUri(href, protocols),
protocols,
defaultProtocol,
}),
)
if (links.length) {
links.forEach(link => {
if (!this.options.shouldAutoLink(link.value)) {
return
}
foundLinks.push({
text: link.value,
data: {
href: link.href,
},
index: link.start,
})
})
}
}
return foundLinks
},
type: this.type,
getAttributes: match => {
return {
href: match.data?.href,
}
},
}),
]
},
addProseMirrorPlugins() {
const plugins: Plugin[] = []
const { protocols, defaultProtocol } = this.options
if (this.options.autolink) {
plugins.push(
autolink({
type: this.type,
defaultProtocol: this.options.defaultProtocol,
validate: url =>
this.options.isAllowedUri(url, {
defaultValidate: href => !!isAllowedUri(href, protocols),
protocols,
defaultProtocol,
}),
shouldAutoLink: this.options.shouldAutoLink,
}),
)
}
plugins.push(
clickHandler({
type: this.type,
editor: this.editor,
openOnClick: this.options.openOnClick === 'whenNotEditable' ? true : this.options.openOnClick,
enableClickSelection: this.options.enableClickSelection,
}),
)
if (this.options.linkOnPaste) {
plugins.push(
pasteHandler({
editor: this.editor,
defaultProtocol: this.options.defaultProtocol,
type: this.type,
shouldAutoLink: this.options.shouldAutoLink,
}),
)
}
return plugins
},
})