@stoneforge/quarry 1.13.0 → 1.14.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.
Files changed (124) hide show
  1. package/dist/api/quarry-api.d.ts +9 -1
  2. package/dist/api/quarry-api.d.ts.map +1 -1
  3. package/dist/api/quarry-api.js +21 -2
  4. package/dist/api/quarry-api.js.map +1 -1
  5. package/dist/api/types.d.ts +8 -1
  6. package/dist/api/types.d.ts.map +1 -1
  7. package/dist/api/types.js.map +1 -1
  8. package/dist/cli/commands/auto-link-helper.d.ts.map +1 -1
  9. package/dist/cli/commands/auto-link-helper.js +1 -0
  10. package/dist/cli/commands/auto-link-helper.js.map +1 -1
  11. package/dist/cli/commands/crud.d.ts +2 -0
  12. package/dist/cli/commands/crud.d.ts.map +1 -1
  13. package/dist/cli/commands/crud.js +100 -10
  14. package/dist/cli/commands/crud.js.map +1 -1
  15. package/dist/cli/commands/docs.js +2 -2
  16. package/dist/cli/commands/docs.js.map +1 -1
  17. package/dist/cli/commands/document.js +1 -1
  18. package/dist/cli/commands/document.js.map +1 -1
  19. package/dist/cli/commands/entity.js +1 -1
  20. package/dist/cli/commands/entity.js.map +1 -1
  21. package/dist/cli/commands/external-sync.d.ts +6 -5
  22. package/dist/cli/commands/external-sync.d.ts.map +1 -1
  23. package/dist/cli/commands/external-sync.js +1032 -180
  24. package/dist/cli/commands/external-sync.js.map +1 -1
  25. package/dist/cli/commands/library.js +1 -1
  26. package/dist/cli/commands/library.js.map +1 -1
  27. package/dist/cli/commands/message.js +2 -2
  28. package/dist/cli/commands/message.js.map +1 -1
  29. package/dist/cli/commands/serve.d.ts.map +1 -1
  30. package/dist/cli/commands/serve.js +2 -0
  31. package/dist/cli/commands/serve.js.map +1 -1
  32. package/dist/cli/commands/task.d.ts.map +1 -1
  33. package/dist/cli/commands/task.js +7 -4
  34. package/dist/cli/commands/task.js.map +1 -1
  35. package/dist/cli/commands/team.js +1 -1
  36. package/dist/cli/commands/team.js.map +1 -1
  37. package/dist/cli/commands/workflow.js +1 -1
  38. package/dist/cli/commands/workflow.js.map +1 -1
  39. package/dist/cli/utils/progress.d.ts +30 -0
  40. package/dist/cli/utils/progress.d.ts.map +1 -0
  41. package/dist/cli/utils/progress.js +47 -0
  42. package/dist/cli/utils/progress.js.map +1 -0
  43. package/dist/config/config.d.ts.map +1 -1
  44. package/dist/config/config.js +6 -0
  45. package/dist/config/config.js.map +1 -1
  46. package/dist/config/defaults.d.ts.map +1 -1
  47. package/dist/config/defaults.js +1 -0
  48. package/dist/config/defaults.js.map +1 -1
  49. package/dist/config/file.d.ts.map +1 -1
  50. package/dist/config/file.js +10 -0
  51. package/dist/config/file.js.map +1 -1
  52. package/dist/config/merge.d.ts.map +1 -1
  53. package/dist/config/merge.js +7 -1
  54. package/dist/config/merge.js.map +1 -1
  55. package/dist/config/types.d.ts +7 -2
  56. package/dist/config/types.d.ts.map +1 -1
  57. package/dist/config/types.js +3 -0
  58. package/dist/config/types.js.map +1 -1
  59. package/dist/config/validation.d.ts.map +1 -1
  60. package/dist/config/validation.js +13 -0
  61. package/dist/config/validation.js.map +1 -1
  62. package/dist/external-sync/adapters/document-sync-adapter.d.ts +150 -0
  63. package/dist/external-sync/adapters/document-sync-adapter.d.ts.map +1 -0
  64. package/dist/external-sync/adapters/document-sync-adapter.js +325 -0
  65. package/dist/external-sync/adapters/document-sync-adapter.js.map +1 -0
  66. package/dist/external-sync/index.d.ts +3 -0
  67. package/dist/external-sync/index.d.ts.map +1 -1
  68. package/dist/external-sync/index.js +4 -0
  69. package/dist/external-sync/index.js.map +1 -1
  70. package/dist/external-sync/provider-registry.d.ts +7 -3
  71. package/dist/external-sync/provider-registry.d.ts.map +1 -1
  72. package/dist/external-sync/provider-registry.js +20 -3
  73. package/dist/external-sync/provider-registry.js.map +1 -1
  74. package/dist/external-sync/providers/folder/folder-document-adapter.d.ts +97 -0
  75. package/dist/external-sync/providers/folder/folder-document-adapter.d.ts.map +1 -0
  76. package/dist/external-sync/providers/folder/folder-document-adapter.js +261 -0
  77. package/dist/external-sync/providers/folder/folder-document-adapter.js.map +1 -0
  78. package/dist/external-sync/providers/folder/folder-fs.d.ts +146 -0
  79. package/dist/external-sync/providers/folder/folder-fs.d.ts.map +1 -0
  80. package/dist/external-sync/providers/folder/folder-fs.js +300 -0
  81. package/dist/external-sync/providers/folder/folder-fs.js.map +1 -0
  82. package/dist/external-sync/providers/folder/folder-provider.d.ts +28 -0
  83. package/dist/external-sync/providers/folder/folder-provider.d.ts.map +1 -0
  84. package/dist/external-sync/providers/folder/folder-provider.js +87 -0
  85. package/dist/external-sync/providers/folder/folder-provider.js.map +1 -0
  86. package/dist/external-sync/providers/folder/index.d.ts +11 -0
  87. package/dist/external-sync/providers/folder/index.d.ts.map +1 -0
  88. package/dist/external-sync/providers/folder/index.js +13 -0
  89. package/dist/external-sync/providers/folder/index.js.map +1 -0
  90. package/dist/external-sync/providers/index.d.ts +4 -0
  91. package/dist/external-sync/providers/index.d.ts.map +1 -1
  92. package/dist/external-sync/providers/index.js +5 -0
  93. package/dist/external-sync/providers/index.js.map +1 -1
  94. package/dist/external-sync/providers/notion/index.d.ts +19 -0
  95. package/dist/external-sync/providers/notion/index.d.ts.map +1 -0
  96. package/dist/external-sync/providers/notion/index.js +20 -0
  97. package/dist/external-sync/providers/notion/index.js.map +1 -0
  98. package/dist/external-sync/providers/notion/notion-api.d.ts +253 -0
  99. package/dist/external-sync/providers/notion/notion-api.d.ts.map +1 -0
  100. package/dist/external-sync/providers/notion/notion-api.js +492 -0
  101. package/dist/external-sync/providers/notion/notion-api.js.map +1 -0
  102. package/dist/external-sync/providers/notion/notion-blocks.d.ts +93 -0
  103. package/dist/external-sync/providers/notion/notion-blocks.d.ts.map +1 -0
  104. package/dist/external-sync/providers/notion/notion-blocks.js +773 -0
  105. package/dist/external-sync/providers/notion/notion-blocks.js.map +1 -0
  106. package/dist/external-sync/providers/notion/notion-document-adapter.d.ts +176 -0
  107. package/dist/external-sync/providers/notion/notion-document-adapter.d.ts.map +1 -0
  108. package/dist/external-sync/providers/notion/notion-document-adapter.js +413 -0
  109. package/dist/external-sync/providers/notion/notion-document-adapter.js.map +1 -0
  110. package/dist/external-sync/providers/notion/notion-provider.d.ts +57 -0
  111. package/dist/external-sync/providers/notion/notion-provider.d.ts.map +1 -0
  112. package/dist/external-sync/providers/notion/notion-provider.js +159 -0
  113. package/dist/external-sync/providers/notion/notion-provider.js.map +1 -0
  114. package/dist/external-sync/providers/notion/notion-types.d.ts +388 -0
  115. package/dist/external-sync/providers/notion/notion-types.d.ts.map +1 -0
  116. package/dist/external-sync/providers/notion/notion-types.js +47 -0
  117. package/dist/external-sync/providers/notion/notion-types.js.map +1 -0
  118. package/dist/external-sync/sync-engine.d.ts +70 -4
  119. package/dist/external-sync/sync-engine.d.ts.map +1 -1
  120. package/dist/external-sync/sync-engine.js +436 -67
  121. package/dist/external-sync/sync-engine.js.map +1 -1
  122. package/dist/server/index.js +8 -8
  123. package/dist/server/index.js.map +1 -1
  124. package/package.json +4 -12
@@ -0,0 +1,773 @@
1
+ /**
2
+ * Notion Blocks ↔ Markdown Converter
3
+ *
4
+ * Bidirectional converter between markdown text and Notion block format.
5
+ * Internal module with no external dependencies.
6
+ *
7
+ * Supported conversions:
8
+ * - Paragraphs ↔ paragraph blocks
9
+ * - Headings (# ## ###) ↔ heading_1, heading_2, heading_3 blocks
10
+ * - Bulleted lists (- or *) ↔ bulleted_list_item blocks
11
+ * - Numbered lists (1. 2. 3.) ↔ numbered_list_item blocks
12
+ * - Code blocks (```) ↔ code blocks (with language annotation)
13
+ * - Blockquotes (>) ↔ quote blocks
14
+ * - Checkboxes (- [ ] / - [x]) ↔ to_do blocks
15
+ * - Rich text: **bold**, *italic*, `inline code`, [links](url)
16
+ */
17
+ import { DEFAULT_ANNOTATIONS } from './notion-types.js';
18
+ // ============================================================================
19
+ // Rich Text Chunking (Notion 2000-character limit)
20
+ // ============================================================================
21
+ /**
22
+ * Maximum length for a single rich_text element's text.content in the Notion API.
23
+ * @see https://developers.notion.com/reference/block
24
+ */
25
+ export const NOTION_MAX_TEXT_LENGTH = 2000;
26
+ /**
27
+ * Maximum number of elements in a single rich_text array in the Notion API.
28
+ * When a block's rich_text array exceeds this limit, the block must be split
29
+ * into multiple blocks of the same type.
30
+ * @see https://developers.notion.com/reference/block
31
+ */
32
+ export const NOTION_MAX_RICH_TEXT_ARRAY_LENGTH = 100;
33
+ /**
34
+ * Split a text string into chunks of at most `maxLength` characters,
35
+ * preferring word boundaries when possible.
36
+ */
37
+ function splitTextAtWordBoundaries(text, maxLength = NOTION_MAX_TEXT_LENGTH) {
38
+ if (text.length <= maxLength) {
39
+ return [text];
40
+ }
41
+ const chunks = [];
42
+ let remaining = text;
43
+ while (remaining.length > 0) {
44
+ if (remaining.length <= maxLength) {
45
+ chunks.push(remaining);
46
+ break;
47
+ }
48
+ // Split at last space before maxLength
49
+ let splitAt = remaining.lastIndexOf(' ', maxLength);
50
+ if (splitAt <= 0)
51
+ splitAt = maxLength; // No space found, hard split
52
+ chunks.push(remaining.slice(0, splitAt));
53
+ remaining = remaining.slice(splitAt).trimStart();
54
+ }
55
+ return chunks;
56
+ }
57
+ /**
58
+ * Split a plain text string into multiple NotionRichText elements,
59
+ * each with text.content at most `maxLength` characters.
60
+ * Splits at word boundaries when possible.
61
+ */
62
+ export function chunkRichText(text, maxLength = NOTION_MAX_TEXT_LENGTH) {
63
+ if (text.length <= maxLength) {
64
+ return [createPlainRichText(text)];
65
+ }
66
+ return splitTextAtWordBoundaries(text, maxLength).map((chunk) => createPlainRichText(chunk));
67
+ }
68
+ /**
69
+ * Split a single NotionRichText element into multiple elements if its content
70
+ * exceeds `maxLength`, preserving annotations and link information.
71
+ */
72
+ function chunkRichTextElement(rt, maxLength = NOTION_MAX_TEXT_LENGTH) {
73
+ const content = rt.text?.content ?? rt.plain_text;
74
+ if (content.length <= maxLength) {
75
+ return [rt];
76
+ }
77
+ const textChunks = splitTextAtWordBoundaries(content, maxLength);
78
+ return textChunks.map((chunk) => ({
79
+ type: rt.type,
80
+ text: rt.text ? { content: chunk, link: rt.text.link } : undefined,
81
+ annotations: { ...rt.annotations },
82
+ plain_text: chunk,
83
+ href: rt.href,
84
+ }));
85
+ }
86
+ /**
87
+ * Check if two NotionRichText elements have identical annotations.
88
+ */
89
+ function annotationsEqual(a, b) {
90
+ return (a.bold === b.bold &&
91
+ a.italic === b.italic &&
92
+ a.strikethrough === b.strikethrough &&
93
+ a.underline === b.underline &&
94
+ a.code === b.code &&
95
+ a.color === b.color);
96
+ }
97
+ /**
98
+ * Check if two NotionRichText elements can be merged (same annotations and link).
99
+ */
100
+ function canMergeRichText(a, b) {
101
+ if (a.type !== 'text' || b.type !== 'text')
102
+ return false;
103
+ if (!annotationsEqual(a.annotations, b.annotations))
104
+ return false;
105
+ // Both must have same link (both null, or both same URL)
106
+ const aLink = a.text?.link?.url ?? null;
107
+ const bLink = b.text?.link?.url ?? null;
108
+ return aLink === bLink;
109
+ }
110
+ /**
111
+ * Merge consecutive rich_text elements that share the same annotations and link.
112
+ * This reduces array length when inline-heavy markdown produces many alternating
113
+ * plain/formatted segments that happen to be adjacent with identical formatting.
114
+ * Respects the per-element character limit to avoid undoing character chunking.
115
+ */
116
+ function mergeAdjacentRichText(richTexts, maxLength = NOTION_MAX_TEXT_LENGTH) {
117
+ if (richTexts.length <= 1)
118
+ return richTexts;
119
+ const merged = [richTexts[0]];
120
+ for (let i = 1; i < richTexts.length; i++) {
121
+ const prev = merged[merged.length - 1];
122
+ const curr = richTexts[i];
123
+ const prevLen = (prev.text?.content ?? prev.plain_text).length;
124
+ const currLen = (curr.text?.content ?? curr.plain_text).length;
125
+ if (canMergeRichText(prev, curr) && prevLen + currLen <= maxLength) {
126
+ // Merge: concatenate content (stays within character limit)
127
+ const content = (prev.text?.content ?? prev.plain_text) + (curr.text?.content ?? curr.plain_text);
128
+ merged[merged.length - 1] = {
129
+ type: prev.type,
130
+ text: prev.text ? { content, link: prev.text.link } : undefined,
131
+ annotations: { ...prev.annotations },
132
+ plain_text: content,
133
+ href: prev.href,
134
+ };
135
+ }
136
+ else {
137
+ merged.push(curr);
138
+ }
139
+ }
140
+ return merged;
141
+ }
142
+ /**
143
+ * Ensure all elements in a rich_text array respect both:
144
+ * 1. The Notion 2000-character-per-element limit (splits oversized elements)
145
+ * 2. Reduced array length via merging adjacent same-formatted elements
146
+ *
147
+ * Merge first (to reduce count), then chunk (to enforce character limits).
148
+ */
149
+ function ensureRichTextWithinLimits(richTexts, maxLength = NOTION_MAX_TEXT_LENGTH) {
150
+ // First merge adjacent elements with same formatting to reduce count
151
+ const merged = mergeAdjacentRichText(richTexts, maxLength);
152
+ // Then split any elements that exceed the character limit
153
+ return merged.flatMap((rt) => chunkRichTextElement(rt, maxLength));
154
+ }
155
+ // ============================================================================
156
+ // Markdown → Notion Blocks
157
+ // ============================================================================
158
+ /**
159
+ * Convert markdown text to an array of Notion blocks.
160
+ *
161
+ * Parses markdown line-by-line, recognizing headings, lists, code blocks,
162
+ * blockquotes, checkboxes, and paragraphs. Rich text formatting (bold,
163
+ * italic, inline code, links) is preserved within each block.
164
+ */
165
+ export function markdownToNotionBlocks(markdown) {
166
+ if (!markdown || markdown.trim() === '') {
167
+ return [];
168
+ }
169
+ const lines = markdown.split('\n');
170
+ const blocks = [];
171
+ let i = 0;
172
+ while (i < lines.length) {
173
+ const line = lines[i];
174
+ // Code block (fenced with ```)
175
+ if (line.trimStart().startsWith('```')) {
176
+ const indent = line.indexOf('```');
177
+ const langMatch = line.slice(indent + 3).trim();
178
+ const language = langMatch || 'plain text';
179
+ const codeLines = [];
180
+ i++;
181
+ while (i < lines.length && !lines[i].trimStart().startsWith('```')) {
182
+ codeLines.push(lines[i]);
183
+ i++;
184
+ }
185
+ // Skip closing ```
186
+ if (i < lines.length) {
187
+ i++;
188
+ }
189
+ blocks.push(...createCodeBlocks(codeLines.join('\n'), language));
190
+ continue;
191
+ }
192
+ // Empty line — skip (paragraph breaks are implicit)
193
+ if (line.trim() === '') {
194
+ i++;
195
+ continue;
196
+ }
197
+ // Heading (# ## ###)
198
+ const headingMatch = line.match(/^(#{1,3})\s+(.+)$/);
199
+ if (headingMatch) {
200
+ const level = headingMatch[1].length;
201
+ const text = headingMatch[2];
202
+ blocks.push(...createHeadingBlock(level, text));
203
+ i++;
204
+ continue;
205
+ }
206
+ // Checkbox (- [ ] or - [x] or * [ ] or * [x])
207
+ const checkboxMatch = line.match(/^[-*]\s+\[([ xX])\]\s+(.*)$/);
208
+ if (checkboxMatch) {
209
+ const checked = checkboxMatch[1].toLowerCase() === 'x';
210
+ const text = checkboxMatch[2];
211
+ blocks.push(...createToDoBlock(text, checked));
212
+ i++;
213
+ continue;
214
+ }
215
+ // Bulleted list (- or *)
216
+ const bulletMatch = line.match(/^[-*]\s+(.+)$/);
217
+ if (bulletMatch) {
218
+ const text = bulletMatch[1];
219
+ blocks.push(...createBulletedListItemBlock(text));
220
+ i++;
221
+ continue;
222
+ }
223
+ // Numbered list (1. 2. etc.)
224
+ const numberedMatch = line.match(/^\d+\.\s+(.+)$/);
225
+ if (numberedMatch) {
226
+ const text = numberedMatch[1];
227
+ blocks.push(...createNumberedListItemBlock(text));
228
+ i++;
229
+ continue;
230
+ }
231
+ // Blockquote (>)
232
+ const quoteMatch = line.match(/^>\s?(.*)$/);
233
+ if (quoteMatch) {
234
+ // Collect consecutive blockquote lines
235
+ const quoteLines = [quoteMatch[1]];
236
+ i++;
237
+ while (i < lines.length && lines[i].match(/^>\s?(.*)/)) {
238
+ const nextQuoteMatch = lines[i].match(/^>\s?(.*)$/);
239
+ if (nextQuoteMatch) {
240
+ quoteLines.push(nextQuoteMatch[1]);
241
+ }
242
+ i++;
243
+ }
244
+ blocks.push(...createQuoteBlock(quoteLines.join('\n')));
245
+ continue;
246
+ }
247
+ // Paragraph (default)
248
+ // Collect consecutive non-empty, non-special lines as a single paragraph
249
+ const paragraphLines = [line];
250
+ i++;
251
+ while (i < lines.length) {
252
+ const nextLine = lines[i];
253
+ // Stop paragraph on empty line or any special syntax
254
+ if (nextLine.trim() === '' ||
255
+ nextLine.match(/^#{1,3}\s+/) ||
256
+ nextLine.match(/^[-*]\s+/) ||
257
+ nextLine.match(/^\d+\.\s+/) ||
258
+ nextLine.match(/^>\s?/) ||
259
+ nextLine.trimStart().startsWith('```')) {
260
+ break;
261
+ }
262
+ paragraphLines.push(nextLine);
263
+ i++;
264
+ }
265
+ blocks.push(...createParagraphBlock(paragraphLines.join('\n')));
266
+ }
267
+ return blocks;
268
+ }
269
+ // ============================================================================
270
+ // Notion Blocks → Markdown
271
+ // ============================================================================
272
+ /**
273
+ * Convert Notion blocks back to a markdown string.
274
+ *
275
+ * Handles all supported block types. Unsupported block types produce
276
+ * a fallback `[Unsupported: {type}]` text.
277
+ */
278
+ export function notionBlocksToMarkdown(blocks) {
279
+ if (!blocks || blocks.length === 0) {
280
+ return '';
281
+ }
282
+ const lines = [];
283
+ for (let i = 0; i < blocks.length; i++) {
284
+ const block = blocks[i];
285
+ const prevBlock = i > 0 ? blocks[i - 1] : null;
286
+ // Add blank line between blocks of different types,
287
+ // but not between consecutive list items of the same type
288
+ if (prevBlock && i > 0) {
289
+ const needsBlankLine = !isSameListType(prevBlock.type, block.type);
290
+ if (needsBlankLine) {
291
+ lines.push('');
292
+ }
293
+ }
294
+ const markdown = blockToMarkdown(block);
295
+ if (markdown !== null) {
296
+ lines.push(markdown);
297
+ }
298
+ }
299
+ return lines.join('\n');
300
+ }
301
+ // ============================================================================
302
+ // Block Creation Helpers (markdown → Notion)
303
+ // ============================================================================
304
+ /**
305
+ * Split a rich_text array into chunks respecting the Notion 100-element limit.
306
+ * Each chunk contains at most NOTION_MAX_RICH_TEXT_ARRAY_LENGTH elements.
307
+ */
308
+ function splitRichTextArray(richTexts, maxArrayLength = NOTION_MAX_RICH_TEXT_ARRAY_LENGTH) {
309
+ if (richTexts.length <= maxArrayLength) {
310
+ return [richTexts];
311
+ }
312
+ const chunks = [];
313
+ for (let i = 0; i < richTexts.length; i += maxArrayLength) {
314
+ chunks.push(richTexts.slice(i, i + maxArrayLength));
315
+ }
316
+ return chunks;
317
+ }
318
+ /**
319
+ * Prepare rich_text for a block: parse inline markdown, enforce character limits,
320
+ * merge adjacent elements, and split into chunks if array exceeds 100 elements.
321
+ */
322
+ function prepareRichTextChunks(text) {
323
+ const richTexts = ensureRichTextWithinLimits(parseInlineMarkdown(text));
324
+ return splitRichTextArray(richTexts);
325
+ }
326
+ function createParagraphBlock(text) {
327
+ return prepareRichTextChunks(text).map((richText) => ({
328
+ type: 'paragraph',
329
+ paragraph: { rich_text: richText },
330
+ }));
331
+ }
332
+ function createHeadingBlock(level, text) {
333
+ const type = `heading_${level}`;
334
+ const chunks = prepareRichTextChunks(text);
335
+ // First chunk keeps the heading type; overflow chunks become paragraphs
336
+ // (Notion doesn't support multiple consecutive headings for a single logical heading)
337
+ return chunks.map((richText, i) => {
338
+ if (i === 0) {
339
+ return { type, [type]: { rich_text: richText } };
340
+ }
341
+ return { type: 'paragraph', paragraph: { rich_text: richText } };
342
+ });
343
+ }
344
+ function createBulletedListItemBlock(text) {
345
+ return prepareRichTextChunks(text).map((richText) => ({
346
+ type: 'bulleted_list_item',
347
+ bulleted_list_item: { rich_text: richText },
348
+ }));
349
+ }
350
+ function createNumberedListItemBlock(text) {
351
+ return prepareRichTextChunks(text).map((richText) => ({
352
+ type: 'numbered_list_item',
353
+ numbered_list_item: { rich_text: richText },
354
+ }));
355
+ }
356
+ /**
357
+ * Create one or more code blocks from a code string.
358
+ * If the code exceeds NOTION_MAX_TEXT_LENGTH, it is split into multiple
359
+ * consecutive code blocks with the same language annotation.
360
+ * Code splits prefer line boundaries, then word boundaries.
361
+ */
362
+ function createCodeBlocks(code, language) {
363
+ const normalizedLang = normalizeLanguage(language);
364
+ if (code.length <= NOTION_MAX_TEXT_LENGTH) {
365
+ return [
366
+ {
367
+ type: 'code',
368
+ code: {
369
+ rich_text: [createPlainRichText(code)],
370
+ language: normalizedLang,
371
+ },
372
+ },
373
+ ];
374
+ }
375
+ // Split code at line boundaries when possible
376
+ const chunks = splitCodeAtLineBoundaries(code, NOTION_MAX_TEXT_LENGTH);
377
+ return chunks.map((chunk) => ({
378
+ type: 'code',
379
+ code: {
380
+ rich_text: [createPlainRichText(chunk)],
381
+ language: normalizedLang,
382
+ },
383
+ }));
384
+ }
385
+ /**
386
+ * Split code text into chunks, preferring line boundaries (\n) over word boundaries.
387
+ */
388
+ function splitCodeAtLineBoundaries(text, maxLength) {
389
+ if (text.length <= maxLength) {
390
+ return [text];
391
+ }
392
+ const chunks = [];
393
+ let remaining = text;
394
+ while (remaining.length > 0) {
395
+ if (remaining.length <= maxLength) {
396
+ chunks.push(remaining);
397
+ break;
398
+ }
399
+ // Prefer splitting at a newline before maxLength
400
+ let splitAt = remaining.lastIndexOf('\n', maxLength);
401
+ if (splitAt <= 0) {
402
+ // No newline found; try word boundary
403
+ splitAt = remaining.lastIndexOf(' ', maxLength);
404
+ }
405
+ if (splitAt <= 0) {
406
+ // No boundary found; hard split
407
+ splitAt = maxLength;
408
+ }
409
+ chunks.push(remaining.slice(0, splitAt));
410
+ // For newline splits, skip the newline character itself
411
+ if (remaining[splitAt] === '\n') {
412
+ remaining = remaining.slice(splitAt + 1);
413
+ }
414
+ else {
415
+ remaining = remaining.slice(splitAt).trimStart();
416
+ }
417
+ }
418
+ return chunks;
419
+ }
420
+ function createQuoteBlock(text) {
421
+ return prepareRichTextChunks(text).map((richText) => ({
422
+ type: 'quote',
423
+ quote: { rich_text: richText },
424
+ }));
425
+ }
426
+ function createToDoBlock(text, checked) {
427
+ const chunks = prepareRichTextChunks(text);
428
+ return chunks.map((richText, i) => ({
429
+ type: 'to_do',
430
+ to_do: {
431
+ rich_text: richText,
432
+ // Only the first block carries the checked state; overflow blocks are unchecked
433
+ checked: i === 0 ? checked : false,
434
+ },
435
+ }));
436
+ }
437
+ // ============================================================================
438
+ // Block → Markdown Helpers
439
+ // ============================================================================
440
+ function blockToMarkdown(block) {
441
+ switch (block.type) {
442
+ case 'paragraph': {
443
+ const b = block;
444
+ return richTextToMarkdown(b.paragraph.rich_text);
445
+ }
446
+ case 'heading_1': {
447
+ const b = block;
448
+ return `# ${richTextToMarkdown(b.heading_1.rich_text)}`;
449
+ }
450
+ case 'heading_2': {
451
+ const b = block;
452
+ return `## ${richTextToMarkdown(b.heading_2.rich_text)}`;
453
+ }
454
+ case 'heading_3': {
455
+ const b = block;
456
+ return `### ${richTextToMarkdown(b.heading_3.rich_text)}`;
457
+ }
458
+ case 'bulleted_list_item': {
459
+ const b = block;
460
+ return `- ${richTextToMarkdown(b.bulleted_list_item.rich_text)}`;
461
+ }
462
+ case 'numbered_list_item': {
463
+ const b = block;
464
+ return `1. ${richTextToMarkdown(b.numbered_list_item.rich_text)}`;
465
+ }
466
+ case 'code': {
467
+ const b = block;
468
+ const lang = b.code.language === 'plain text' ? '' : b.code.language;
469
+ const codeText = richTextToPlainText(b.code.rich_text);
470
+ return `\`\`\`${lang}\n${codeText}\n\`\`\``;
471
+ }
472
+ case 'quote': {
473
+ const b = block;
474
+ const quoteText = richTextToMarkdown(b.quote.rich_text);
475
+ // Prefix each line with >
476
+ return quoteText
477
+ .split('\n')
478
+ .map((line) => `> ${line}`)
479
+ .join('\n');
480
+ }
481
+ case 'to_do': {
482
+ const b = block;
483
+ const checkbox = b.to_do.checked ? '[x]' : '[ ]';
484
+ return `- ${checkbox} ${richTextToMarkdown(b.to_do.rich_text)}`;
485
+ }
486
+ default:
487
+ // Unsupported block type fallback
488
+ return `[Unsupported: ${block.type}]`;
489
+ }
490
+ }
491
+ /**
492
+ * Check if two block types belong to the same list category
493
+ * (so we don't insert blank lines between consecutive items).
494
+ */
495
+ function isSameListType(typeA, typeB) {
496
+ const listTypes = new Set([
497
+ 'bulleted_list_item',
498
+ 'numbered_list_item',
499
+ 'to_do',
500
+ ]);
501
+ // Same exact type (e.g., both bulleted_list_item)
502
+ if (typeA === typeB && listTypes.has(typeA)) {
503
+ return true;
504
+ }
505
+ return false;
506
+ }
507
+ // ============================================================================
508
+ // URL Validation
509
+ // ============================================================================
510
+ /**
511
+ * Check whether a URL string is valid for use in a Notion link block.
512
+ * Notion only accepts absolute http: or https: URLs. Relative paths,
513
+ * fragment-only references (#section), workspace element IDs (el-xxxx),
514
+ * empty strings, and malformed URLs are all rejected.
515
+ */
516
+ export function isValidNotionUrl(url) {
517
+ try {
518
+ const parsed = new URL(url);
519
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
520
+ }
521
+ catch {
522
+ return false;
523
+ }
524
+ }
525
+ // ============================================================================
526
+ // Rich Text Parsing (markdown inline → Notion rich text)
527
+ // ============================================================================
528
+ /**
529
+ * Parse inline markdown formatting into an array of NotionRichText objects.
530
+ *
531
+ * Supports:
532
+ * - **bold** or __bold__
533
+ * - *italic* or _italic_
534
+ * - `inline code`
535
+ * - [link text](url)
536
+ * - Combinations thereof
537
+ */
538
+ export function parseInlineMarkdown(text) {
539
+ if (!text) {
540
+ return [];
541
+ }
542
+ const richTexts = [];
543
+ // Regex for matching inline markdown tokens
544
+ // Order matters: bold before italic to avoid ambiguity with ** vs *
545
+ const inlinePattern = /(\*\*(.+?)\*\*|__(.+?)__|\*(.+?)\*|_([^_]+?)_|`([^`]+?)`|\[([^\]]+?)\]\(([^)]+?)\))/g;
546
+ let lastIndex = 0;
547
+ let match;
548
+ while ((match = inlinePattern.exec(text)) !== null) {
549
+ // Add plain text before this match
550
+ if (match.index > lastIndex) {
551
+ const plainText = text.slice(lastIndex, match.index);
552
+ richTexts.push(createPlainRichText(plainText));
553
+ }
554
+ const fullMatch = match[0];
555
+ if (match[2] !== undefined || match[3] !== undefined) {
556
+ // **bold** or __bold__
557
+ const boldText = match[2] ?? match[3];
558
+ richTexts.push(createAnnotatedRichText(boldText, { bold: true }));
559
+ }
560
+ else if (match[4] !== undefined || match[5] !== undefined) {
561
+ // *italic* or _italic_
562
+ const italicText = match[4] ?? match[5];
563
+ richTexts.push(createAnnotatedRichText(italicText, { italic: true }));
564
+ }
565
+ else if (match[6] !== undefined) {
566
+ // `inline code`
567
+ richTexts.push(createAnnotatedRichText(match[6], { code: true }));
568
+ }
569
+ else if (match[7] !== undefined && match[8] !== undefined) {
570
+ // [link text](url) — validate URL before creating a link block
571
+ if (isValidNotionUrl(match[8])) {
572
+ richTexts.push(createLinkRichText(match[7], match[8]));
573
+ }
574
+ else {
575
+ // Invalid URL — render as plain text to avoid Notion rejection
576
+ richTexts.push(createPlainRichText(match[7]));
577
+ }
578
+ }
579
+ lastIndex = match.index + fullMatch.length;
580
+ }
581
+ // Add remaining plain text after last match
582
+ if (lastIndex < text.length) {
583
+ richTexts.push(createPlainRichText(text.slice(lastIndex)));
584
+ }
585
+ // If no matches found at all, return the text as plain
586
+ if (richTexts.length === 0) {
587
+ richTexts.push(createPlainRichText(text));
588
+ }
589
+ return richTexts;
590
+ }
591
+ // ============================================================================
592
+ // Rich Text → Markdown (Notion rich text → inline markdown)
593
+ // ============================================================================
594
+ /**
595
+ * Convert an array of Notion rich text objects back to inline markdown.
596
+ */
597
+ export function richTextToMarkdown(richTexts) {
598
+ if (!richTexts || richTexts.length === 0) {
599
+ return '';
600
+ }
601
+ return richTexts.map(richTextElementToMarkdown).join('');
602
+ }
603
+ /**
604
+ * Convert rich text array to plain text (no formatting).
605
+ * Used for code blocks where we want raw content.
606
+ */
607
+ function richTextToPlainText(richTexts) {
608
+ if (!richTexts || richTexts.length === 0) {
609
+ return '';
610
+ }
611
+ return richTexts.map((rt) => rt.plain_text).join('');
612
+ }
613
+ /**
614
+ * Convert a single rich text element to its markdown representation.
615
+ */
616
+ function richTextElementToMarkdown(rt) {
617
+ let text = rt.plain_text;
618
+ const { annotations } = rt;
619
+ const link = rt.text?.link;
620
+ // Apply inline code first (code takes precedence, no nesting inside code)
621
+ if (annotations.code) {
622
+ text = `\`${text}\``;
623
+ }
624
+ else {
625
+ // Apply bold and italic (can be combined)
626
+ if (annotations.bold && annotations.italic) {
627
+ text = `***${text}***`;
628
+ }
629
+ else if (annotations.bold) {
630
+ text = `**${text}**`;
631
+ }
632
+ else if (annotations.italic) {
633
+ text = `*${text}*`;
634
+ }
635
+ // Apply link wrapping
636
+ if (link?.url) {
637
+ text = `[${text}](${link.url})`;
638
+ }
639
+ }
640
+ return text;
641
+ }
642
+ // ============================================================================
643
+ // Rich Text Construction Helpers
644
+ // ============================================================================
645
+ /**
646
+ * Create a plain (unformatted) rich text element.
647
+ */
648
+ function createPlainRichText(content) {
649
+ return {
650
+ type: 'text',
651
+ text: {
652
+ content,
653
+ link: null,
654
+ },
655
+ annotations: { ...DEFAULT_ANNOTATIONS },
656
+ plain_text: content,
657
+ href: null,
658
+ };
659
+ }
660
+ /**
661
+ * Create a rich text element with specific annotations.
662
+ */
663
+ function createAnnotatedRichText(content, annotationOverrides) {
664
+ const annotations = {
665
+ ...DEFAULT_ANNOTATIONS,
666
+ ...annotationOverrides,
667
+ };
668
+ return {
669
+ type: 'text',
670
+ text: {
671
+ content,
672
+ link: null,
673
+ },
674
+ annotations,
675
+ plain_text: content,
676
+ href: null,
677
+ };
678
+ }
679
+ /**
680
+ * Create a rich text element with a link.
681
+ */
682
+ function createLinkRichText(content, url) {
683
+ return {
684
+ type: 'text',
685
+ text: {
686
+ content,
687
+ link: { url },
688
+ },
689
+ annotations: { ...DEFAULT_ANNOTATIONS },
690
+ plain_text: content,
691
+ href: url,
692
+ };
693
+ }
694
+ // ============================================================================
695
+ // Language Normalization
696
+ // ============================================================================
697
+ /**
698
+ * The complete set of language identifiers accepted by the Notion API
699
+ * for code blocks. Any language not in this set will be rejected.
700
+ * @see https://developers.notion.com/reference/block#code
701
+ */
702
+ export const NOTION_LANGUAGES = new Set([
703
+ 'abap', 'abc', 'agda', 'arduino', 'ascii art', 'assembly', 'bash', 'basic',
704
+ 'bnf', 'c', 'c#', 'c++', 'clojure', 'coffeescript', 'coq', 'css', 'dart',
705
+ 'dhall', 'diff', 'docker', 'ebnf', 'elixir', 'elm', 'erlang', 'f#', 'flow',
706
+ 'fortran', 'gherkin', 'glsl', 'go', 'graphql', 'groovy', 'haskell', 'hcl',
707
+ 'html', 'idris', 'java', 'javascript', 'json', 'julia', 'kotlin', 'latex',
708
+ 'less', 'lisp', 'livescript', 'llvm ir', 'lua', 'makefile', 'markdown',
709
+ 'markup', 'matlab', 'mathematica', 'mermaid', 'nix', 'notion formula',
710
+ 'objective-c', 'ocaml', 'pascal', 'perl', 'php', 'plain text', 'powershell',
711
+ 'prolog', 'protobuf', 'purescript', 'python', 'r', 'racket', 'reason',
712
+ 'ruby', 'rust', 'sass', 'scala', 'scheme', 'scss', 'shell', 'smalltalk',
713
+ 'solidity', 'sql', 'swift', 'toml', 'typescript', 'vb.net', 'verilog',
714
+ 'vhdl', 'visual basic', 'webassembly', 'xml', 'yaml', 'java/c/c++/c#',
715
+ ]);
716
+ /**
717
+ * Map common code fence language aliases to their Notion-accepted equivalents.
718
+ * These cover short names, file extensions, and alternate spellings that
719
+ * markdown authors commonly use but Notion does not recognize.
720
+ */
721
+ export const LANGUAGE_ALIASES = {
722
+ 'tsx': 'typescript',
723
+ 'ts': 'typescript',
724
+ 'jsx': 'javascript',
725
+ 'js': 'javascript',
726
+ 'sh': 'shell',
727
+ 'zsh': 'shell',
728
+ 'yml': 'yaml',
729
+ 'py': 'python',
730
+ 'rb': 'ruby',
731
+ 'rs': 'rust',
732
+ 'cs': 'c#',
733
+ 'cpp': 'c++',
734
+ 'objc': 'objective-c',
735
+ 'dockerfile': 'docker',
736
+ 'tf': 'hcl',
737
+ 'hs': 'haskell',
738
+ 'ex': 'elixir',
739
+ 'exs': 'elixir',
740
+ 'kt': 'kotlin',
741
+ 'fs': 'f#',
742
+ 'fsharp': 'f#',
743
+ 'csharp': 'c#',
744
+ 'jsonc': 'json',
745
+ 'md': 'markdown',
746
+ 'text': 'plain text',
747
+ 'txt': 'plain text',
748
+ 'plaintext': 'plain text',
749
+ '': 'plain text',
750
+ };
751
+ /**
752
+ * Map a code fence language identifier to a Notion-accepted language value.
753
+ *
754
+ * 1. If the lowercased input is already in NOTION_LANGUAGES, return it.
755
+ * 2. If it matches a known alias, return the mapped value.
756
+ * 3. Otherwise, fall back to 'plain text' (always accepted by Notion).
757
+ */
758
+ export function mapLanguageToNotion(lang) {
759
+ const lower = lang.toLowerCase().trim();
760
+ if (NOTION_LANGUAGES.has(lower))
761
+ return lower;
762
+ if (LANGUAGE_ALIASES[lower])
763
+ return LANGUAGE_ALIASES[lower];
764
+ return 'plain text'; // safe fallback
765
+ }
766
+ /**
767
+ * Normalize a language identifier to Notion's format.
768
+ * Delegates to mapLanguageToNotion for full allowlist validation.
769
+ */
770
+ function normalizeLanguage(lang) {
771
+ return mapLanguageToNotion(lang);
772
+ }
773
+ //# sourceMappingURL=notion-blocks.js.map