Skip to content

Commit 76db5e2

Browse files
feat: parse color:#rgb syntax
1 parent 225d0bf commit 76db5e2

2 files changed

Lines changed: 75 additions & 1 deletion

File tree

packages/message-parser/src/chars.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ export function isPlainChar(ch: string): boolean {
3030
return ch !== '' && !isNewline(ch) && !isMarkupChar(ch);
3131
}
3232

33+
export function isHexDigit(ch: string): boolean {
34+
return (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F');
35+
}
36+
3337
// ─── Emoticon ──────────────────────────────────────────────────────────────
3438
export const EMOTICONS: Record<string, string> = {
3539
':)': 'slight_smile',

packages/message-parser/src/parser.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,10 @@ import {
5454
emoji,
5555
emoticon,
5656
emojiUnicode,
57+
color,
5758
} from './utils';
5859
import { Scanner } from './scanner';
59-
import { isNewline, isPlainChar, isSpace, isAlpha, isAlphaNum, isDigit, EMOTICON_KEYS, EMOTICONS } from './chars';
60+
import { isNewline, isPlainChar, isSpace, isAlpha, isAlphaNum, isDigit, EMOTICON_KEYS, EMOTICONS, isHexDigit } from './chars';
6061

6162
// ─── Constants ────────────────────────────────────────────────────────────────
6263

@@ -93,6 +94,17 @@ export function matchEmoticon(scanner: Scanner): Inlines | null {
9394
return null;
9495
}
9596

97+
function isAnyText(ch: string): boolean {
98+
if (ch === '') return false;
99+
return (
100+
(ch >= ' ' && ch <= "'") || // space ! " # $ % & '
101+
(ch >= '+' && ch <= '@') || // + , - . / 0-9 : ; < = > ? @
102+
(ch >= 'A' && ch <= 'Z') || // A-Z
103+
(ch >= 'a' && ch <= 'z') || // a-z
104+
ch.charCodeAt(0) > 127 // any non-ASCII character
105+
);
106+
}
107+
96108
// ─── Re-entrancy guards ───────────────────────────────────────────────────
97109

98110
let skipBold = false;
@@ -346,6 +358,16 @@ function parseInline(scanner: Scanner, options: Options): Inlines[] {
346358
}
347359
}
348360

361+
// Color (color:#rgb / rgba / rrggbb / rrggbbaa) — only when enabled
362+
if (scanner.matches('color:#')) {
363+
const result = tryColor(scanner, options);
364+
if (result !== null) {
365+
nodes.push(result);
366+
prevChar = '';
367+
continue;
368+
}
369+
}
370+
349371
// Email (local@domain)
350372
if (isAlpha(ch) || isDigit(ch) || ch.charCodeAt(0) > 127) {
351373
const result = tryEmail(scanner);
@@ -576,6 +598,16 @@ function parseInlineContent(scanner: Scanner, options: Options, stopChar: string
576598
}
577599
}
578600

601+
// Color (color:#rgb / rgba / rrggbb / rrggbbaa)
602+
if (scanner.matches('color:#')) {
603+
const result = tryColor(scanner, options);
604+
if (result !== null) {
605+
nodes.push(result);
606+
prevChar = '';
607+
continue;
608+
}
609+
}
610+
579611
// Email (local@domain)
580612
if (isAlpha(ch) || isDigit(ch) || ch.charCodeAt(0) > 127) {
581613
const result = tryEmail(scanner);
@@ -1521,6 +1553,44 @@ function tryUnicodeEmoji(scanner: Scanner): Inlines | null {
15211553
return emojiUnicode(m[0]);
15221554
}
15231555

1556+
function tryColor(scanner: Scanner, options: Options): Inlines | null {
1557+
if (!options.colors) return null;
1558+
const delimiter = 'color:#';
1559+
1560+
if (!scanner.matches(delimiter)) return null;
1561+
1562+
const startPos = scanner.position();
1563+
scanner.consume(delimiter.length); // consume "color:#"
1564+
1565+
const hexStart = scanner.position();
1566+
while (!scanner.isEnd() && isHexDigit(scanner.char())) {
1567+
scanner.consume();
1568+
}
1569+
const hex = scanner.sliceFrom(hexStart);
1570+
1571+
let rgba: [number, number, number, number] | null = null;
1572+
1573+
if (hex.length === 6 || hex.length === 8) {
1574+
// byte pairs: c7 -> 0xc7
1575+
const b: number[] = [];
1576+
for (let i = 0; i < hex.length; i += 2) b.push(parseInt(hex.slice(i, i + 2), 16));
1577+
rgba = [b[0], b[1], b[2], b[3] ?? 255];
1578+
} else if (hex.length === 3 || hex.length === 4) {
1579+
// single nibbles doubled: c -> cc -> 0xcc
1580+
const n: number[] = [];
1581+
for (let i = 0; i < hex.length; i++) n.push(parseInt(hex[i] + hex[i], 16));
1582+
rgba = [n[0], n[1], n[2], n[3] ?? 255];
1583+
}
1584+
1585+
// Invalid digit count, or color is immediately followed by text -> not a color.
1586+
if (rgba === null || isAnyText(scanner.char())) {
1587+
scanner.backtrack(startPos);
1588+
return null;
1589+
}
1590+
1591+
return color(rgba[0], rgba[1], rgba[2], rgba[3]);
1592+
}
1593+
15241594
// ─── Block methods ──────────────────────────────────────────────────────────────
15251595

15261596
function tryCodeFence(scanner: Scanner): Code | null {

0 commit comments

Comments
 (0)