Files
dependabot[bot] fc01deeefd chore(deps-dev): bump oxlint-tsgolint from 0.14.2 to 0.22.1 (#12320)
* chore(deps-dev): bump oxlint-tsgolint from 0.14.2 to 0.22.1

Bumps [oxlint-tsgolint](https://github.com/oxc-project/tsgolint) from 0.14.2 to 0.22.1.
- [Release notes](https://github.com/oxc-project/tsgolint/releases)
- [Commits](https://github.com/oxc-project/tsgolint/compare/v0.14.2...v0.22.1)

---
updated-dependencies:
- dependency-name: oxlint-tsgolint
  dependency-version: 0.22.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore: Switch tsconfig to bundler resolution for tsgolint 0.22.1

oxlint-tsgolint 0.22.1 removed support for moduleResolution=node10
(the alias for "node"). Switch to "bundler" with resolvePackageJsonExports
disabled so packages whose exports field omits a types condition still
resolve. Update markdown-it type imports to sub-paths since the package's
.d.mts entry only re-exports a subset of named types.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: Resolve type-aware lint errors caught by tsgolint 0.22.1

oxlint-tsgolint 0.22.1 catches several await-thenable, no-floating-promises,
and no-meaningless-void-operator cases the prior 0.14.2 missed:

- Drop redundant inner `await` from Promise.all([await x, await y]) call sites
  so the array entries are real Promises rather than already-resolved values.
- Replace Promise.all wrappers around synchronous presenters (presentEvent,
  presentTemplate, presentPublicTeam) with plain map / direct calls.
- Wrap non-promise branches of ternaries inside Promise.all with
  Promise.resolve so the array remains thenable across both arms.
- Add `void` to the unawaited provider.connect() in the auth-failed retry
  chain, and remove `void` from the disconnect() call which returns void.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 07:59:13 -04:00

383 lines
10 KiB
TypeScript

import { t } from "i18next";
import type Token from "markdown-it/lib/token.mjs";
import { InputRule } from "prosemirror-inputrules";
import type { MarkdownSerializerState } from "prosemirror-markdown";
import type {
Attrs,
MarkSpec,
MarkType,
Node,
Mark as ProsemirrorMark,
} from "prosemirror-model";
import type { Command, EditorState } from "prosemirror-state";
import { Plugin, TextSelection } from "prosemirror-state";
import type { EditorView } from "prosemirror-view";
import { toast } from "sonner";
import { isUrl, sanitizeUrl } from "../../utils/urls";
import { getMarkRange } from "../queries/getMarkRange";
import Mark from "./Mark";
import {
addLink,
openLink,
removeLink,
updateLink,
toggleLink,
} from "../commands/link";
import { isInCode } from "../queries/isInCode";
const LINK_INPUT_REGEX = /\[([^[]+)]\((\S+)\)$/;
function isPlainURL(
link: ProsemirrorMark,
parent: Node,
index: number,
side: -1 | 1
) {
if (link.attrs.title || !/^\w+:/.test(link.attrs.href)) {
return false;
}
const content = parent.child(index + (side < 0 ? -1 : 0));
if (
!content.isText ||
content.text !== link.attrs.href ||
content.marks[content.marks.length - 1] !== link
) {
return false;
}
if (index === (side < 0 ? 1 : parent.childCount - 1)) {
return true;
}
const next = parent.child(index + (side < 0 ? -2 : 1));
return !link.isInSet(next.marks);
}
/**
* Options for the Link mark.
*/
type LinkOptions = {
/** Callback invoked when the user clicks any link in the document. */
onClickLink?: (
href: string,
event?: MouseEvent | React.MouseEvent<HTMLButtonElement>
) => void;
};
export default class Link extends Mark<LinkOptions> {
get name() {
return "link";
}
get schema(): MarkSpec {
return {
attrs: {
href: {
default: "",
validate: "string",
},
title: {
default: null,
validate: "string|null",
},
},
inclusive: false,
parseDOM: [
{
tag: "a[href]:not(.embed)",
getAttrs: (dom: HTMLElement) => ({
href: dom.getAttribute("href"),
title: dom.getAttribute("title"),
}),
},
],
toDOM: (node) => [
"a",
{
title: node.attrs.title,
href: sanitizeUrl(node.attrs.href),
class: "use-hover-preview",
rel: "noopener noreferrer nofollow",
},
0,
],
};
}
inputRules({ type }: { type: MarkType }) {
return [
new InputRule(LINK_INPUT_REGEX, (state, match, start, end) => {
const [okay, alt, href] = match;
const { tr } = state;
if (okay) {
tr.replaceWith(start, end, this.editor.schema.text(alt)).addMark(
start,
start + alt.length,
type.create({ href })
);
}
return tr;
}),
];
}
keys(): Record<string, Command> {
return {
"Mod-Enter": openLink(this.options.onClickLink),
};
}
commands() {
return {
link: (attrs: Attrs) => toggleLink(attrs),
addLink,
updateLink,
openLink: (): Command => openLink(this.options.onClickLink),
removeLink,
};
}
get plugins() {
const handleClick = (view: EditorView, pos: number) => {
const { doc, tr } = view.state;
const range = getMarkRange(
doc.resolve(pos),
this.editor.schema.marks.link
);
if (!range || range.from === pos || range.to === pos) {
return false;
}
try {
const $start = doc.resolve(range.from);
const $end = doc.resolve(range.to);
tr.setSelection(new TextSelection($start, $end));
view.dispatch(tr);
return true;
} catch (_err) {
// Failed to set selection
}
return false;
};
const plugin: Plugin = new Plugin({
props: {
decorations: (state: EditorState) => plugin.getState(state),
handleDOMEvents: {
contextmenu: (view: EditorView, event: MouseEvent) => {
const result = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (result) {
return handleClick(view, result.pos);
}
return false;
},
mousedown: (view: EditorView, event: MouseEvent) => {
const target = (event.target as HTMLElement)?.closest("a");
if (
!(target instanceof HTMLAnchorElement) ||
(event.button !== 0 && event.button !== 1)
) {
return false;
}
if (
target.role === "button" ||
target.matches(".component-attachment *")
) {
return false;
}
// If an image is selected in write mode, disallow navigation to its href
const selectedDOMNode = view.nodeDOM(view.state.selection.from);
if (
view.editable &&
selectedDOMNode &&
selectedDOMNode instanceof HTMLSpanElement &&
selectedDOMNode.classList.contains("component-image") &&
event.target instanceof HTMLImageElement &&
selectedDOMNode.contains(event.target)
) {
return false;
}
// clicking a link while editing should show the link toolbar,
// clicking in read-only will navigate
if (!view.editable || (view.editable && !view.hasFocus())) {
const href =
target.href ||
(target.parentNode instanceof HTMLAnchorElement
? target.parentNode.href
: "");
try {
const sanitized = sanitizeUrl(href);
if (this.options.onClickLink && sanitized) {
event.stopPropagation();
event.preventDefault();
this.options.onClickLink(sanitized, event);
}
} catch (_err) {
toast.error(t("Sorry, that type of link is not supported"));
}
return true;
}
const result = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (result && handleClick(view, result.pos)) {
event.preventDefault();
return true;
}
return false;
},
click: (_view: EditorView, event: MouseEvent) => {
if (
!(event.target instanceof HTMLAnchorElement) ||
(event.button !== 0 && event.button !== 1)
) {
return false;
}
if (
event.target.role === "button" ||
event.target.matches(".component-attachment *")
) {
return false;
}
// Prevent all default click behavior of links, see mousedown above
// for custom link handling.
if (this.options.onClickLink) {
event.stopPropagation();
event.preventDefault();
}
return false;
},
keydown: (view: EditorView, event: KeyboardEvent) => {
if (event.key !== " " && event.key !== "Enter") {
return false;
}
const { state } = view;
const { selection, schema } = state;
if (!selection.empty || !selection.$from.parent.isTextblock) {
return false;
}
let textContent = "";
selection.$from.parent.forEach((node) => {
if (node.isText && node.text) {
textContent += node.text;
}
});
const words = textContent.split(/\s+/);
if (!words.length) {
return false;
}
// check if there is a code mark at the current cursor position
const hasCodeMark = schema.marks.code_inline.isInSet(
selection.$from.marks()
);
if (hasCodeMark) {
return false;
}
// check if we are in a code block or code fence
if (isInCode(view.state, { onlyBlock: true })) {
return false;
}
const lastWord = words[words.length - 1];
if (
!lastWord ||
!isUrl(lastWord, {
requireProtocol: false,
})
) {
return false;
}
const lastWordIndex = textContent.lastIndexOf(lastWord);
if (lastWordIndex === -1) {
return false;
}
const start = selection.$from.start() + lastWordIndex;
const end = start + lastWord.length;
const href = lastWord.startsWith("www.")
? `https://${lastWord}`
: lastWord;
const tr = state.tr.addMark(
start,
end,
schema.marks.link.create({ href })
);
view.dispatch(tr);
return false;
},
},
},
});
return [plugin];
}
toMarkdown() {
return {
open: (
_state: MarkdownSerializerState,
mark: ProsemirrorMark,
parent: Node,
index: number
) => (isPlainURL(mark, parent, index, 1) ? "<" : "["),
close: (
state: MarkdownSerializerState,
mark: ProsemirrorMark,
parent: Node,
index: number
) =>
isPlainURL(mark, parent, index, -1)
? ">"
: "](" +
state.esc(mark.attrs.href) +
(mark.attrs.title ? " " + quote(mark.attrs.title) : "") +
")",
};
}
parseMarkdown() {
return {
mark: "link",
getAttrs: (token: Token) => ({
href: token.attrGet("href"),
title: token.attrGet("title") || null,
}),
};
}
}
function quote(str: string) {
const wrap =
str.indexOf('"') === -1 ? '""' : str.indexOf("'") === -1 ? "''" : "()";
return wrap[0] + str + wrap[1];
}