notion-mcp-server 1.0.1 → 2.4.2

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 (99) hide show
  1. package/README.md +383 -192
  2. package/build/config/index.js +3 -1
  3. package/build/dispatch/concurrency.js +15 -0
  4. package/build/dispatch/idempotency.js +38 -0
  5. package/build/dispatch/index.js +175 -0
  6. package/build/dispatch/rate-limit.js +56 -0
  7. package/build/dispatch/retry.js +97 -0
  8. package/build/index.js +1 -1
  9. package/build/markdown/parse.js +265 -0
  10. package/build/operations/blocks.js +331 -0
  11. package/build/operations/comments.js +191 -0
  12. package/build/operations/data-sources.js +85 -0
  13. package/build/operations/databases.js +345 -0
  14. package/build/operations/files.js +239 -0
  15. package/build/operations/index.js +19 -0
  16. package/build/operations/pages.js +486 -0
  17. package/build/operations/registry.js +16 -0
  18. package/build/operations/users.js +101 -0
  19. package/build/prompts/index.js +105 -0
  20. package/build/schema/blocks.js +19 -138
  21. package/build/schema/database.js +27 -111
  22. package/build/schema/emit.js +68 -0
  23. package/build/schema/file.js +1 -1
  24. package/build/schema/filter-dsl.js +333 -0
  25. package/build/schema/icon.js +1 -1
  26. package/build/schema/page-properties.js +17 -3
  27. package/build/schema/page.js +12 -125
  28. package/build/schema/refs.js +16 -0
  29. package/build/schema/rich-text.js +1 -1
  30. package/build/server/index.js +16 -3
  31. package/build/services/auth.js +19 -0
  32. package/build/services/notion.js +14 -17
  33. package/build/tools/index.js +119 -21
  34. package/build/utils/error.js +125 -86
  35. package/build/utils/handler.js +11 -0
  36. package/build/utils/learning-error.js +40 -0
  37. package/build/utils/notion-types.js +16 -0
  38. package/build/utils/paginate.js +35 -0
  39. package/build/utils/schema-slice.js +156 -0
  40. package/build/utils/slim.js +269 -0
  41. package/package.json +13 -7
  42. package/build/resources/imageList.js +0 -62
  43. package/build/resources/index.js +0 -1
  44. package/build/resources/predictionList.js +0 -43
  45. package/build/resources/svgList.js +0 -69
  46. package/build/schema/comments.js +0 -60
  47. package/build/schema/notion.js +0 -57
  48. package/build/schema/richText.js +0 -757
  49. package/build/schema/tools.js +0 -17
  50. package/build/schema/users.js +0 -39
  51. package/build/services/loggs.js +0 -13
  52. package/build/services/replicate.js +0 -23
  53. package/build/tools/appendBlockChildren.js +0 -25
  54. package/build/tools/batchAppendBlockChildren.js +0 -33
  55. package/build/tools/batchDeleteBlocks.js +0 -32
  56. package/build/tools/batchMixedOperations.js +0 -58
  57. package/build/tools/batchUpdateBlocks.js +0 -33
  58. package/build/tools/blocks.js +0 -34
  59. package/build/tools/comments.js +0 -81
  60. package/build/tools/createDatabase.js +0 -18
  61. package/build/tools/createPage.js +0 -18
  62. package/build/tools/createPrediction.js +0 -28
  63. package/build/tools/database.js +0 -16
  64. package/build/tools/deleteBlock.js +0 -24
  65. package/build/tools/formatRichText.js +0 -83
  66. package/build/tools/generateImage.js +0 -48
  67. package/build/tools/generateImageVariants.js +0 -105
  68. package/build/tools/generateMultipleImages.js +0 -60
  69. package/build/tools/generateSVG.js +0 -43
  70. package/build/tools/getPrediction.js +0 -22
  71. package/build/tools/pages.js +0 -22
  72. package/build/tools/predictionList.js +0 -30
  73. package/build/tools/queryDatabase.js +0 -22
  74. package/build/tools/retrieveBlock.js +0 -24
  75. package/build/tools/retrieveBlockChildren.js +0 -32
  76. package/build/tools/searchPage.js +0 -24
  77. package/build/tools/updateBlock.js +0 -25
  78. package/build/tools/updateDatabase.js +0 -18
  79. package/build/tools/updatePage.js +0 -40
  80. package/build/tools/updatePageProperties.js +0 -21
  81. package/build/tools/users.js +0 -75
  82. package/build/types/blocks.js +0 -12
  83. package/build/types/comments.js +0 -7
  84. package/build/types/database.js +0 -6
  85. package/build/types/notion.js +0 -1
  86. package/build/types/page.js +0 -8
  87. package/build/types/richText.js +0 -1
  88. package/build/types/tools.js +0 -1
  89. package/build/types/users.js +0 -6
  90. package/build/utils/blob.js +0 -5
  91. package/build/utils/image.js +0 -34
  92. package/build/utils/index.js +0 -1
  93. package/build/utils/richText.js +0 -174
  94. package/build/validation/blocks.js +0 -568
  95. package/build/validation/notion.js +0 -51
  96. package/build/validation/page.js +0 -262
  97. package/build/validation/richText.js +0 -744
  98. package/build/validation/tools.js +0 -16
  99. /package/build/{types/index.js → operations/types.js} +0 -0
@@ -1,5 +1,7 @@
1
1
  // Configuration
2
2
  export const CONFIG = {
3
3
  serverName: "notion-mcp-server",
4
- serverVersion: "1.0.1",
4
+ serverTitle: "Notion",
5
+ serverVersion: "1.4.0",
6
+ serverUrl: "https://github.com/awkoy/notion-mcp-server",
5
7
  };
@@ -0,0 +1,15 @@
1
+ export async function mapWithConcurrency(items, limit, fn) {
2
+ const results = new Array(items.length);
3
+ let cursor = 0;
4
+ async function worker() {
5
+ while (true) {
6
+ const index = cursor++;
7
+ if (index >= items.length)
8
+ return;
9
+ results[index] = await fn(items[index], index);
10
+ }
11
+ }
12
+ const workerCount = Math.max(1, Math.min(limit, items.length));
13
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
14
+ return results;
15
+ }
@@ -0,0 +1,38 @@
1
+ const TTL_MS = 5 * 60 * 1000;
2
+ const MAX_ENTRIES = 512;
3
+ const cache = new Map();
4
+ function evictExpired(now) {
5
+ for (const [key, entry] of cache) {
6
+ if (entry.expiresAt <= now)
7
+ cache.delete(key);
8
+ }
9
+ if (cache.size > MAX_ENTRIES) {
10
+ const overflow = cache.size - MAX_ENTRIES;
11
+ let removed = 0;
12
+ for (const key of cache.keys()) {
13
+ if (removed >= overflow)
14
+ break;
15
+ cache.delete(key);
16
+ removed++;
17
+ }
18
+ }
19
+ }
20
+ export function lookup(key) {
21
+ const now = Date.now();
22
+ const entry = cache.get(key);
23
+ if (!entry)
24
+ return undefined;
25
+ if (entry.expiresAt <= now) {
26
+ cache.delete(key);
27
+ return undefined;
28
+ }
29
+ return entry.result;
30
+ }
31
+ export function store(key, result) {
32
+ const now = Date.now();
33
+ evictExpired(now);
34
+ cache.set(key, { result, expiresAt: now + TTL_MS });
35
+ }
36
+ export function buildKey(operation, idempotencyKey) {
37
+ return `${operation}::${idempotencyKey}`;
38
+ }
@@ -0,0 +1,175 @@
1
+ import { z } from "zod";
2
+ import { getOperation, operationNames } from "../operations/registry.js";
3
+ import { buildKey, lookup, store } from "./idempotency.js";
4
+ import { mapWithConcurrency } from "./concurrency.js";
5
+ import { rateLimiter } from "./rate-limit.js";
6
+ import { isRetryableErrorCode, withRetry } from "./retry.js";
7
+ import { buildValidationError } from "../utils/learning-error.js";
8
+ import { toErrorEnvelope } from "../utils/error.js";
9
+ const DEFAULT_CONCURRENCY = 3;
10
+ const MAX_CONCURRENCY = 10;
11
+ function isBatchPayload(payload) {
12
+ return (typeof payload === "object" &&
13
+ payload !== null &&
14
+ Array.isArray(payload.items));
15
+ }
16
+ function unknownOperationError(name) {
17
+ return {
18
+ code: "unknown_operation",
19
+ message: `Unknown operation: "${name}". Use notion_describe with a valid operation name, or check the notion://operations resource for the full list.`,
20
+ fix: `Available operations: ${operationNames().join(", ")}`,
21
+ };
22
+ }
23
+ export async function dispatch(operationName, payload) {
24
+ const def = getOperation(operationName);
25
+ if (!def) {
26
+ return { ok: false, error: unknownOperationError(operationName) };
27
+ }
28
+ if (isBatchPayload(payload)) {
29
+ if (!def.batchable) {
30
+ // batch_mixed_blocks looks batch-shaped but uses its own `operations[]`
31
+ // envelope (mixed op kinds, no per-item rollback). Point callers at the
32
+ // right shape instead of the generic not_batchable message.
33
+ if (operationName === "batch_mixed_blocks") {
34
+ return {
35
+ ok: false,
36
+ error: {
37
+ code: "wrong_envelope",
38
+ message: 'batch_mixed_blocks uses its own envelope: { operations: [{ op: "append"|"update"|"delete", ... }] }. The universal { items: [...] } envelope does not apply here.',
39
+ fix: 'Wrap your operations as { operations: [{ op: "append", block_id, markdown }, { op: "update", ... }, { op: "delete", ... }] }. Or use the items[] form on append_blocks / update_block / delete_block for single-kind batches.',
40
+ },
41
+ };
42
+ }
43
+ return {
44
+ ok: false,
45
+ error: {
46
+ code: "not_batchable",
47
+ message: `Operation "${operationName}" does not support batch mode.`,
48
+ fix: "Call it with a single payload object instead of { items: [...] }.",
49
+ },
50
+ };
51
+ }
52
+ return runBatch(def, payload);
53
+ }
54
+ return runSingle(def, payload);
55
+ }
56
+ // Run the handler under the shared rate limiter, retrying on transient SDK
57
+ // failures. Token is acquired inside withRetry so each retry attempt counts
58
+ // against the per-second budget instead of bursting on retry storms.
59
+ function runHandlerWithLimitAndRetry(def, params) {
60
+ return withRetry(async () => {
61
+ await rateLimiter.acquire();
62
+ return def.handler(params);
63
+ }, { isRetryableResult: (r) => r.ok === false && isRetryableErrorCode(r.error.code) });
64
+ }
65
+ async function runSingle(def, payload) {
66
+ const parsed = def.schema.safeParse(payload);
67
+ if (!parsed.success) {
68
+ return { ok: false, error: buildValidationError(def, parsed.error) };
69
+ }
70
+ try {
71
+ return await runHandlerWithLimitAndRetry(def, parsed.data);
72
+ }
73
+ catch (error) {
74
+ return { ok: false, error: toErrorEnvelope(error) };
75
+ }
76
+ }
77
+ async function runBatch(def, payload) {
78
+ const idempotencyKey = payload.idempotency_key;
79
+ if (idempotencyKey) {
80
+ const cached = lookup(buildKey(def.name, idempotencyKey));
81
+ if (cached)
82
+ return cached;
83
+ }
84
+ const atomic = payload.atomic === true;
85
+ // Atomic mode requires serial execution: with concurrency > 1, the `aborted`
86
+ // flag is set only after the first failure resolves, but other workers have
87
+ // already started in-flight requests, so later items execute when they
88
+ // shouldn't. Force concurrency=1 to make the abort barrier reliable.
89
+ const requested = payload.concurrency ?? DEFAULT_CONCURRENCY;
90
+ const concurrency = atomic ? 1 : Math.max(1, Math.min(requested, MAX_CONCURRENCY));
91
+ const items = payload.items;
92
+ const createdForRollback = [];
93
+ let aborted = false;
94
+ const results = await mapWithConcurrency(items, concurrency, async (item, index) => {
95
+ if (aborted) {
96
+ return {
97
+ index,
98
+ ok: false,
99
+ error: {
100
+ code: "aborted",
101
+ message: "Skipped: a prior item failed in atomic batch.",
102
+ },
103
+ };
104
+ }
105
+ const parsed = def.schema.safeParse(item);
106
+ if (!parsed.success) {
107
+ const failure = {
108
+ index,
109
+ ok: false,
110
+ error: buildValidationError(def, parsed.error),
111
+ };
112
+ if (atomic)
113
+ aborted = true;
114
+ return failure;
115
+ }
116
+ try {
117
+ const result = await runHandlerWithLimitAndRetry(def, parsed.data);
118
+ if (result.ok) {
119
+ const success = { index, ok: true, data: result.data };
120
+ if (atomic && def.rollback)
121
+ createdForRollback.push({ item: success });
122
+ return success;
123
+ }
124
+ const failure = {
125
+ index,
126
+ ok: false,
127
+ error: result.error,
128
+ };
129
+ if (atomic)
130
+ aborted = true;
131
+ return failure;
132
+ }
133
+ catch (error) {
134
+ const failure = {
135
+ index,
136
+ ok: false,
137
+ error: toErrorEnvelope(error),
138
+ };
139
+ if (atomic)
140
+ aborted = true;
141
+ return failure;
142
+ }
143
+ });
144
+ const succeeded = results.filter((r) => r.ok).length;
145
+ const failed = results.length - succeeded;
146
+ let rolledBack;
147
+ if (atomic && failed > 0 && def.rollback && createdForRollback.length > 0) {
148
+ rolledBack = 0;
149
+ for (const { item } of createdForRollback) {
150
+ if (!item.ok)
151
+ continue;
152
+ try {
153
+ await def.rollback(item.data);
154
+ rolledBack++;
155
+ }
156
+ catch {
157
+ // best-effort: swallow rollback errors so we still return the original failure
158
+ }
159
+ }
160
+ }
161
+ const batchResult = {
162
+ ok: failed === 0,
163
+ summary: { total: results.length, succeeded, failed },
164
+ results,
165
+ ...(rolledBack !== undefined ? { rolled_back: rolledBack } : {}),
166
+ };
167
+ if (idempotencyKey) {
168
+ store(buildKey(def.name, idempotencyKey), batchResult);
169
+ }
170
+ return batchResult;
171
+ }
172
+ export const BATCH_ENVELOPE_HELP = `Batch mode: pass { items: [...], atomic?: boolean, idempotency_key?: string, concurrency?: 1-10 }. Each item is validated independently; failures are reported per-item. atomic:true forces serial execution (concurrency=1) and triggers best-effort rollback of created entities on first failure; subsequent items are skipped with code:"aborted".`;
173
+ export const _internal = { isBatchPayload };
174
+ // Re-export Zod for downstream operation files to share a single version
175
+ export { z };
@@ -0,0 +1,56 @@
1
+ // Global limiter for Notion API calls. Notion enforces ~3 requests per second
2
+ // per integration; without a cap, dispatch's parallel execution (concurrency
3
+ // up to 10) hits 429s on larger batches. One shared instance means the limit
4
+ // holds across single calls, batch items, and concurrent dispatches.
5
+ //
6
+ // Implementation: a token bucket with capacity=1, equivalent to strict
7
+ // pacing — each acquire reserves the next slot at `1000 / rate` ms after the
8
+ // previous one. This guarantees the sliding-window invariant of "no more
9
+ // than `rate` calls in any 1-second window", whereas a bucket with capacity
10
+ // equal to rate would allow a 2*rate burst across the bucket-refill boundary.
11
+ //
12
+ // Rate is read from NOTION_RATE_LIMIT (requests/second). Default 3.
13
+ export const DEFAULT_RATE_PER_SECOND = 3;
14
+ export const RATE_LIMIT_ENV_VAR = "NOTION_RATE_LIMIT";
15
+ const MS_PER_SECOND = 1_000;
16
+ export class TokenBucket {
17
+ rate;
18
+ nextSlot = 0;
19
+ intervalMs;
20
+ constructor(rate) {
21
+ this.rate = rate;
22
+ if (!(rate > 0))
23
+ throw new Error(`TokenBucket rate must be > 0, got ${rate}`);
24
+ this.intervalMs = MS_PER_SECOND / rate;
25
+ }
26
+ acquire() {
27
+ const now = Date.now();
28
+ const slot = Math.max(now, this.nextSlot);
29
+ this.nextSlot = slot + this.intervalMs;
30
+ const delay = slot - now;
31
+ if (delay <= 0)
32
+ return Promise.resolve();
33
+ return new Promise((resolve) => setTimeout(resolve, delay));
34
+ }
35
+ }
36
+ function readRateFromEnv() {
37
+ const raw = process.env[RATE_LIMIT_ENV_VAR];
38
+ if (!raw)
39
+ return DEFAULT_RATE_PER_SECOND;
40
+ const n = Number(raw);
41
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_RATE_PER_SECOND;
42
+ }
43
+ let instance = new TokenBucket(readRateFromEnv());
44
+ export const rateLimiter = {
45
+ acquire() {
46
+ return instance.acquire();
47
+ },
48
+ };
49
+ // Replace the singleton bucket. Used by tests and by callers that need to
50
+ // re-read NOTION_RATE_LIMIT after the env var has been mutated.
51
+ export function configureRateLimiter(rate) {
52
+ instance = new TokenBucket(rate ?? readRateFromEnv());
53
+ }
54
+ export function getRateLimiterBucket() {
55
+ return instance;
56
+ }
@@ -0,0 +1,97 @@
1
+ import { APIErrorCode, APIResponseError, RequestTimeoutError, UnknownHTTPResponseError, } from "@notionhq/client";
2
+ import { isRetryableNotionCode } from "../utils/error.js";
3
+ const DEFAULT_ATTEMPTS = 5;
4
+ const DEFAULT_BASE_MS = 250;
5
+ const DEFAULT_MAX_MS = 8_000;
6
+ const SERVER_ERROR_STATUS_MIN = 500;
7
+ const RETRY_AFTER_HEADER = "retry-after";
8
+ const MS_PER_SECOND = 1_000;
9
+ // Re-export so dispatch and tests can classify codes against the same set
10
+ // without crossing modules twice.
11
+ export { isRetryableNotionCode as isRetryableErrorCode };
12
+ export function isRetryableSdkError(err) {
13
+ if (APIResponseError.isAPIResponseError(err)) {
14
+ return (err.code === APIErrorCode.RateLimited ||
15
+ err.status >= SERVER_ERROR_STATUS_MIN);
16
+ }
17
+ if (UnknownHTTPResponseError.isUnknownHTTPResponseError(err)) {
18
+ return err.status >= SERVER_ERROR_STATUS_MIN;
19
+ }
20
+ if (RequestTimeoutError.isRequestTimeoutError(err)) {
21
+ return true;
22
+ }
23
+ return false;
24
+ }
25
+ function parseRetryAfter(raw) {
26
+ if (!raw)
27
+ return null;
28
+ const seconds = Number(raw);
29
+ if (Number.isFinite(seconds))
30
+ return Math.max(0, seconds * MS_PER_SECOND);
31
+ const dateMs = Date.parse(raw);
32
+ if (!Number.isNaN(dateMs))
33
+ return Math.max(0, dateMs - Date.now());
34
+ return null;
35
+ }
36
+ // The SDK types `headers` as `unknown` (see fetch-types.d.ts). At runtime it's
37
+ // either a global `Headers` instance (native fetch) or a plain
38
+ // header-name → value record (legacy / test stubs). Handle both without
39
+ // assertions by feature-detecting.
40
+ function readRetryAfterHeader(headers) {
41
+ if (typeof Headers !== "undefined" && headers instanceof Headers) {
42
+ return headers.get(RETRY_AFTER_HEADER);
43
+ }
44
+ if (headers === null || typeof headers !== "object")
45
+ return null;
46
+ for (const [key, value] of Object.entries(headers)) {
47
+ if (key.toLowerCase() !== RETRY_AFTER_HEADER)
48
+ continue;
49
+ const first = Array.isArray(value) ? value[0] : value;
50
+ return typeof first === "string" ? first : null;
51
+ }
52
+ return null;
53
+ }
54
+ function readRetryAfterMs(err) {
55
+ if (!APIResponseError.isAPIResponseError(err) &&
56
+ !UnknownHTTPResponseError.isUnknownHTTPResponseError(err)) {
57
+ return null;
58
+ }
59
+ return parseRetryAfter(readRetryAfterHeader(err.headers));
60
+ }
61
+ function computeBackoff(baseMs, maxMs, attempt, retryAfterMs) {
62
+ if (retryAfterMs !== null)
63
+ return Math.min(retryAfterMs, maxMs);
64
+ const ceiling = Math.min(maxMs, baseMs * 2 ** attempt);
65
+ return Math.random() * ceiling;
66
+ }
67
+ function sleep(ms) {
68
+ return new Promise((resolve) => setTimeout(resolve, ms));
69
+ }
70
+ export async function withRetry(fn, opts = {}) {
71
+ const attempts = opts.attempts ?? DEFAULT_ATTEMPTS;
72
+ const baseMs = opts.baseMs ?? DEFAULT_BASE_MS;
73
+ const maxMs = opts.maxMs ?? DEFAULT_MAX_MS;
74
+ const shouldRetry = opts.shouldRetry ?? isRetryableSdkError;
75
+ const { isRetryableResult } = opts;
76
+ let lastErr;
77
+ for (let attempt = 0; attempt < attempts; attempt++) {
78
+ try {
79
+ const result = await fn();
80
+ if (isRetryableResult && isRetryableResult(result)) {
81
+ if (attempt === attempts - 1)
82
+ return result;
83
+ await sleep(computeBackoff(baseMs, maxMs, attempt, null));
84
+ continue;
85
+ }
86
+ return result;
87
+ }
88
+ catch (err) {
89
+ lastErr = err;
90
+ if (attempt === attempts - 1 || !shouldRetry(err))
91
+ throw err;
92
+ await sleep(computeBackoff(baseMs, maxMs, attempt, readRetryAfterMs(err)));
93
+ }
94
+ }
95
+ // Unreachable: the loop either returns or throws on the final attempt.
96
+ throw lastErr;
97
+ }
package/build/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { startServer } from "./server/index.js";
3
3
  import { registerAllTools } from "./tools/index.js";
4
- registerAllTools();
5
4
  async function main() {
6
5
  try {
6
+ await registerAllTools();
7
7
  await startServer();
8
8
  }
9
9
  catch (error) {
@@ -0,0 +1,265 @@
1
+ import { unified } from "unified";
2
+ import remarkParse from "remark-parse";
3
+ import remarkGfm from "remark-gfm";
4
+ const processor = unified().use(remarkParse).use(remarkGfm);
5
+ export function parseMarkdownToBlocks(markdown) {
6
+ if (!markdown.trim())
7
+ return [];
8
+ const tree = processor.parse(markdown);
9
+ return tree.children.flatMap(convertBlock);
10
+ }
11
+ function convertBlock(node) {
12
+ switch (node.type) {
13
+ case "paragraph":
14
+ return [paragraphFrom(node)];
15
+ case "heading":
16
+ return [headingFrom(node)];
17
+ case "list":
18
+ return convertList(node);
19
+ case "blockquote":
20
+ return convertBlockquote(node);
21
+ case "code":
22
+ return [codeFrom(node)];
23
+ case "thematicBreak":
24
+ return [{ type: "divider", divider: {} }];
25
+ default:
26
+ return [];
27
+ }
28
+ }
29
+ function paragraphFrom(node) {
30
+ if (node.children.length === 1 && node.children[0].type === "image") {
31
+ return imageFrom(node.children[0]);
32
+ }
33
+ const inlineImage = node.children.find((c) => c.type === "image");
34
+ if (inlineImage && node.children.length === 1) {
35
+ return imageFrom(inlineImage);
36
+ }
37
+ return {
38
+ type: "paragraph",
39
+ paragraph: { rich_text: phrasingToRichText(node.children) },
40
+ };
41
+ }
42
+ function headingFrom(node) {
43
+ const level = Math.min(4, node.depth);
44
+ const key = `heading_${level}`;
45
+ return {
46
+ type: key,
47
+ [key]: { rich_text: phrasingToRichText(node.children) },
48
+ };
49
+ }
50
+ function convertList(node) {
51
+ const isOrdered = node.ordered === true;
52
+ const type = isOrdered ? "numbered_list_item" : "bulleted_list_item";
53
+ return node.children.map((item) => listItemBlock(item, type));
54
+ }
55
+ function listItemBlock(item, defaultType) {
56
+ const isToDo = typeof item.checked === "boolean";
57
+ const firstParaIdx = item.children.findIndex((c) => c.type === "paragraph");
58
+ const head = firstParaIdx >= 0
59
+ ? item.children[firstParaIdx]
60
+ : { type: "paragraph", children: [] };
61
+ const richText = phrasingToRichText(head.children);
62
+ const tail = item.children.filter((_, i) => i !== firstParaIdx);
63
+ const children = tail.flatMap(convertBlock);
64
+ if (isToDo) {
65
+ return {
66
+ type: "to_do",
67
+ to_do: {
68
+ rich_text: richText,
69
+ checked: item.checked === true,
70
+ ...(children.length ? { children } : {}),
71
+ },
72
+ };
73
+ }
74
+ return {
75
+ type: defaultType,
76
+ [defaultType]: {
77
+ rich_text: richText,
78
+ ...(children.length ? { children } : {}),
79
+ },
80
+ };
81
+ }
82
+ function convertBlockquote(node) {
83
+ const children = node.children.flatMap(convertBlock);
84
+ if (children.length === 0)
85
+ return [];
86
+ const [first, ...rest] = children;
87
+ if (first.type === "paragraph") {
88
+ const paragraph = first.paragraph;
89
+ return [
90
+ {
91
+ type: "quote",
92
+ quote: {
93
+ rich_text: paragraph.rich_text,
94
+ ...(rest.length ? { children: rest } : {}),
95
+ },
96
+ },
97
+ ];
98
+ }
99
+ return [
100
+ {
101
+ type: "quote",
102
+ quote: { rich_text: [], children },
103
+ },
104
+ ];
105
+ }
106
+ function codeFrom(node) {
107
+ return {
108
+ type: "code",
109
+ code: {
110
+ rich_text: [
111
+ {
112
+ type: "text",
113
+ text: { content: node.value },
114
+ },
115
+ ],
116
+ language: normalizeLanguage(node.lang),
117
+ },
118
+ };
119
+ }
120
+ function imageFrom(node) {
121
+ return {
122
+ type: "image",
123
+ image: {
124
+ type: "external",
125
+ external: { url: node.url },
126
+ ...(node.alt
127
+ ? {
128
+ caption: [
129
+ { type: "text", text: { content: node.alt } },
130
+ ],
131
+ }
132
+ : {}),
133
+ },
134
+ };
135
+ }
136
+ function phrasingToRichText(nodes, annotations = {}) {
137
+ const out = [];
138
+ for (const node of nodes) {
139
+ pushPhrasing(node, annotations, out);
140
+ }
141
+ return mergeAdjacent(out);
142
+ }
143
+ function pushPhrasing(node, annotations, out) {
144
+ switch (node.type) {
145
+ case "text":
146
+ out.push(textRT(node.value, annotations));
147
+ return;
148
+ case "strong":
149
+ for (const c of node.children) {
150
+ pushPhrasing(c, { ...annotations, bold: true }, out);
151
+ }
152
+ return;
153
+ case "emphasis":
154
+ for (const c of node.children) {
155
+ pushPhrasing(c, { ...annotations, italic: true }, out);
156
+ }
157
+ return;
158
+ case "delete":
159
+ for (const c of node.children) {
160
+ pushPhrasing(c, { ...annotations, strikethrough: true }, out);
161
+ }
162
+ return;
163
+ case "inlineCode":
164
+ out.push(textRT(node.value, { ...annotations, code: true }));
165
+ return;
166
+ case "link": {
167
+ const link = node;
168
+ const inner = [];
169
+ for (const c of link.children)
170
+ pushPhrasing(c, annotations, inner);
171
+ const merged = mergeAdjacent(inner);
172
+ for (const r of merged) {
173
+ if (r.type === "text")
174
+ r.text.link = { url: link.url };
175
+ out.push(r);
176
+ }
177
+ return;
178
+ }
179
+ case "break":
180
+ out.push(textRT("\n", annotations));
181
+ return;
182
+ default:
183
+ // Unhandled inline (image inside paragraph already promoted; html, footnote, etc.)
184
+ return;
185
+ }
186
+ }
187
+ function textRT(content, annotations) {
188
+ const cleaned = clean(annotations);
189
+ return {
190
+ type: "text",
191
+ text: { content },
192
+ ...(Object.keys(cleaned).length ? { annotations: cleaned } : {}),
193
+ };
194
+ }
195
+ function clean(a) {
196
+ const out = {};
197
+ if (a.bold)
198
+ out.bold = true;
199
+ if (a.italic)
200
+ out.italic = true;
201
+ if (a.strikethrough)
202
+ out.strikethrough = true;
203
+ if (a.underline)
204
+ out.underline = true;
205
+ if (a.code)
206
+ out.code = true;
207
+ return out;
208
+ }
209
+ function mergeAdjacent(items) {
210
+ const out = [];
211
+ for (const item of items) {
212
+ const prev = out[out.length - 1];
213
+ if (prev &&
214
+ prev.type === "text" &&
215
+ item.type === "text" &&
216
+ sameAnnotations(prev.annotations, item.annotations) &&
217
+ sameLink(prev.text.link, item.text.link)) {
218
+ prev.text.content += item.text.content;
219
+ continue;
220
+ }
221
+ out.push(item);
222
+ }
223
+ return out;
224
+ }
225
+ function sameAnnotations(a, b) {
226
+ return JSON.stringify(a ?? {}) === JSON.stringify(b ?? {});
227
+ }
228
+ function sameLink(a, b) {
229
+ return (a?.url ?? null) === (b?.url ?? null);
230
+ }
231
+ const KNOWN_LANGUAGES = new Set([
232
+ "abap", "arduino", "bash", "basic", "c", "clojure", "coffeescript", "c++",
233
+ "c#", "css", "dart", "diff", "docker", "elixir", "elm", "erlang", "flow",
234
+ "fortran", "f#", "gherkin", "glsl", "go", "graphql", "groovy", "haskell",
235
+ "html", "java", "javascript", "json", "julia", "kotlin", "latex", "less",
236
+ "lisp", "livescript", "lua", "makefile", "markdown", "markup", "matlab",
237
+ "mermaid", "nix", "objective-c", "ocaml", "pascal", "perl", "php", "plain text",
238
+ "powershell", "prolog", "protobuf", "python", "r", "reason", "ruby", "rust",
239
+ "sass", "scala", "scheme", "scss", "shell", "sql", "swift", "typescript",
240
+ "vb.net", "verilog", "vhdl", "visual basic", "webassembly", "xml", "yaml",
241
+ ]);
242
+ function normalizeLanguage(lang) {
243
+ if (!lang)
244
+ return "plain text";
245
+ const lower = lang.toLowerCase();
246
+ if (KNOWN_LANGUAGES.has(lower))
247
+ return lower;
248
+ const aliases = {
249
+ js: "javascript",
250
+ ts: "typescript",
251
+ py: "python",
252
+ rb: "ruby",
253
+ sh: "shell",
254
+ yml: "yaml",
255
+ md: "markdown",
256
+ "c++": "c++",
257
+ cpp: "c++",
258
+ cs: "c#",
259
+ "objc": "objective-c",
260
+ rs: "rust",
261
+ tsx: "typescript",
262
+ jsx: "javascript",
263
+ };
264
+ return aliases[lower] ?? "plain text";
265
+ }