File size: 4,073 Bytes
aa1ee73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
const HTML_TAG_REGEX = /<\/?[a-z][\s\S]*>/i;
const EMPTY_PARAGRAPH_REGEX = /<p>(?:\s|&nbsp;|<br\s*\/?>)*<\/p>/gi;
const HTML_BREAK_REGEX = /<br\s*\/?>/gi;
const HTML_ANY_TAG_REGEX = /<\/?[^>]+>/g;
const INVISIBLE_WHITESPACE_REGEX = /[\s\u200B-\u200D\uFEFF]/g;
const TRAILING_LIST_PARAGRAPH_REGEX =
  /(<\/(?:ul|ol)>)\s*<p>(?:\s|&nbsp;|<br\s*\/?>)*<\/p>\s*$/i;
const RICH_TEXT_ANCHOR_REGEX = /<a\b([^>]*)>/gi;
const CLASS_ATTRIBUTE_REGEX = /\bclass\s*=\s*("([^"]*)"|'([^']*)')/i;
const CLASS_ATTRIBUTE_GLOBAL_REGEX = /\bclass\s*=\s*("([^"]*)"|'([^']*)')/gi;
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const DOMAIN_REGEX =
  /^(?:www\.)?[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+(?:[/?#][^\s]*)?$/i;
const SAFE_LINK_PROTOCOL_REGEX = /^(https?:|mailto:|tel:)/i;
const ANY_PROTOCOL_REGEX = /^[a-z][a-z\d+\-.]*:/i;
const LEGACY_RICH_TEXT_CLASSES = new Set(["custom-list", "custom-list-ordered"]);

const escapeHtml = (text: string) =>
  text
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;");

const decorateRichTextAnchors = (content: string) =>
  content.replace(RICH_TEXT_ANCHOR_REGEX, (match, attrs: string) => {
    if (CLASS_ATTRIBUTE_REGEX.test(attrs)) {
      return match.replace(CLASS_ATTRIBUTE_REGEX, (_classMatch, quotedValue, doubleQuoted, singleQuoted) => {
        const currentValue = doubleQuoted ?? singleQuoted ?? quotedValue ?? "";
        const classes = currentValue.split(/\s+/).filter(Boolean);

        if (!classes.includes("rich-text-link")) {
          classes.push("rich-text-link");
        }

        return `class="${classes.join(" ")}"`;
      });
    }

    return `<a class="rich-text-link"${attrs}>`;
  });

export const stripLegacyRichTextClasses = (content?: string) => {
  if (!content) return "";

  return content.replace(
    CLASS_ATTRIBUTE_GLOBAL_REGEX,
    (_match, quotedValue, doubleQuoted, singleQuoted) => {
      const currentValue = doubleQuoted ?? singleQuoted ?? quotedValue ?? "";
      const classes = currentValue
        .split(/\s+/)
        .filter(Boolean)
        .filter((className: string) => !LEGACY_RICH_TEXT_CLASSES.has(className));

      return classes.length ? `class="${classes.join(" ")}"` : "";
    }
  );
};

export const stripTrailingListParagraph = (content?: string) => {
  if (!content) return "";

  let normalized = content;

  while (TRAILING_LIST_PARAGRAPH_REGEX.test(normalized)) {
    normalized = normalized.replace(TRAILING_LIST_PARAGRAPH_REGEX, "$1");
  }

  return normalized;
};

export const normalizeLinkHref = (href?: string) => {
  if (!href) return null;

  const value = href.trim();
  if (!value) return null;

  if (SAFE_LINK_PROTOCOL_REGEX.test(value)) {
    return value;
  }

  if (value.startsWith("//")) {
    return `https:${value}`;
  }

  if (EMAIL_REGEX.test(value)) {
    return `mailto:${value}`;
  }

  if (DOMAIN_REGEX.test(value)) {
    return `https://${value}`;
  }

  if (ANY_PROTOCOL_REGEX.test(value)) {
    return null;
  }

  return null;
};

/**
 * 规范化富文本内容,解决以下问题:
 * 1. 纯文本中的换行无法在 HTML 中展示;
 * 2. TipTap 产生的空 <p> 标签没有高度。
 */
export const normalizeRichTextContent = (content?: string) => {
  if (!content) return "";

  let normalized = content;

  if (!HTML_TAG_REGEX.test(content)) {
    normalized = escapeHtml(content).replace(/\r\n|\r|\n/g, "<br />");
  }

  return decorateRichTextAnchors(
    stripTrailingListParagraph(stripLegacyRichTextClasses(normalized))
  ).replace(
    EMPTY_PARAGRAPH_REGEX,
    "<p><br /></p>"
  );
};

export const hasMeaningfulRichTextContent = (content?: string) => {
  if (!content) return false;

  if (!HTML_TAG_REGEX.test(content)) {
    return content.replace(INVISIBLE_WHITESPACE_REGEX, "").length > 0;
  }

  const plainText = content
    .replace(EMPTY_PARAGRAPH_REGEX, "")
    .replace(HTML_BREAK_REGEX, "")
    .replace(/&nbsp;/gi, " ")
    .replace(HTML_ANY_TAG_REGEX, "")
    .replace(INVISIBLE_WHITESPACE_REGEX, "");

  return plainText.length > 0;
};