This commit is contained in:
Philipinho
2026-08-08 22:25:33 +01:00
parent 92b6513f39
commit fed7d78495
29 changed files with 1091 additions and 122 deletions
@@ -1,8 +1,9 @@
export { IntegrationLink } from "./integration-link";
export { IntegrationLink, createIntegrationAttributes } from "./integration-link";
export type {
IntegrationLinkOptions,
IntegrationLinkAttributes,
} from "./integration-link";
export { IntegrationMention } from "./integration-mention";
export {
integrationLinkPatterns,
matchIntegrationLink,
@@ -4,6 +4,19 @@ export type IntegrationLinkPattern = {
};
export const integrationLinkPatterns: IntegrationLinkPattern[] = [
// Slack message permalink (host-specific; must precede the host-agnostic
// GitHub patterns, whose repo form would swallow /archives/<channel>)
{
provider: "slack",
regex:
/^https?:\/\/[a-z0-9-]+\.slack\.com\/archives\/([a-zA-Z0-9-]+)\/p(\d+)(?:\?thread_ts=[\d.]+&cid=[A-Za-z\d]+)?$/,
},
// Slack channel
{
provider: "slack",
regex:
/^https?:\/\/[a-z0-9-]+\.slack\.com\/archives\/([a-zA-Z0-9-]+)\/?$/,
},
// GitHub PR commit (must be before generic PR pattern)
{
provider: "github",
@@ -76,6 +89,18 @@ export const integrationLinkPatterns: IntegrationLinkPattern[] = [
regex:
/^https?:\/\/[^\/]+\/(.+)\/-\/issues\/(\d+)/,
},
// GitLab work item (new URL format for issues)
{
provider: "gitlab",
regex:
/^https?:\/\/[^\/]+\/(.+)\/-\/work_items\/(\d+)/,
},
// GitLab work item opened as a drawer over the list (?show=base64 payload)
{
provider: "gitlab",
regex:
/^https?:\/\/[^\/]+\/(.+)\/-\/work_items\/?\?(?:.*&)?show=/,
},
// GitLab commit
{
provider: "gitlab",
@@ -14,6 +14,55 @@ export interface IntegrationLinkAttributes {
status: "pending" | "loaded" | "error";
}
// Shared by IntegrationLink (block) and IntegrationMention (inline) so both
// serialize the same attrs and stay convertible into each other.
export function createIntegrationAttributes() {
return {
url: {
default: "",
parseHTML: (element: HTMLElement) => {
const url = element.getAttribute("data-url");
return sanitizeUrl(url);
},
renderHTML: (attributes: IntegrationLinkAttributes) => ({
"data-url": sanitizeUrl(attributes.url),
}),
},
provider: {
default: "",
parseHTML: (element: HTMLElement) => element.getAttribute("data-provider"),
renderHTML: (attributes: IntegrationLinkAttributes) => ({
"data-provider": attributes.provider,
}),
},
unfurlData: {
default: null,
parseHTML: (element: HTMLElement) => {
const data = element.getAttribute("data-unfurl");
if (!data) return null;
try {
return JSON.parse(data);
} catch {
return null;
}
},
renderHTML: (attributes: IntegrationLinkAttributes) => ({
"data-unfurl": attributes.unfurlData
? JSON.stringify(attributes.unfurlData)
: null,
}),
},
status: {
default: "pending",
parseHTML: (element: HTMLElement) =>
element.getAttribute("data-status") ?? "pending",
renderHTML: (attributes: IntegrationLinkAttributes) => ({
"data-status": attributes.status,
}),
},
};
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
integrationLink: {
@@ -41,49 +90,7 @@ export const IntegrationLink = Node.create<IntegrationLinkOptions>({
},
addAttributes() {
return {
url: {
default: "",
parseHTML: (element) => {
const url = element.getAttribute("data-url");
return sanitizeUrl(url);
},
renderHTML: (attributes: IntegrationLinkAttributes) => ({
"data-url": sanitizeUrl(attributes.url),
}),
},
provider: {
default: "",
parseHTML: (element) => element.getAttribute("data-provider"),
renderHTML: (attributes: IntegrationLinkAttributes) => ({
"data-provider": attributes.provider,
}),
},
unfurlData: {
default: null,
parseHTML: (element) => {
const data = element.getAttribute("data-unfurl");
if (!data) return null;
try {
return JSON.parse(data);
} catch {
return null;
}
},
renderHTML: (attributes: IntegrationLinkAttributes) => ({
"data-unfurl": attributes.unfurlData
? JSON.stringify(attributes.unfurlData)
: null,
}),
},
status: {
default: "pending",
parseHTML: (element) => element.getAttribute("data-status") ?? "pending",
renderHTML: (attributes: IntegrationLinkAttributes) => ({
"data-status": attributes.status,
}),
},
};
return createIntegrationAttributes();
},
parseHTML() {
@@ -0,0 +1,82 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { sanitizeUrl } from "../utils";
import {
createIntegrationAttributes,
IntegrationLinkAttributes,
IntegrationLinkOptions,
} from "./integration-link";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
integrationMention: {
setIntegrationMention: (
attributes: Partial<IntegrationLinkAttributes>,
) => ReturnType;
};
}
}
// Inline counterpart of IntegrationLink: same attrs, flows with text.
export const IntegrationMention = Node.create<IntegrationLinkOptions>({
name: "integrationMention",
inline: true,
group: "inline",
atom: true,
selectable: true,
draggable: false,
addOptions() {
return {
HTMLAttributes: {},
view: null,
};
},
addAttributes() {
return createIntegrationAttributes();
},
parseHTML() {
return [
{
tag: `span[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
const url = HTMLAttributes["data-url"];
const safeUrl = sanitizeUrl(url);
return [
"span",
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes,
),
["a", { href: safeUrl, target: "_blank", rel: "noopener" }, safeUrl],
];
},
addCommands() {
return {
setIntegrationMention:
(attrs) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: {
...attrs,
url: sanitizeUrl(attrs.url),
},
});
},
};
},
addNodeView() {
return ReactNodeViewRenderer(this.options.view);
},
});