markdown-to-slack-blocks 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/parser.js +0 -7
- package/dist/splitter.d.ts +17 -0
- package/dist/splitter.d.ts.map +1 -0
- package/dist/splitter.js +200 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -13,6 +13,14 @@ While Slack does offer native [markdown support in blocks](https://api.slack.com
|
|
|
13
13
|
|
|
14
14
|
This library is particularly useful for apps that leverage **platform AI features** where you expect a **markdown response from an LLM**. Instead of sending raw markdown that Slack can't fully render, this library converts it to proper Block Kit JSON that displays correctly.
|
|
15
15
|
|
|
16
|
+
### Additional Features
|
|
17
|
+
|
|
18
|
+
Beyond basic markdown conversion, this library provides:
|
|
19
|
+
|
|
20
|
+
- **Mention Support**: User, channel, user group, and team mentions (`@username`, `#channel`) are automatically detected and converted to Slack's native mention format when ID mappings are provided. Without mappings, mentions are rendered as plain text.
|
|
21
|
+
- **Native Slack Dates**: Support for Slack's date formatting syntax, allowing dynamic date rendering that respects user timezones.
|
|
22
|
+
- **Color Detection**: Optional color detection that converts color values (hex, rgb, named colors) into Slack's native color elements for rich visual formatting.
|
|
23
|
+
|
|
16
24
|
## How It Works
|
|
17
25
|
|
|
18
26
|
This library uses a two-step conversion process:
|
|
@@ -129,3 +137,25 @@ The library validates that the IDs provided in the `mentions` option adhere to S
|
|
|
129
137
|
- **Team IDs**: Must start with `T`.
|
|
130
138
|
|
|
131
139
|
All IDs must be alphanumeric.
|
|
140
|
+
|
|
141
|
+
### Handling Large Messages
|
|
142
|
+
|
|
143
|
+
Slack limits messages to **~45 blocks** and **~12KB** of JSON. Use `splitBlocks` to split large outputs:
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
import { markdownToBlocks, splitBlocks } from 'markdown-to-slack-blocks';
|
|
147
|
+
|
|
148
|
+
const blocks = markdownToBlocks(veryLongMarkdown);
|
|
149
|
+
const batches = splitBlocks(blocks);
|
|
150
|
+
|
|
151
|
+
for (const batch of batches) {
|
|
152
|
+
await slack.postMessage({ channel, blocks: batch });
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Options:**
|
|
157
|
+
```typescript
|
|
158
|
+
splitBlocks(blocks, { maxBlocks: 40, maxCharacters: 12000 });
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The function splits at natural boundaries: between blocks first, then within `rich_text` elements, and finally within large code blocks by line.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MarkdownToBlocksOptions } from './types';
|
|
2
2
|
export * from './types';
|
|
3
|
+
export { splitBlocks, SplitBlocksOptions } from './splitter';
|
|
3
4
|
export declare function markdownToBlocks(markdown: string, options?: MarkdownToBlocksOptions): import("./types").Block[];
|
|
4
5
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,uBAAuB,EAAE,MAAM,SAAS,CAAC;AAIlD,cAAc,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,uBAAuB,EAAE,MAAM,SAAS,CAAC;AAIlD,cAAc,SAAS,CAAC;AACxB,OAAO,EAAE,WAAW,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAE7D,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,uBAAuB,6BAGnF"}
|
package/dist/index.js
CHANGED
|
@@ -14,11 +14,14 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.splitBlocks = void 0;
|
|
17
18
|
exports.markdownToBlocks = markdownToBlocks;
|
|
18
19
|
const parser_1 = require("./parser");
|
|
19
20
|
const validator_1 = require("./validator");
|
|
20
21
|
// Re-export types for consumers
|
|
21
22
|
__exportStar(require("./types"), exports);
|
|
23
|
+
var splitter_1 = require("./splitter");
|
|
24
|
+
Object.defineProperty(exports, "splitBlocks", { enumerable: true, get: function () { return splitter_1.splitBlocks; } });
|
|
22
25
|
function markdownToBlocks(markdown, options) {
|
|
23
26
|
(0, validator_1.validateOptions)(options);
|
|
24
27
|
return (0, parser_1.parseMarkdown)(markdown, options);
|
package/dist/parser.js
CHANGED
|
@@ -176,7 +176,6 @@ function parseMarkdown(markdown, options = {}) {
|
|
|
176
176
|
return blocks;
|
|
177
177
|
}
|
|
178
178
|
function mapInlineNode(node, options) {
|
|
179
|
-
// console.log('Node:', node.type, node.value || node);
|
|
180
179
|
if (node.type === 'text' || node.type === 'html') {
|
|
181
180
|
// Pass empty object for style, processTextNode will handle it (and not attach if empty)
|
|
182
181
|
return processTextNode(node.value, {}, options);
|
|
@@ -219,10 +218,6 @@ function flattenStyles(children, style, options) {
|
|
|
219
218
|
return { ...el, style: mergedStyle };
|
|
220
219
|
}
|
|
221
220
|
// If it was empty before and we're not adding anything (shouldn't happen here if style has keys), just return
|
|
222
|
-
// But if el.style was undefined and style is {}, we want undefined.
|
|
223
|
-
// Logic: if mergedStyle has keys, use it. Else, if el had style, keep it? No, we want to flatten.
|
|
224
|
-
// Actually, if we merge {bold: true} with {}, we get {bold: true}.
|
|
225
|
-
// If we merge {} with {}, we get {}. We want to avoid {}.
|
|
226
221
|
const { style: _, ...rest } = el;
|
|
227
222
|
return rest;
|
|
228
223
|
});
|
|
@@ -238,8 +233,6 @@ function processTextNode(text, style, options) {
|
|
|
238
233
|
// 7. Emoji: :shortcode:
|
|
239
234
|
// 8. Mapped Mention: @name
|
|
240
235
|
// 9. Mapped Channel: #name
|
|
241
|
-
// Note: JS Regex stateful global matching
|
|
242
|
-
// We need to capture everything carefully.
|
|
243
236
|
// Groups:
|
|
244
237
|
// 1. Broadcast: (<!here>|<!channel>|<!everyone>)
|
|
245
238
|
// 2. Mention: (<@([\w.-]+)>)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Block } from './types';
|
|
2
|
+
export interface SplitBlocksOptions {
|
|
3
|
+
/** Maximum number of blocks per message. Default: 40 */
|
|
4
|
+
maxBlocks?: number;
|
|
5
|
+
/** Maximum JSON character count per message. Default: 12000 */
|
|
6
|
+
maxCharacters?: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Splits an array of blocks into multiple arrays that fit within Slack's limits.
|
|
10
|
+
* Attempts to split at natural boundaries (between top-level blocks, rich_text elements, etc.)
|
|
11
|
+
*
|
|
12
|
+
* @param blocks - Array of blocks from markdownToBlocks
|
|
13
|
+
* @param options - Optional configuration for limits
|
|
14
|
+
* @returns Array of block arrays, each fitting within the limits
|
|
15
|
+
*/
|
|
16
|
+
export declare function splitBlocks(blocks: Block[], options?: SplitBlocksOptions): Block[][];
|
|
17
|
+
//# sourceMappingURL=splitter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"splitter.d.ts","sourceRoot":"","sources":["../src/splitter.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,KAAK,EAIR,MAAM,SAAS,CAAC;AAEjB,MAAM,WAAW,kBAAkB;IAC/B,wDAAwD;IACxD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,+DAA+D;IAC/D,aAAa,CAAC,EAAE,MAAM,CAAC;CAC1B;AAKD;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE,OAAO,CAAC,EAAE,kBAAkB,GAAG,KAAK,EAAE,EAAE,CAkEpF"}
|
package/dist/splitter.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.splitBlocks = splitBlocks;
|
|
4
|
+
const DEFAULT_MAX_BLOCKS = 40;
|
|
5
|
+
const DEFAULT_MAX_CHARACTERS = 12000;
|
|
6
|
+
/**
|
|
7
|
+
* Splits an array of blocks into multiple arrays that fit within Slack's limits.
|
|
8
|
+
* Attempts to split at natural boundaries (between top-level blocks, rich_text elements, etc.)
|
|
9
|
+
*
|
|
10
|
+
* @param blocks - Array of blocks from markdownToBlocks
|
|
11
|
+
* @param options - Optional configuration for limits
|
|
12
|
+
* @returns Array of block arrays, each fitting within the limits
|
|
13
|
+
*/
|
|
14
|
+
function splitBlocks(blocks, options) {
|
|
15
|
+
const maxBlocks = options?.maxBlocks ?? DEFAULT_MAX_BLOCKS;
|
|
16
|
+
const maxChars = options?.maxCharacters ?? DEFAULT_MAX_CHARACTERS;
|
|
17
|
+
if (blocks.length === 0) {
|
|
18
|
+
return [[]];
|
|
19
|
+
}
|
|
20
|
+
// Check if everything fits in one message
|
|
21
|
+
if (blocks.length <= maxBlocks && JSON.stringify(blocks).length <= maxChars) {
|
|
22
|
+
return [blocks];
|
|
23
|
+
}
|
|
24
|
+
const result = [];
|
|
25
|
+
let currentBatch = [];
|
|
26
|
+
const fitsInBatch = (batch, newBlock) => {
|
|
27
|
+
if (batch.length + 1 > maxBlocks)
|
|
28
|
+
return false;
|
|
29
|
+
const newSize = JSON.stringify([...batch, newBlock]).length;
|
|
30
|
+
return newSize <= maxChars;
|
|
31
|
+
};
|
|
32
|
+
const flushBatch = () => {
|
|
33
|
+
if (currentBatch.length > 0) {
|
|
34
|
+
result.push(currentBatch);
|
|
35
|
+
currentBatch = [];
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
for (const block of blocks) {
|
|
39
|
+
// Try to add block to current batch
|
|
40
|
+
if (fitsInBatch(currentBatch, block)) {
|
|
41
|
+
currentBatch.push(block);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
// Block doesn't fit - flush current batch first
|
|
45
|
+
flushBatch();
|
|
46
|
+
// Check if this single block fits on its own
|
|
47
|
+
if (fitsInBatch([], block)) {
|
|
48
|
+
currentBatch.push(block);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
// Single block is too large - try to split it
|
|
52
|
+
if (block.type === 'rich_text') {
|
|
53
|
+
const splitRichText = splitLargeRichTextBlock(block, maxBlocks, maxChars);
|
|
54
|
+
for (const subBlock of splitRichText) {
|
|
55
|
+
if (fitsInBatch(currentBatch, subBlock)) {
|
|
56
|
+
currentBatch.push(subBlock);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
flushBatch();
|
|
60
|
+
currentBatch.push(subBlock);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
// For non-rich_text blocks that are too large, we have to include them as-is
|
|
66
|
+
// (tables, images, etc. can't really be split semantically)
|
|
67
|
+
currentBatch.push(block);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
flushBatch();
|
|
71
|
+
return result.length > 0 ? result : [[]];
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Splits a large RichTextBlock into smaller RichTextBlocks by splitting its elements
|
|
75
|
+
*/
|
|
76
|
+
function splitLargeRichTextBlock(block, maxBlocks, maxChars) {
|
|
77
|
+
const elements = block.elements;
|
|
78
|
+
if (elements.length === 0) {
|
|
79
|
+
return [block];
|
|
80
|
+
}
|
|
81
|
+
// First, try splitting by elements
|
|
82
|
+
const elementBlocks = splitRichTextByElements(elements, maxChars);
|
|
83
|
+
// If any single element is still too large, try to split it further
|
|
84
|
+
const result = [];
|
|
85
|
+
for (const elementBlock of elementBlocks) {
|
|
86
|
+
const blockJson = JSON.stringify(elementBlock);
|
|
87
|
+
if (blockJson.length <= maxChars) {
|
|
88
|
+
result.push(elementBlock);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
// Try to split individual elements (e.g., large code blocks)
|
|
92
|
+
const furtherSplit = splitRichTextBlockElements(elementBlock, maxChars);
|
|
93
|
+
result.push(...furtherSplit);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Splits rich_text elements into separate RichTextBlocks
|
|
100
|
+
*/
|
|
101
|
+
function splitRichTextByElements(elements, maxChars) {
|
|
102
|
+
const result = [];
|
|
103
|
+
let currentElements = [];
|
|
104
|
+
const createBlock = (elems) => ({
|
|
105
|
+
type: 'rich_text',
|
|
106
|
+
elements: elems,
|
|
107
|
+
});
|
|
108
|
+
for (const element of elements) {
|
|
109
|
+
const testBlock = createBlock([...currentElements, element]);
|
|
110
|
+
const testJson = JSON.stringify(testBlock);
|
|
111
|
+
if (testJson.length <= maxChars) {
|
|
112
|
+
currentElements.push(element);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
// Flush current elements
|
|
116
|
+
if (currentElements.length > 0) {
|
|
117
|
+
result.push(createBlock(currentElements));
|
|
118
|
+
currentElements = [];
|
|
119
|
+
}
|
|
120
|
+
// Add this element to a new batch
|
|
121
|
+
currentElements.push(element);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (currentElements.length > 0) {
|
|
125
|
+
result.push(createBlock(currentElements));
|
|
126
|
+
}
|
|
127
|
+
return result.length > 0 ? result : [createBlock([])];
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Attempts to split individual elements within a RichTextBlock (e.g., large code blocks)
|
|
131
|
+
*/
|
|
132
|
+
function splitRichTextBlockElements(block, maxChars) {
|
|
133
|
+
const result = [];
|
|
134
|
+
for (const element of block.elements) {
|
|
135
|
+
if (element.type === 'rich_text_preformatted') {
|
|
136
|
+
// Split large code blocks by lines
|
|
137
|
+
const splitPreformatted = splitPreformattedElement(element, maxChars);
|
|
138
|
+
for (const splitElem of splitPreformatted) {
|
|
139
|
+
result.push({
|
|
140
|
+
type: 'rich_text',
|
|
141
|
+
elements: [splitElem],
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
// For other element types, just wrap them as-is
|
|
147
|
+
result.push({
|
|
148
|
+
type: 'rich_text',
|
|
149
|
+
elements: [element],
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return result.length > 0 ? result : [block];
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Splits a large preformatted (code) element by lines
|
|
157
|
+
*/
|
|
158
|
+
function splitPreformattedElement(element, maxChars) {
|
|
159
|
+
// Get the text content
|
|
160
|
+
const textElements = element.elements.filter(e => e.type === 'text');
|
|
161
|
+
if (textElements.length === 0) {
|
|
162
|
+
return [element];
|
|
163
|
+
}
|
|
164
|
+
const fullText = textElements.map(e => e.type === 'text' ? e.text : '').join('');
|
|
165
|
+
const lines = fullText.split('\n');
|
|
166
|
+
if (lines.length <= 1) {
|
|
167
|
+
// Can't split further
|
|
168
|
+
return [element];
|
|
169
|
+
}
|
|
170
|
+
const result = [];
|
|
171
|
+
let currentLines = [];
|
|
172
|
+
const createPreformatted = (text) => ({
|
|
173
|
+
type: 'rich_text_preformatted',
|
|
174
|
+
elements: [{ type: 'text', text }],
|
|
175
|
+
...(element.border !== undefined ? { border: element.border } : {}),
|
|
176
|
+
});
|
|
177
|
+
const estimateSize = (text) => {
|
|
178
|
+
return JSON.stringify(createPreformatted(text)).length;
|
|
179
|
+
};
|
|
180
|
+
for (const line of lines) {
|
|
181
|
+
const testText = [...currentLines, line].join('\n');
|
|
182
|
+
if (estimateSize(testText) <= maxChars) {
|
|
183
|
+
currentLines.push(line);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
// Flush current lines
|
|
187
|
+
if (currentLines.length > 0) {
|
|
188
|
+
result.push(createPreformatted(currentLines.join('\n')));
|
|
189
|
+
currentLines = [];
|
|
190
|
+
}
|
|
191
|
+
// Start new batch with this line
|
|
192
|
+
currentLines.push(line);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Flush remaining
|
|
196
|
+
if (currentLines.length > 0) {
|
|
197
|
+
result.push(createPreformatted(currentLines.join('\n')));
|
|
198
|
+
}
|
|
199
|
+
return result.length > 0 ? result : [element];
|
|
200
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "markdown-to-slack-blocks",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Convert Markdown to Slack Block Kit JSON format",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"rich-text",
|
|
31
31
|
"parser"
|
|
32
32
|
],
|
|
33
|
-
"author": "
|
|
33
|
+
"author": "allx",
|
|
34
34
|
"license": "MIT",
|
|
35
35
|
"repository": {
|
|
36
36
|
"type": "git",
|