Web Totals

Học từ Facebook #73: Auto-link URL, cắt chữ an toàn, giới hạn LaTeX

Facebook tìm URL trong bài viết bằng cách đếm ngoặc cân bằng, cắt "Xem thêm" không chặt đôi link, chặn công thức LaTeX lồng quá sâu; kèm một lỗi gõ #.

hunghg255hunghg25524 min read
#facebook#text#regex#unicode#security
Học từ Facebook #73: Auto-link URL, cắt chữ an toàn, giới hạn LaTeX

Chữ người dùng gõ là thứ khó đoán nhất trong một trang mạng xã hội. Ba việc tưởng nhỏ nhưng làm sai là thấy ngay:

  • Biến URL thành link. Câu "(xem https://vi.wikipedia.org/wiki/Sao_Thủy_(hành_tinh))." có hai dấu ) và một dấu . sau URL. Lấy tới khoảng trắng thì link hỏng; dừng ở ( đầu tiên thì cũng hỏng.
  • Cắt chữ cho nút "Xem thêm". Cắt ở ký tự thứ 500 có thể rơi giữa một URL (link nửa vời trỏ sang trang 404) hoặc giữa một emoji gia đình 👨‍👩‍👧 (hiện ra ô vuông).
  • Render công thức toán trong Messenger. {{{{…}}}} lồng vài nghìn bậc là đủ để trình render đệ quy tới tràn stack, ngay trên máy người nhận.

Mã nguồn facebook.com có lời giải cho cả ba, và một lỗi nhỏ đáng học ở phần gợi ý hashtag.

Tip

Tóm tắt

  • URLMatcher tìm điểm bắt đầu bằng regex, rồi quét tay phần đuôi: ( tăng độ sâu, ) giảm và chỉ "chốt" khi về 0; dấu câu cuối không tính trừ khi phía sau còn chữ.
  • ExpandableText.react cắt ở ký tự 500 (mỗi lần "Thêm" mở thêm 500); ExpandableTextUtils lùi chỗ cắt về đầu URL nếu chỗ cắt rơi vào giữa URL. Bản viết lại lùi tiếp về ranh giới grapheme bằng Intl.Segmenter.
  • MessengerMathUtils đo độ lồng của {} và \begin…\end trước khi render; quá 20 bậc hoặc dài quá 1000 ký tự thì báo "Equation is too complex".
  • Lỗi đáng nhớ: endsWith("\\uFF03") so với chuỗi 6 ký tự, nên dấu # toàn khổ không bao giờ mở gợi ý hashtag.

#Facebook làm thế nào?

#URLMatcher: regex để tìm chỗ bắt đầu, vòng lặp để tìm chỗ kết thúc

Module URLMatcher (bundle 15.js) không cố viết một regex "bắt URL hoàn hảo". Nó tách bài toán làm hai:

  1. Một regex cho phần đầu URL: scheme (https://) hoặc www., rồi host. Phần host có danh sách ký tự cho phép và TLD.
  2. Một vòng lặp tay cho phần đuôi (path, query, fragment). Vòng lặp này giữ một bộ đếm ngoặc:
    • gặp ( thì tăng;
    • gặp ) thì giảm, và chỉ khi về 0 mới coi phần trong ngoặc là thuộc URL;
    • gặp dấu câu như ., ,, !, ? thì chưa chốt ngay, chỉ chốt nếu phía sau còn ký tự thường.

Nhờ vậy https://en.wikipedia.org/wiki/Bash_(Unix_shell) giữ được (Unix_shell), còn (link: https://example.com/a). thì bỏ ) và . của câu văn. Cuối cùng module lọc bỏ những thứ trông như email (an@www.example.com không thành link www.example.com).

Lý do không dùng một regex duy nhất: "ngoặc cân bằng" không biểu diễn được bằng regex thường (cần đếm), và một regex cố làm việc đó dễ thành regex chạy lâu theo hàm mũ (ReDoS) với input xấu. Vòng lặp tay chạy tuyến tính.

#ExpandableTextUtils: đừng cắt giữa URL

ExpandableTextUtils.adjustCutoffToAvoidTruncatingURLs (bundle 189.js) nhận đoạn text và chỗ cắt dự kiến. Nó chạy URLMatcher trên text; nếu một URL phủ vị trí limit − 1 thì chỗ cắt lùi về đầu URL đó. Phần "Xem thêm" sẽ bắt đầu bằng nguyên URL, không có link cụt.

Người gọi là component ExpandableText.react (cùng bundle). Chỗ cắt và bước nhảy nằm ở component chứ không ở hàm tiện ích: defaultProps có cutoff: 500 và grow: 500, mỗi lần bấm "Thêm" thì cutoff += grow, bấm "Ít hơn" thì trừ lại. Đầu module còn có một hằng 50, nhưng biến đó bị gán đè ngay phía dưới nên có lẽ là phần sót lại, không được dùng làm chỗ cắt. Việc lùi về đầu URL chỉ chạy khi component nhận prop avoidTruncatingURLs; sau khi cắt bằng substr, text được trim().

Component còn một chế độ penalizeNewLine: mỗi dấu xuống dòng tính thành newLinePenalizationWeight ký tự, để đoạn có nhiều dòng ngắn không chiếm cả màn hình. Nhánh này tự đếm từng ký tự và không gọi adjustCutoffToAvoidTruncatingURLs, nên bật nó lên là mất luôn phần bảo vệ URL.

Cả hai nhánh đều không quan tâm tới emoji hay dấu tổ hợp: chỗ cắt vẫn có thể rơi giữa cặp surrogate của 👋 hay giữa chuỗi ZWJ của 👨‍👩‍👧 (nhánh penalizeNewLine còn dùng split(""), tách theo code unit).

#MessengerMathUtils: đếm độ lồng trước khi render

Messenger cho gõ công thức LaTeX và render bằng KaTeX. MessengerMath.react kiểm tra hai thứ trước khi render:

  • độ dài thân công thức > maxLength (1000);
  • MessengerMathUtils.getMaxBraceDepth(body) > maxBraceDepth (20).

Vượt một trong hai thì hiện lỗi "Equation is too complex" thay vì gọi KaTeX. Ngoài ra KaTeX được gọi với maxSize: 5 (giới hạn kích thước ký hiệu co giãn) và chỉ khi công thức nằm trong khoảng 500px quanh viewport.

getMaxBraceDepth (bundle 117.js) đếm như sau: \begin cộng 2, \end trừ 2, \ theo sau bởi ký tự khác thì bỏ qua ký tự đó (để \{ không bị đếm), { cộng 1, } trừ 1. Hàm lấy giá trị lớn nhất của bộ đếm.

Chỗ đáng sửa: bộ đếm được phép xuống âm. Chuỗi }}}}}} ở đầu kéo bộ đếm về −6, và 24 dấu { phía sau chỉ đo được 18. Công thức lồng 24 bậc lọt qua giới hạn 20. KaTeX vẫn có thể báo lỗi cú pháp vì } thừa, nhưng bước bảo vệ đã bị vượt qua; nút "} thừa ở đầu" trong demo cho thấy hai con số.

#HashtagSuggestionsStrategy: một dấu gạch chéo thừa

Composer mở gợi ý hashtag khi chữ vừa gõ kết thúc bằng #. Bàn phím tiếng Nhật, Trung ở chế độ toàn khổ gõ ra # (U+FF03), nên code có kiểm tra thêm:

a.endsWith("#") || a.endsWith("\\uFF03")
js

Trong mã nguồn đã build, chuỗi là "\\uFF03": dấu \ được escape, nên đây là chuỗi 6 ký tự #, không phải ký tự #. Không ai gõ sáu ký tự đó, nên nhánh này không bao giờ đúng. Có lẽ lỗi sinh ra khi một công cụ build escape lại chuỗi đã escape.

#Bên trong trình duyệt: "một ký tự" là gì?

JavaScript đếm chuỗi theo UTF-16 code unit. '👋'.length === 2, '👨‍👩‍👧'.length === 8 (ba emoji, mỗi cái 2 unit, nối bằng hai ZWJ). slice(0, n) cắt theo code unit, nên có thể để lại nửa cặp surrogate: trình duyệt hiện �.

Thứ người dùng thấy là grapheme cluster: chữ cái + dấu tổ hợp, emoji + modifier màu da, chuỗi ZWJ. Intl.Segmenter với granularity: 'grapheme' (có trong mọi trình duyệt lớn tính tới 10/2026) trả các ranh giới đó.

flowchart LR
  A["cutoff = 500"] --> B{"rơi vào giữa URL?"}
  B -- có --> C["lùi về đầu URL"]
  B -- không --> D["giữ limit"]
  C --> E{"giữa grapheme?"}
  D --> E
  E -- có --> F["lùi về ranh giới gần nhất"]
  E -- không --> G["cắt"]
  F --> G

#Viết lại

Phần tìm URL giữ đúng ý tưởng của URLMatcher: regex chỉ tìm điểm bắt đầu và lấy "ứng viên" tới khoảng trắng hoặc ký tự cấm, rồi trimUrl quét tay:

src/lib/autolink.ts
export function trimUrl(candidate: string): string {
  let end = 0;
  let depth = 0;
  for (let i = 0; i < candidate.length; i++) {
    const ch = candidate[i];
    if (ch === '(') {
      depth++;
      continue;
    }
    if (ch === ')') {
      if (depth === 0) break;
      depth--;
      if (depth === 0) end = i + 1;
      continue;
    }
    if (depth > 0) continue; // trong ngoặc: chờ tới khi đóng mới chốt
    if (TRAILING.test(ch)) continue; // dấu câu: chỉ được giữ nếu phía sau còn chữ
    end = i + 1;
  }
  return candidate.slice(0, end);
}
ts

Cắt chữ: lùi về đầu URL như bản gốc, rồi lùi tiếp về ranh giới grapheme:

src/lib/autolink.ts
export function safeCutoff(text: string, limit: number): number {
  if (limit >= text.length) return text.length;
  let cut = limit;
  for (const m of findUrls(text)) {
    if (m.start >= cut) break;
    if (m.end > cut) {
      cut = m.start;
      break;
    }
  }
  return toGraphemeBoundary(text, cut);
}
ts

Đếm độ lồng LaTeX, không cho bộ đếm xuống dưới 0:

src/lib/autolink.ts
export function maxBraceDepth(tex: string): number {
  let depth = 0;
  let max = 0;
  for (let i = 0; i < tex.length; i++) {
    const ch = tex[i];
    if (ch === '\\') {
      if (tex.startsWith('\\begin', i)) {
        depth += 2;
        max = Math.max(max, depth);
      } else if (tex.startsWith('\\end', i)) {
        depth = Math.max(0, depth - 2);
      } else {
        i++; // bỏ qua ký tự được escape
      }
    } else if (ch === '{') {
      depth++;
      max = Math.max(max, depth);
    } else if (ch === '}') {
      depth = Math.max(0, depth - 1);
    }
  }
  return max;
}
ts
Xem toàn bộ src/lib/autolink.ts
src/lib/autolink.ts
/**
 * Ba tiện ích quanh "chữ người dùng gõ" (ý tưởng từ URLMatcher, ExpandableTextUtils,
 * MessengerMathUtils và HashtagSuggestionsStrategy của Facebook):
 *  - tìm URL trong đoạn văn, nuốt đúng cặp ngoặc nhưng bỏ dấu câu cuối câu
 *  - cắt chữ cho "Xem thêm" mà không chặt đôi một URL (hay một emoji)
 *  - chặn công thức LaTeX lồng quá sâu trước khi đưa cho trình render
 */

// ---- 1. Tìm URL ----

/** Điểm bắt đầu: có scheme, hoặc www. (www2., www3. cũng được). */
const START = /(?:\bhttps?:\/\/|\bwww\d{0,3}\.)[^\s<>"'`]+/giu;
/** Dấu câu thường đứng sau URL trong câu văn, không thuộc về URL. */
const TRAILING = /[.,;:!?'"»… -]/u;

/**
 * Cắt phần đuôi của một ứng viên URL:
 *  - `(` mở một nhóm; chỉ "chốt" phần trong ngoặc khi gặp `)` đóng đúng cặp
 *  - `)` không có cặp (vd URL nằm trong ngoặc của câu văn) là điểm dừng
 *  - dấu câu ở cuối không được tính, trừ khi phía sau còn ký tự thường
 */
export function trimUrl(candidate: string): string {
  let end = 0;
  let depth = 0;
  for (let i = 0; i < candidate.length; i++) {
    const ch = candidate[i];
    if (ch === '(') {
      depth++;
      continue;
    }
    if (ch === ')') {
      if (depth === 0) break;
      depth--;
      if (depth === 0) end = i + 1;
      continue;
    }
    if (depth > 0) continue; // trong ngoặc: chờ tới khi đóng mới chốt
    if (TRAILING.test(ch)) continue; // dấu câu: chỉ được giữ nếu phía sau còn chữ
    end = i + 1;
  }
  return candidate.slice(0, end);
}

export interface UrlMatch {
  url: string;
  start: number;
  end: number;
}

export function findUrls(text: string): UrlMatch[] {
  const out: UrlMatch[] = [];
  for (const m of text.matchAll(START)) {
    const url = trimUrl(m[0]);
    if (url.length < 5) continue;
    out.push({ url, start: m.index!, end: m.index! + url.length });
  }
  return out;
}

/** Cách "ngây thơ" hay gặp: lấy hết tới khoảng trắng. */
export function findUrlsNaive(text: string): UrlMatch[] {
  return [...text.matchAll(/https?:\/\/\S+|www\.\S+/gi)].map((m) => ({
    url: m[0],
    start: m.index!,
    end: m.index! + m[0].length,
  }));
}

export type Segment = { kind: 'text'; text: string } | { kind: 'link'; text: string; href: string };

export function linkify(text: string, matches = findUrls(text)): Segment[] {
  const segments: Segment[] = [];
  let pos = 0;
  for (const m of matches) {
    if (m.start > pos) segments.push({ kind: 'text', text: text.slice(pos, m.start) });
    const href = /^https?:\/\//i.test(m.url) ? m.url : `https://${m.url}`;
    segments.push({ kind: 'link', text: m.url, href });
    pos = m.end;
  }
  if (pos < text.length) segments.push({ kind: 'text', text: text.slice(pos) });
  return segments;
}

// ---- 2. Cắt chữ không chặt URL ----

const graphemes =
  typeof Intl !== 'undefined' && 'Segmenter' in Intl
    ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
    : null;

/** Lùi `index` về ranh giới grapheme gần nhất (không cắt giữa emoji hay chữ có dấu tổ hợp). */
function toGraphemeBoundary(text: string, index: number): number {
  if (!graphemes) {
    // không có Segmenter: ít nhất đừng cắt giữa cặp surrogate
    const code = text.charCodeAt(index - 1);
    return code >= 0xd800 && code <= 0xdbff ? index - 1 : index;
  }
  let last = 0;
  for (const { index: i } of graphemes.segment(text)) {
    if (i > index) break;
    last = i;
  }
  return last;
}

/**
 * Chỗ cắt an toàn cho đoạn "Xem thêm": nếu `limit` rơi vào giữa một URL thì lùi về
 * đầu URL đó (cả URL xuống dòng ẩn), rồi lùi tiếp về ranh giới grapheme.
 */
export function safeCutoff(text: string, limit: number): number {
  if (limit >= text.length) return text.length;
  let cut = limit;
  for (const m of findUrls(text)) {
    if (m.start >= cut) break;
    if (m.end > cut) {
      cut = m.start;
      break;
    }
  }
  return toGraphemeBoundary(text, cut);
}

// ---- 3. Giới hạn độ sâu LaTeX ----

/**
 * Độ lồng sâu nhất của `{…}` và `\begin…\end` (mỗi môi trường tính 2 bậc, như bản gốc).
 * Ký tự sau `\` được bỏ qua, nên `\{` không bị đếm.
 * Khác bản gốc: không để độ sâu âm. Bản gốc cho `}` thừa kéo bộ đếm xuống dưới 0,
 * khiến các `{` phía sau bị đếm thiếu.
 */
export function maxBraceDepth(tex: string): number {
  let depth = 0;
  let max = 0;
  for (let i = 0; i < tex.length; i++) {
    const ch = tex[i];
    if (ch === '\\') {
      if (tex.startsWith('\\begin', i)) {
        depth += 2;
        max = Math.max(max, depth);
      } else if (tex.startsWith('\\end', i)) {
        depth = Math.max(0, depth - 2);
      } else {
        i++; // bỏ qua ký tự được escape
      }
    } else if (ch === '{') {
      depth++;
      max = Math.max(max, depth);
    } else if (ch === '}') {
      depth = Math.max(0, depth - 1);
    }
  }
  return max;
}

export interface LatexLimits {
  maxLength: number;
  maxDepth: number;
}

/** Giới hạn mặc định giống Messenger: dài tối đa 1000 ký tự, lồng tối đa 20. */
export const MESSENGER_LIMITS: LatexLimits = { maxLength: 1000, maxDepth: 20 };

export function checkLatex(tex: string, limits = MESSENGER_LIMITS): { ok: boolean; reason?: string; depth: number } {
  const depth = maxBraceDepth(tex);
  if (tex.length > limits.maxLength) return { ok: false, reason: 'quá dài', depth };
  if (depth > limits.maxDepth) return { ok: false, reason: 'lồng quá sâu', depth };
  return { ok: true, depth };
}

// ---- 4. Dấu # để mở gợi ý hashtag ----

/** `#` thường hoặc `#` (U+FF03, bàn phím tiếng Nhật/Trung gõ ra). */
export function endsWithHashTrigger(text: string): boolean {
  return text.endsWith('#') || text.endsWith('#');
}

/** Cách viết của bản gốc: chuỗi 6 ký tự "#" chứ không phải ký tự U+FF03. */
export function endsWithHashTriggerFbStyle(text: string): boolean {
  return text.endsWith('#') || text.endsWith('\\uFF03');
}
ts

#Đi qua code

  • START chỉ bắt https?:// hoặc www, www2.…, rồi lấy tham lam tới khoảng trắng, <, >, dấu nháy. Cờ u để \b và lớp ký tự làm việc đúng với chữ có dấu.
  • trimUrl đi qua từng ký tự của ứng viên. end là vị trí cuối đã "chốt". Ký tự thường được chốt ngay. Dấu câu không được chốt, nhưng nếu sau nó còn ký tự thường thì ký tự đó chốt luôn cả dấu câu (file.tar.gz. giữ được file.tar.gz). Trong ngoặc thì chờ tới ) đóng đúng cặp mới chốt cả cụm; ) không có cặp là điểm dừng.
  • linkify đổi danh sách match thành các đoạn text/link; URL bắt đầu bằng www. được thêm https:// cho href. Render bằng React, không dùng innerHTML, nên không cần escape tay.
  • safeCutoff dừng ở URL đầu tiên phủ qua limit; toGraphemeBoundary duyệt các grapheme tới khi vượt limit. Không có Intl.Segmenter thì ít nhất không cắt giữa cặp surrogate.
  • checkLatex trả cả lý do và độ sâu, để giao diện báo đúng "quá dài" hay "lồng quá sâu".

Khác bản gốc:

  • Không có bảng TLD và luật host chi tiết; findUrls chấp nhận mọi host sau https://.
  • Không lọc email. an@www.example.com vẫn ra link www.example.com; cần thì thêm bước kiểm tra ký tự trước start.
  • safeCutoff có thêm bước ranh giới grapheme.
  • maxBraceDepth kẹp bộ đếm ở 0.
  • endsWithHashTrigger dùng đúng ký tự '#'.

#Tại sao không làm cách khác?

  • Một regex lớn cho cả URL (kiểu regex của Gruber): đọc khó, sửa khó, và nhiều bản có nhóm lặp lồng nhau dễ thành ReDoS. Tách "điểm bắt đầu" (regex đơn giản) với "điểm kết thúc" (vòng lặp) dễ kiểm thử hơn.
  • new URL() để kiểm tra: chỉ cho biết một chuỗi có phải URL hợp lệ không, không cho biết URL kết thúc ở đâu trong câu văn. https://example.com/a). vẫn là URL hợp lệ.
  • Cắt chữ bằng CSS line-clamp: tốt cho hiển thị, nhưng "Xem thêm" của Facebook là cắt theo số ký tự ở phía dữ liệu (để đo, để log, để render phía server). Khi chỉ cần hiển thị, line-clamp đơn giản hơn và không bao giờ cắt giữa grapheme.
  • Đặt timeout cho trình render LaTeX: JavaScript chạy một luồng, không ngắt được một hàm đệ quy đang chạy. Kiểm tra trước khi render là cách duy nhất (hoặc render trong Worker).

#Edge case và cạm bẫy

  • URL có ngoặc lệch thật: https://example.com/a(b (thiếu )). Bộ đếm không bao giờ về 0, nên phần từ ( trở đi bị bỏ. Đây là đánh đổi chấp nhận được: hiếm, và link vẫn trỏ về đúng trang cha.
  • Dấu câu toàn khổ 。, 、 của tiếng Nhật, Trung: thêm vào TRAILING nếu người dùng của bạn hay viết các thứ tiếng này.
  • URL tiếng Việt chưa mã hoá (/wiki/Sao_Thủy): START nhận mọi ký tự không phải khoảng trắng nên vẫn khớp. href để nguyên, trình duyệt tự percent-encode khi điều hướng.
  • Link javascript:: START chỉ nhận http(s) và www., nên không bao giờ sinh href="javascript:…". Đừng nới regex này.
  • Độ sâu không phải là thước đo duy nhất: \underbrace hay \sqrt lồng nhau vẫn tốn nhiều thời gian render dù độ sâu thấp. Messenger còn giới hạn độ dài và maxSize.

#Dùng trong dự án

import { linkify, safeCutoff } from './autolink';

function PostBody({ text, expanded }: { text: string; expanded: boolean }) {
  const shown = expanded ? text : text.slice(0, safeCutoff(text, 500));
  return (
    <p>
      {linkify(shown).map((s, i) =>
        s.kind === 'link' ? <a key={i} href={s.href} rel='noopener noreferrer'>{s.text}</a> : s.text,
      )}
    </p>
  );
}
tsx

Trước khi đưa công thức cho KaTeX hay MathJax, gọi checkLatex(tex); ok là false thì hiện thông báo "công thức quá phức tạp" kèm reason thay vì render.

#Kiểm thử

Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it } from 'vitest';
import { checkLatex, endsWithHashTrigger, endsWithHashTriggerFbStyle, findUrls, maxBraceDepth, safeCutoff } from './autolink';

describe('findUrls', () => {
  it('giữ ngoặc đúng cặp của Wikipedia', () => {
    expect(findUrls('xem https://en.wikipedia.org/wiki/Bash_(Unix_shell) nhé')[0].url).toBe(
      'https://en.wikipedia.org/wiki/Bash_(Unix_shell)',
    );
  });
  it('bỏ ) của câu văn và dấu câu cuối', () => {
    expect(findUrls('(link: https://example.com/a).')[0].url).toBe('https://example.com/a');
    expect(findUrls('Vào www.example.com, rồi https://x.dev/p?q=1!').map((m) => m.url)).toEqual([
      'www.example.com',
      'https://x.dev/p?q=1',
    ]);
  });
  it('dấu chấm giữa path vẫn giữ', () => {
    expect(findUrls('https://a.com/file.tar.gz.')[0].url).toBe('https://a.com/file.tar.gz');
  });
});

describe('safeCutoff', () => {
  it('không chặt đôi URL', () => {
    const text = 'Đọc thêm ở https://example.com/rat-dai/bai-viet';
    expect(safeCutoff(text, 20)).toBe(text.indexOf('https'));
  });
  it('không cắt giữa emoji gia đình', () => {
    expect(safeCutoff('abc👨‍👩‍👧xyz', 5)).toBe(3);
  });
});

describe('LaTeX', () => {
  it('đếm ngoặc và \\begin như bản gốc', () => {
    expect(maxBraceDepth('\\frac{a}{b}')).toBe(1);
    expect(maxBraceDepth('\\begin{x}{y}\\end{x}')).toBe(3);
    expect(maxBraceDepth('\\{\\}')).toBe(0);
  });
  it('} thừa không làm giảm độ sâu đo được', () => {
    expect(maxBraceDepth('}}}}' + '{'.repeat(25))).toBe(25);
    expect(checkLatex('{'.repeat(21) + '}'.repeat(21)).ok).toBe(false);
  });
});

it('nhận # toàn khổ, bản gốc thì không', () => {
  expect(endsWithHashTrigger('tag #')).toBe(true);
  expect(endsWithHashTriggerFbStyle('tag #')).toBe(false);
});
ts

#So với thư viện và chuẩn web

  • linkify-it (dùng trong markdown-it) và linkifyjs làm cùng việc với URLMatcher, kèm danh sách TLD đầy đủ, email, và nhiều luật hơn. Với sản phẩm thật nên dùng chúng; bản 20 dòng ở đây để hiểu cơ chế ngoặc cân bằng. linkify-it cũng xử lý ngoặc theo kiểu đếm cặp.
  • Intl.Segmenter là cách chuẩn để cắt theo grapheme. Thư viện grapheme-splitter từng lấp chỗ trống này trước khi Firefox hỗ trợ Intl.Segmenter (Firefox 125, 2024).
  • CSS line-clamp (-webkit-line-clamp, đã chuẩn hoá thành line-clamp không tiền tố ở một số trình duyệt tính tới 10/2026) cắt theo dòng hiển thị, không bao giờ cắt giữa grapheme, nhưng không cho biết đã cắt ở ký tự nào.
  • KaTeX có option maxExpand (giới hạn số lần mở macro) và maxSize, nhưng không có giới hạn độ lồng. Kiểm tra độ sâu trước như Messenger là lớp bảo vệ bổ sung đáng có.

Bài này thuộc series Học từ mã nguồn Facebook (106 bài, đọc theo thứ tự).

Xem nhanh danh sách bài trong series

Phần 1

Phần 2

Phần 3

Phần 4–6

Phần 7–10

Phần 11–15

Code + demo: examples/facebook-patterns · Mở trên StackBlitz