* feat(zalouser): add markdown-to-Zalo text style parsing Parse markdown formatting (bold, italic, strikethrough, headings, lists, code blocks, blockquotes, custom color/style tags) into Zalo native TextStyle ranges so outbound messages render with rich formatting. - Add text-styles.ts with parseZalouserTextStyles() converter - Wire markdown mode into send pipeline (sendMessageZalouser) - Export TextStyle enum and Style type from zca-client - Add textMode/textStyles to ZaloSendOptions - Pass textStyles through sendZaloTextMessage to zca-js API - Enable textMode:"markdown" in outbound sendText/sendMedia and monitor - Add comprehensive tests for parsing, send, and channel integration * fix(zalouser): harden markdown text parsing * fix(zalouser): mirror zca-js text style types * fix(zalouser): support tilde fenced code blocks * fix(zalouser): handle quoted fenced code blocks * fix(zalouser): preserve literal quote lines in code fences * fix(zalouser): support indented quoted fences * fix(zalouser): preserve quoted markdown blocks * fix(zalouser): rechunk formatted messages * fix(zalouser): preserve markdown structure across chunks * fix(zalouser): honor chunk limits and CRLF fences
204 lines
6.4 KiB
TypeScript
204 lines
6.4 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { parseZalouserTextStyles } from "./text-styles.js";
|
|
import { TextStyle } from "./zca-client.js";
|
|
|
|
describe("parseZalouserTextStyles", () => {
|
|
it("renders inline markdown emphasis as Zalo style ranges", () => {
|
|
expect(parseZalouserTextStyles("**bold** *italic* ~~strike~~")).toEqual({
|
|
text: "bold italic strike",
|
|
styles: [
|
|
{ start: 0, len: 4, st: TextStyle.Bold },
|
|
{ start: 5, len: 6, st: TextStyle.Italic },
|
|
{ start: 12, len: 6, st: TextStyle.StrikeThrough },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("keeps inline code and plain math markers literal", () => {
|
|
expect(parseZalouserTextStyles("before `inline *code*` after\n2 * 3 * 4")).toEqual({
|
|
text: "before `inline *code*` after\n2 * 3 * 4",
|
|
styles: [],
|
|
});
|
|
});
|
|
|
|
it("preserves backslash escapes inside code spans and fenced code blocks", () => {
|
|
expect(parseZalouserTextStyles("before `\\*` after\n```ts\n\\*\\_\\\\\n```")).toEqual({
|
|
text: "before `\\*` after\n\\*\\_\\\\",
|
|
styles: [],
|
|
});
|
|
});
|
|
|
|
it("closes fenced code blocks when the input uses CRLF newlines", () => {
|
|
expect(parseZalouserTextStyles("```\r\n*code*\r\n```\r\n**after**")).toEqual({
|
|
text: "*code*\nafter",
|
|
styles: [{ start: 7, len: 5, st: TextStyle.Bold }],
|
|
});
|
|
});
|
|
|
|
it("maps headings, block quotes, and lists into line styles", () => {
|
|
expect(parseZalouserTextStyles(["# Title", "> quoted", " - nested"].join("\n"))).toEqual({
|
|
text: "Title\nquoted\nnested",
|
|
styles: [
|
|
{ start: 0, len: 5, st: TextStyle.Bold },
|
|
{ start: 0, len: 5, st: TextStyle.Big },
|
|
{ start: 6, len: 6, st: TextStyle.Indent, indentSize: 1 },
|
|
{ start: 13, len: 6, st: TextStyle.UnorderedList },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("treats 1-3 leading spaces as markdown padding for headings and lists", () => {
|
|
expect(parseZalouserTextStyles(" # Title\n 1. item\n - bullet")).toEqual({
|
|
text: "Title\nitem\nbullet",
|
|
styles: [
|
|
{ start: 0, len: 5, st: TextStyle.Bold },
|
|
{ start: 0, len: 5, st: TextStyle.Big },
|
|
{ start: 6, len: 4, st: TextStyle.OrderedList },
|
|
{ start: 11, len: 6, st: TextStyle.UnorderedList },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("strips fenced code markers and preserves leading indentation with nbsp", () => {
|
|
expect(parseZalouserTextStyles("```ts\n const x = 1\n\treturn x\n```")).toEqual({
|
|
text: "\u00A0\u00A0const x = 1\n\u00A0\u00A0\u00A0\u00A0return x",
|
|
styles: [],
|
|
});
|
|
});
|
|
|
|
it("treats tilde fences as literal code blocks", () => {
|
|
expect(parseZalouserTextStyles("~~~bash\n*cmd*\n~~~")).toEqual({
|
|
text: "*cmd*",
|
|
styles: [],
|
|
});
|
|
});
|
|
|
|
it("treats fences indented under list items as literal code blocks", () => {
|
|
expect(parseZalouserTextStyles(" ```\n*cmd*\n ```")).toEqual({
|
|
text: "*cmd*",
|
|
styles: [],
|
|
});
|
|
});
|
|
|
|
it("treats quoted backtick fences as literal code blocks", () => {
|
|
expect(parseZalouserTextStyles("> ```js\n> *cmd*\n> ```")).toEqual({
|
|
text: "*cmd*",
|
|
styles: [],
|
|
});
|
|
});
|
|
|
|
it("treats quoted tilde fences as literal code blocks", () => {
|
|
expect(parseZalouserTextStyles("> ~~~\n> *cmd*\n> ~~~")).toEqual({
|
|
text: "*cmd*",
|
|
styles: [],
|
|
});
|
|
});
|
|
|
|
it("preserves quote-prefixed lines inside normal fenced code blocks", () => {
|
|
expect(parseZalouserTextStyles("```\n> prompt\n```")).toEqual({
|
|
text: "> prompt",
|
|
styles: [],
|
|
});
|
|
});
|
|
|
|
it("does not treat quote-prefixed fence text inside code as a closing fence", () => {
|
|
expect(parseZalouserTextStyles("```\n> ```\n*still code*\n```")).toEqual({
|
|
text: "> ```\n*still code*",
|
|
styles: [],
|
|
});
|
|
});
|
|
|
|
it("treats indented blockquotes as quoted lines", () => {
|
|
expect(parseZalouserTextStyles(" > quoted")).toEqual({
|
|
text: "quoted",
|
|
styles: [{ start: 0, len: 6, st: TextStyle.Indent, indentSize: 1 }],
|
|
});
|
|
});
|
|
|
|
it("treats spaced nested blockquotes as deeper quoted lines", () => {
|
|
expect(parseZalouserTextStyles("> > quoted")).toEqual({
|
|
text: "quoted",
|
|
styles: [{ start: 0, len: 6, st: TextStyle.Indent, indentSize: 2 }],
|
|
});
|
|
});
|
|
|
|
it("treats indented quoted fences as literal code blocks", () => {
|
|
expect(parseZalouserTextStyles(" > ```\n > *cmd*\n > ```")).toEqual({
|
|
text: "*cmd*",
|
|
styles: [],
|
|
});
|
|
});
|
|
|
|
it("treats spaced nested quoted fences as literal code blocks", () => {
|
|
expect(parseZalouserTextStyles("> > ```\n> > code\n> > ```")).toEqual({
|
|
text: "code",
|
|
styles: [],
|
|
});
|
|
});
|
|
|
|
it("preserves inner quote markers inside quoted fenced code blocks", () => {
|
|
expect(parseZalouserTextStyles("> ```\n>> prompt\n> ```")).toEqual({
|
|
text: "> prompt",
|
|
styles: [],
|
|
});
|
|
});
|
|
|
|
it("keeps quote indentation on heading lines", () => {
|
|
expect(parseZalouserTextStyles("> # Title")).toEqual({
|
|
text: "Title",
|
|
styles: [
|
|
{ start: 0, len: 5, st: TextStyle.Bold },
|
|
{ start: 0, len: 5, st: TextStyle.Big },
|
|
{ start: 0, len: 5, st: TextStyle.Indent, indentSize: 1 },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("keeps unmatched fences literal", () => {
|
|
expect(parseZalouserTextStyles("```python")).toEqual({
|
|
text: "```python",
|
|
styles: [],
|
|
});
|
|
});
|
|
|
|
it("keeps unclosed fenced blocks literal until eof", () => {
|
|
expect(parseZalouserTextStyles("```python\n\\*not italic*\n_next_")).toEqual({
|
|
text: "```python\n\\*not italic*\n_next_",
|
|
styles: [],
|
|
});
|
|
});
|
|
|
|
it("supports nested markdown and tag styles regardless of order", () => {
|
|
expect(parseZalouserTextStyles("**{red}x{/red}** {red}**y**{/red}")).toEqual({
|
|
text: "x y",
|
|
styles: [
|
|
{ start: 0, len: 1, st: TextStyle.Bold },
|
|
{ start: 0, len: 1, st: TextStyle.Red },
|
|
{ start: 2, len: 1, st: TextStyle.Red },
|
|
{ start: 2, len: 1, st: TextStyle.Bold },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("treats small text tags as normal text", () => {
|
|
expect(parseZalouserTextStyles("{small}tiny{/small}")).toEqual({
|
|
text: "tiny",
|
|
styles: [],
|
|
});
|
|
});
|
|
|
|
it("keeps escaped markers literal", () => {
|
|
expect(parseZalouserTextStyles("\\*literal\\* \\{underline}tag{/underline}")).toEqual({
|
|
text: "*literal* {underline}tag{/underline}",
|
|
styles: [],
|
|
});
|
|
});
|
|
|
|
it("keeps indented code blocks literal", () => {
|
|
expect(parseZalouserTextStyles(" *cmd*")).toEqual({
|
|
text: "\u00A0\u00A0\u00A0\u00A0*cmd*",
|
|
styles: [],
|
|
});
|
|
});
|
|
});
|