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 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
@@ -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;AAExB,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,uBAAuB,6BAGnF"}
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"}
@@ -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.0.0",
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": "udi",
33
+ "author": "allx",
34
34
  "license": "MIT",
35
35
  "repository": {
36
36
  "type": "git",