@firedrill-tools/notion 0.1.1
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/LICENSE +201 -0
- package/README.md +402 -0
- package/firedrill/agent.target.json +17 -0
- package/firedrill/baseline.scenario.json +5 -0
- package/firedrill/bounded.scenario.json +19 -0
- package/firedrill/conformance.suite.json +23 -0
- package/firedrill/notion-bounded.drill.json +318 -0
- package/firedrill/notion-byte-budget.drill.json +116 -0
- package/firedrill/notion-mcp-aliases.drill.json +150 -0
- package/firedrill/notion-page-authoring.drill.json +254 -0
- package/firedrill/notion-rate-limited.drill.json +118 -0
- package/firedrill/notion-schema-growth.drill.json +88 -0
- package/firedrill/notion-scope-agent-only.drill.json +131 -0
- package/firedrill/notion-scope-auditor.drill.json +86 -0
- package/firedrill/notion-scope-board-bot.drill.json +128 -0
- package/firedrill/notion-scope-notes-bot.drill.json +303 -0
- package/firedrill/notion-scope-stranger.drill.json +773 -0
- package/firedrill/notion-task-triage.drill.json +277 -0
- package/firedrill/notion-trash-and-restore.drill.json +186 -0
- package/firedrill/notion-update-lost.drill.json +88 -0
- package/firedrill/notion-workspace-read.drill.json +258 -0
- package/firedrill/notion-write-unavailable.drill.json +161 -0
- package/firedrill/rate-limited.scenario.json +11 -0
- package/firedrill/tools/notion/app/assets/ATTRIBUTION.md +35 -0
- package/firedrill/tools/notion/app/assets/fonts/OFL.txt +93 -0
- package/firedrill/tools/notion/app/assets/fonts/inter-latin.woff2 +0 -0
- package/firedrill/tools/notion/app/assets/notion-wordmark.svg +1 -0
- package/firedrill/tools/notion/app/assets/notion.svg +1 -0
- package/firedrill/tools/notion/app/site/app.js +797 -0
- package/firedrill/tools/notion/app/site/assets/fonts/inter-latin.woff2 +0 -0
- package/firedrill/tools/notion/app/site/assets/notion-wordmark.svg +1 -0
- package/firedrill/tools/notion/app/site/assets/notion.svg +1 -0
- package/firedrill/tools/notion/app/site/chrome.js +104 -0
- package/firedrill/tools/notion/app/site/cover-picker.js +83 -0
- package/firedrill/tools/notion/app/site/database.js +648 -0
- package/firedrill/tools/notion/app/site/editors.js +320 -0
- package/firedrill/tools/notion/app/site/format-bar.js +97 -0
- package/firedrill/tools/notion/app/site/icons.js +131 -0
- package/firedrill/tools/notion/app/site/index.html +125 -0
- package/firedrill/tools/notion/app/site/page.js +826 -0
- package/firedrill/tools/notion/app/site/rich.js +159 -0
- package/firedrill/tools/notion/app/site/state.js +170 -0
- package/firedrill/tools/notion/app/site/styles.css +826 -0
- package/firedrill/tools/notion/app/site/ui.js +418 -0
- package/firedrill/tools/notion/behavior.mjs +1123 -0
- package/firedrill/tools/notion/lib/blocks.mjs +371 -0
- package/firedrill/tools/notion/lib/identity.mjs +123 -0
- package/firedrill/tools/notion/lib/ids.mjs +63 -0
- package/firedrill/tools/notion/lib/json-depth.mjs +26 -0
- package/firedrill/tools/notion/lib/markdown.mjs +381 -0
- package/firedrill/tools/notion/lib/properties.mjs +513 -0
- package/firedrill/tools/notion/lib/query.mjs +272 -0
- package/firedrill/tools/notion/lib/render.mjs +137 -0
- package/firedrill/tools/notion/lib/rich-text.mjs +134 -0
- package/firedrill/tools/notion/lib/size.mjs +44 -0
- package/firedrill/tools/notion/lib/state.mjs +192 -0
- package/firedrill/tools/notion/lib/wire.mjs +89 -0
- package/firedrill/tools/notion/notion.tool.json +9837 -0
- package/firedrill/update-lost.scenario.json +11 -0
- package/firedrill/world.json +7039 -0
- package/firedrill/write-unavailable.scenario.json +11 -0
- package/firedrill.json +5 -0
- package/package.json +63 -0
- package/starter.json +6482 -0
- package/test/conformance.mjs +1186 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// Deterministic markdown dialect for the page-markdown endpoints: render a block tree to markdown and
|
|
2
|
+
// parse markdown back into block inputs. The dialect is documented in the README; it is a subset, not
|
|
3
|
+
// Notion's proprietary enhanced markdown.
|
|
4
|
+
import { normalizeId } from "./ids.mjs";
|
|
5
|
+
import { defaultAnnotations, textItem } from "./rich-text.mjs";
|
|
6
|
+
import { MAX_DEPTH, MAX_TREE_LEVELS, nestingError } from "./blocks.mjs";
|
|
7
|
+
import { utf8Length } from "./size.mjs";
|
|
8
|
+
import { validationError } from "./state.mjs";
|
|
9
|
+
|
|
10
|
+
const LIST_TYPES = ["bulleted_list_item", "numbered_list_item", "to_do"];
|
|
11
|
+
/** Notion's request limit of 1000 block elements per payload, applied to every parsed markdown document. */
|
|
12
|
+
export const MAX_MARKDOWN_BLOCKS = 1000;
|
|
13
|
+
const EMOJI = /^\p{Extended_Pictographic}/u;
|
|
14
|
+
const UNKNOWN_TAG = /^<unknown\s+type="([a-z_]+)"\s+id="([0-9a-f-]{36})"(?:\s+url="([^"]*)")?\s*\/>$/;
|
|
15
|
+
const CHILD_LINK = /^\[(📄|🗃️) (.*)\]\(notion:\/\/(page|database)\/([0-9a-f-]{36})\)$/u;
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------------------------
|
|
18
|
+
// Rendering
|
|
19
|
+
// ---------------------------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
function renderInline(items) {
|
|
22
|
+
let out = "";
|
|
23
|
+
for (const item of items) {
|
|
24
|
+
if (item.type === "mention") {
|
|
25
|
+
const mention = item.mention;
|
|
26
|
+
if (mention.type === "user") out += `[${item.plain_text}](notion://user/${mention.user.id})`;
|
|
27
|
+
else if (mention.type === "page") out += `[${item.plain_text}](notion://page/${mention.page.id})`;
|
|
28
|
+
else out += item.plain_text;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
let text = item.text.content;
|
|
32
|
+
const a = item.annotations;
|
|
33
|
+
if (a.code) text = `\`${text}\``;
|
|
34
|
+
if (a.bold) text = `**${text}**`;
|
|
35
|
+
if (a.italic) text = `*${text}*`;
|
|
36
|
+
if (a.strikethrough) text = `~~${text}~~`;
|
|
37
|
+
if (item.text.link !== null) text = `[${text}](${item.text.link.url})`;
|
|
38
|
+
out += text;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function indent(text, depth) {
|
|
44
|
+
if (depth === 0) return text;
|
|
45
|
+
const pad = " ".repeat(depth);
|
|
46
|
+
return text
|
|
47
|
+
.split("\n")
|
|
48
|
+
.map((line) => (line.length === 0 ? line : pad + line))
|
|
49
|
+
.join("\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function renderNode(context, node, depth, counters, unknown) {
|
|
53
|
+
const { block, children } = node;
|
|
54
|
+
const content = block.content;
|
|
55
|
+
const withChildren = (own) => {
|
|
56
|
+
if (children.length === 0) return own;
|
|
57
|
+
const rendered = renderChildren(context, children, depth + 1, unknown);
|
|
58
|
+
return `${own}\n${rendered}`;
|
|
59
|
+
};
|
|
60
|
+
switch (block.type) {
|
|
61
|
+
case "paragraph":
|
|
62
|
+
return withChildren(indent(renderInline(content.rich_text), depth));
|
|
63
|
+
case "heading_1":
|
|
64
|
+
return indent(`# ${renderInline(content.rich_text)}`, depth);
|
|
65
|
+
case "heading_2":
|
|
66
|
+
return indent(`## ${renderInline(content.rich_text)}`, depth);
|
|
67
|
+
case "heading_3":
|
|
68
|
+
return indent(`### ${renderInline(content.rich_text)}`, depth);
|
|
69
|
+
case "bulleted_list_item":
|
|
70
|
+
return withChildren(indent(`- ${renderInline(content.rich_text)}`, depth));
|
|
71
|
+
case "numbered_list_item": {
|
|
72
|
+
const number = (counters.numbered ?? 0) + 1;
|
|
73
|
+
counters.numbered = number;
|
|
74
|
+
return withChildren(indent(`${number}. ${renderInline(content.rich_text)}`, depth));
|
|
75
|
+
}
|
|
76
|
+
case "to_do":
|
|
77
|
+
return withChildren(indent(`- [${content.checked ? "x" : " "}] ${renderInline(content.rich_text)}`, depth));
|
|
78
|
+
case "toggle": {
|
|
79
|
+
const inner = children.length === 0 ? "" : `\n\n${renderChildren(context, children, 0, unknown)}\n`;
|
|
80
|
+
return indent(`<details>\n<summary>${renderInline(content.rich_text)}</summary>${inner}\n</details>`, depth);
|
|
81
|
+
}
|
|
82
|
+
case "quote":
|
|
83
|
+
return withChildren(indent(renderInline(content.rich_text).split("\n").map((line) => `> ${line}`).join("\n"), depth));
|
|
84
|
+
case "callout": {
|
|
85
|
+
const icon = content.icon === null ? "" : `${content.icon.emoji} `;
|
|
86
|
+
return withChildren(indent(`> ${icon}${renderInline(content.rich_text)}`, depth));
|
|
87
|
+
}
|
|
88
|
+
case "code": {
|
|
89
|
+
const code = content.rich_text.map((item) => item.plain_text).join("");
|
|
90
|
+
return indent(`\`\`\`${content.language}\n${code}\n\`\`\``, depth);
|
|
91
|
+
}
|
|
92
|
+
case "divider":
|
|
93
|
+
return indent("---", depth);
|
|
94
|
+
case "child_page": {
|
|
95
|
+
const page = context.state.get("pages", block.id);
|
|
96
|
+
return indent(`[📄 ${page === null ? "" : page.title_plain}](notion://page/${block.id})`, depth);
|
|
97
|
+
}
|
|
98
|
+
case "child_database": {
|
|
99
|
+
const database = context.state.get("databases", block.id);
|
|
100
|
+
return indent(`[🗃️ ${database === null ? "" : database.title_plain}](notion://database/${block.id})`, depth);
|
|
101
|
+
}
|
|
102
|
+
default: {
|
|
103
|
+
unknown.push(block.id);
|
|
104
|
+
const url = block.type === "bookmark" ? content.url : block.type === "image" ? content.external.url : undefined;
|
|
105
|
+
return indent(`<unknown type="${block.type}" id="${block.id}"${url === undefined ? "" : ` url="${url.replaceAll('"', "%22")}"`}/>`, depth);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Blocks separated by blank lines, except consecutive list items which stay adjacent. */
|
|
111
|
+
function renderChildren(context, nodes, depth, unknown) {
|
|
112
|
+
const counters = { numbered: 0 };
|
|
113
|
+
const parts = [];
|
|
114
|
+
let previousType;
|
|
115
|
+
for (const node of nodes) {
|
|
116
|
+
const type = node.block.type;
|
|
117
|
+
if (type !== "numbered_list_item") counters.numbered = 0;
|
|
118
|
+
const separator = parts.length === 0 ? "" : LIST_TYPES.includes(type) && type === previousType ? "\n" : "\n\n";
|
|
119
|
+
parts.push(separator + renderNode(context, node, depth, counters, unknown));
|
|
120
|
+
previousType = type;
|
|
121
|
+
}
|
|
122
|
+
return parts.join("");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function renderMarkdown(context, tree) {
|
|
126
|
+
const unknown = [];
|
|
127
|
+
const markdown = renderChildren(context, tree, 0, unknown);
|
|
128
|
+
return { markdown, unknown_block_ids: unknown };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------------------------
|
|
132
|
+
// Parsing
|
|
133
|
+
// ---------------------------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
/** End of the `[^)\s]*` run starting at `from` (the next `)` or whitespace, or the text length). */
|
|
136
|
+
const URL_RUN = /[^)\s]*/y;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Inline markup in one left-to-right pass. Every forward search result (the next `` ` ``, `]`, and `)`/whitespace)
|
|
140
|
+
* is cached and only recomputed once the cursor passes it, so each character is scanned a bounded number of times:
|
|
141
|
+
* `[` or `[a](` repeated 100 000 times is linear, never a per-position rescan of the tail.
|
|
142
|
+
*/
|
|
143
|
+
function parseInline(text) {
|
|
144
|
+
const items = [];
|
|
145
|
+
const state = { bold: false, italic: false, strikethrough: false };
|
|
146
|
+
let start = 0;
|
|
147
|
+
const flush = (end) => {
|
|
148
|
+
if (end > start) items.push(textItem(text.slice(start, end), { annotations: { ...defaultAnnotations(), ...state } }));
|
|
149
|
+
};
|
|
150
|
+
let nextBacktick = -2; // -2: not searched yet; -1: none left
|
|
151
|
+
let nextClose = -2;
|
|
152
|
+
let stopFrom = -1;
|
|
153
|
+
let stopAt = -1;
|
|
154
|
+
const urlEnd = (from) => {
|
|
155
|
+
if (from > stopAt || stopFrom < 0 || from < stopFrom) {
|
|
156
|
+
URL_RUN.lastIndex = from;
|
|
157
|
+
URL_RUN.exec(text);
|
|
158
|
+
stopFrom = from;
|
|
159
|
+
stopAt = URL_RUN.lastIndex;
|
|
160
|
+
}
|
|
161
|
+
return stopAt;
|
|
162
|
+
};
|
|
163
|
+
let index = 0;
|
|
164
|
+
while (index < text.length) {
|
|
165
|
+
const char = text.charCodeAt(index);
|
|
166
|
+
if (char === 0x60 /* ` */) {
|
|
167
|
+
if (nextBacktick !== -1 && nextBacktick <= index) nextBacktick = text.indexOf("`", index + 1);
|
|
168
|
+
if (nextBacktick > index) {
|
|
169
|
+
flush(index);
|
|
170
|
+
items.push(textItem(text.slice(index + 1, nextBacktick), { annotations: { ...defaultAnnotations(), ...state, code: true } }));
|
|
171
|
+
index = nextBacktick + 1;
|
|
172
|
+
start = index;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
} else if (char === 0x5b /* [ */) {
|
|
176
|
+
if (nextClose !== -1 && nextClose <= index) nextClose = text.indexOf("]", index + 1);
|
|
177
|
+
if (nextClose > index && text.charCodeAt(nextClose + 1) === 0x28 /* ( */) {
|
|
178
|
+
const urlStart = nextClose + 2;
|
|
179
|
+
const end = urlEnd(urlStart);
|
|
180
|
+
if (end > urlStart && text.charCodeAt(end) === 0x29 /* ) */) {
|
|
181
|
+
flush(index);
|
|
182
|
+
const label = text.slice(index + 1, nextClose);
|
|
183
|
+
const url = text.slice(urlStart, end);
|
|
184
|
+
const userId = url.startsWith("notion://user/") ? normalizeId(url.slice("notion://user/".length)) : undefined;
|
|
185
|
+
const pageId = url.startsWith("notion://page/") ? normalizeId(url.slice("notion://page/".length)) : undefined;
|
|
186
|
+
if (userId !== undefined) items.push({ kind: "mention", mention: { type: "user", user: { object: "user", id: userId } } });
|
|
187
|
+
else if (pageId !== undefined) items.push({ kind: "mention", mention: { type: "page", page: { id: pageId } } });
|
|
188
|
+
else items.push(textItem(label, { link: { url }, annotations: { ...defaultAnnotations(), ...state } }));
|
|
189
|
+
index = end + 1;
|
|
190
|
+
start = index;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} else if (char === 0x2a /* * */) {
|
|
195
|
+
flush(index);
|
|
196
|
+
if (text.charCodeAt(index + 1) === 0x2a) { state.bold = !state.bold; index += 2; } else { state.italic = !state.italic; index += 1; }
|
|
197
|
+
start = index;
|
|
198
|
+
continue;
|
|
199
|
+
} else if (char === 0x7e /* ~ */ && text.charCodeAt(index + 1) === 0x7e) {
|
|
200
|
+
flush(index);
|
|
201
|
+
state.strikethrough = !state.strikethrough;
|
|
202
|
+
index += 2;
|
|
203
|
+
start = index;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
index += 1;
|
|
207
|
+
}
|
|
208
|
+
flush(text.length);
|
|
209
|
+
return items;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function lineIndent(line) {
|
|
213
|
+
const spaces = line.length - line.trimStart().length;
|
|
214
|
+
return Math.floor(spaces / 2);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Parse markdown lines into a list of block inputs `{ type, content, children }` (rich text left as the
|
|
219
|
+
* request form `{ text: {content} }` / `{ mention }` so the normal validators run) or `{ keep: id }`
|
|
220
|
+
* references to existing child pages, databases and unknown blocks.
|
|
221
|
+
*
|
|
222
|
+
* At most MAX_MARKDOWN_BLOCKS block elements are produced (counted as they are found, `limits.blocks`), so the
|
|
223
|
+
* writes and the update_content alignment that follow are bounded too.
|
|
224
|
+
*
|
|
225
|
+
* `limits.depth` is the depth of the blocks produced here (0 = top level) and `limits.path` the request path of
|
|
226
|
+
* the list they belong to. A container whose children would exceed `limits.maxDepth` fails with the declared
|
|
227
|
+
* validation error as soon as its first child line is seen — before inner lines are copied or the parser recurses —
|
|
228
|
+
* so `<details>` or indented lists repeated thousands of times cost one bounded pass, never a stack overflow.
|
|
229
|
+
*/
|
|
230
|
+
function parseLines(context, lines, limits) {
|
|
231
|
+
const { depth, maxDepth, path } = limits;
|
|
232
|
+
const blocks = [];
|
|
233
|
+
let index = 0;
|
|
234
|
+
const itemPath = () => (depth === 0 ? path : `${path}[${blocks.length}]`);
|
|
235
|
+
const tooDeep = (currentPath) => {
|
|
236
|
+
if (maxDepth === MAX_DEPTH) return nestingError(context, currentPath);
|
|
237
|
+
return validationError(context, `body failed validation: the markdown nests blocks deeper than ${MAX_TREE_LEVELS} levels.`);
|
|
238
|
+
};
|
|
239
|
+
const childLimits = (currentPath) => ({ depth: depth + 1, maxDepth, path: `${currentPath}.children`, blocks: limits.blocks });
|
|
240
|
+
const count = () => {
|
|
241
|
+
limits.blocks.count += 1;
|
|
242
|
+
if (limits.blocks.count > MAX_MARKDOWN_BLOCKS) {
|
|
243
|
+
return validationError(context, `body failed validation: the markdown content should contain ≤ ${MAX_MARKDOWN_BLOCKS} block elements, instead had more.`);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
const push = (type, richTextSource, extra = {}) => {
|
|
247
|
+
count();
|
|
248
|
+
const block = { type, [type]: { ...extra, ...(richTextSource === undefined ? {} : { rich_text: richTextSource }) }, children: [] };
|
|
249
|
+
blocks.push(block);
|
|
250
|
+
return block;
|
|
251
|
+
};
|
|
252
|
+
while (index < lines.length) {
|
|
253
|
+
const raw = lines[index];
|
|
254
|
+
const line = raw.trimEnd();
|
|
255
|
+
const trimmed = line.trim();
|
|
256
|
+
if (trimmed.length === 0) { index += 1; continue; }
|
|
257
|
+
if (trimmed.startsWith("```")) {
|
|
258
|
+
const language = trimmed.slice(3).trim() || "plain text";
|
|
259
|
+
const code = [];
|
|
260
|
+
index += 1;
|
|
261
|
+
while (index < lines.length && !lines[index].trim().startsWith("```")) { code.push(lines[index]); index += 1; }
|
|
262
|
+
index += 1;
|
|
263
|
+
push("code", [{ text: { content: code.join("\n") } }], { language });
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (trimmed === "<details>") {
|
|
267
|
+
const currentPath = itemPath();
|
|
268
|
+
let open = 1;
|
|
269
|
+
const inner = [];
|
|
270
|
+
index += 1;
|
|
271
|
+
let summary = "";
|
|
272
|
+
if (index < lines.length && lines[index].trim().startsWith("<summary>")) {
|
|
273
|
+
summary = lines[index].trim().replace(/^<summary>/, "").replace(/<\/summary>$/, "");
|
|
274
|
+
index += 1;
|
|
275
|
+
}
|
|
276
|
+
const atLimit = depth + 1 > maxDepth;
|
|
277
|
+
while (index < lines.length && open > 0) {
|
|
278
|
+
const current = lines[index].trim();
|
|
279
|
+
if (current === "<details>") open += 1;
|
|
280
|
+
if (current === "</details>") { open -= 1; if (open === 0) { index += 1; break; } }
|
|
281
|
+
// Every non-blank inner line produces at least one child block.
|
|
282
|
+
if (atLimit && current.length > 0) return tooDeep(currentPath);
|
|
283
|
+
inner.push(lines[index]);
|
|
284
|
+
index += 1;
|
|
285
|
+
}
|
|
286
|
+
const toggle = push("toggle", parseInline(summary));
|
|
287
|
+
toggle.children = parseLines(context, inner, childLimits(currentPath));
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const unknownMatch = UNKNOWN_TAG.exec(trimmed);
|
|
291
|
+
if (unknownMatch !== null) { count(); blocks.push({ keep: unknownMatch[2] }); index += 1; continue; }
|
|
292
|
+
const childMatch = CHILD_LINK.exec(trimmed);
|
|
293
|
+
if (childMatch !== null) { count(); blocks.push({ keep: childMatch[4] }); index += 1; continue; }
|
|
294
|
+
if (trimmed === "---" || trimmed === "***") { push("divider"); index += 1; continue; }
|
|
295
|
+
const heading = /^(#{1,3}) (.*)$/.exec(trimmed);
|
|
296
|
+
if (heading !== null) { push(`heading_${heading[1].length}`, parseInline(heading[2])); index += 1; continue; }
|
|
297
|
+
if (trimmed.startsWith(">")) {
|
|
298
|
+
const quoteLines = [];
|
|
299
|
+
while (index < lines.length && lines[index].trim().startsWith(">")) {
|
|
300
|
+
quoteLines.push(lines[index].trim().replace(/^>\s?/, ""));
|
|
301
|
+
index += 1;
|
|
302
|
+
}
|
|
303
|
+
const text = quoteLines.join("\n");
|
|
304
|
+
const emoji = EMOJI.exec(text);
|
|
305
|
+
if (emoji !== null && text.slice(emoji[0].length).startsWith(" ")) {
|
|
306
|
+
push("callout", parseInline(text.slice(emoji[0].length + 1)), { icon: { type: "emoji", emoji: emoji[0] } });
|
|
307
|
+
} else {
|
|
308
|
+
push("quote", parseInline(text));
|
|
309
|
+
}
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
const listMatch = /^(\s*)(?:- \[( |x|X)\] |- |\d+\. )(.*)$/.exec(line);
|
|
313
|
+
if (listMatch !== null) {
|
|
314
|
+
// Collect this list item and any deeper-indented lines that belong to it.
|
|
315
|
+
const currentPath = itemPath();
|
|
316
|
+
const level = lineIndent(line);
|
|
317
|
+
const marker = /^\s*- \[/.test(line) ? "to_do" : /^\s*- /.test(line) ? "bulleted_list_item" : "numbered_list_item";
|
|
318
|
+
const extra = marker === "to_do" ? { checked: listMatch[2].toLowerCase() === "x" } : {};
|
|
319
|
+
const item = push(marker, parseInline(listMatch[3]), extra);
|
|
320
|
+
index += 1;
|
|
321
|
+
const atLimit = depth + 1 > maxDepth;
|
|
322
|
+
const nested = [];
|
|
323
|
+
while (index < lines.length) {
|
|
324
|
+
const next = lines[index];
|
|
325
|
+
if (next.trim().length === 0) {
|
|
326
|
+
// A blank line ends the item unless deeper-indented content follows.
|
|
327
|
+
const lookahead = lines[index + 1];
|
|
328
|
+
if (lookahead !== undefined && lookahead.trim().length > 0 && lineIndent(lookahead) > level) { nested.push(""); index += 1; continue; }
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
if (lineIndent(next) <= level) break;
|
|
332
|
+
if (atLimit) return tooDeep(currentPath);
|
|
333
|
+
nested.push(next.slice((level + 1) * 2));
|
|
334
|
+
index += 1;
|
|
335
|
+
}
|
|
336
|
+
if (nested.length > 0) item.children = parseLines(context, nested, childLimits(currentPath));
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
// Paragraph: contiguous plain lines.
|
|
340
|
+
const paragraph = [trimmed];
|
|
341
|
+
index += 1;
|
|
342
|
+
while (index < lines.length) {
|
|
343
|
+
const next = lines[index].trim();
|
|
344
|
+
if (next.length === 0 || /^(#{1,3} |> |- |\d+\. |```|---$|<details>|<unknown |\[(📄|🗃️) )/u.test(next)) break;
|
|
345
|
+
paragraph.push(next);
|
|
346
|
+
index += 1;
|
|
347
|
+
}
|
|
348
|
+
push("paragraph", parseInline(paragraph.join("\n")));
|
|
349
|
+
}
|
|
350
|
+
return blocks;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Request-shaped rich text (so the normal validators run) from the parser's inline items. */
|
|
354
|
+
function toRequestRichText(items) {
|
|
355
|
+
return items.map((item) => (item.kind === "mention" ? { type: "mention", mention: item.mention } : { type: "text", text: item.text, annotations: item.annotations }));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function toRequestBlocks(blocks) {
|
|
359
|
+
return blocks.map((block) => {
|
|
360
|
+
if (block.keep !== undefined) return block;
|
|
361
|
+
const body = { ...block[block.type] };
|
|
362
|
+
if (body.rich_text !== undefined) body.rich_text = toRequestRichText(body.rich_text);
|
|
363
|
+
const result = { type: block.type, [block.type]: body };
|
|
364
|
+
if (block.children.length > 0) result.children = toRequestBlocks(block.children);
|
|
365
|
+
return result;
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Parse request markdown. By default nesting is held to the per-request limit (the error names the same
|
|
371
|
+
* `content[0]…children` path as a JSON append would); `{ stored: true }` parses the rendering of a page's existing
|
|
372
|
+
* tree, which may be as deep as the stored-tree limit.
|
|
373
|
+
*/
|
|
374
|
+
export function parseMarkdown(context, text, maxBytes, { stored = false } = {}) {
|
|
375
|
+
if (typeof text !== "string") return validationError(context, "body failed validation: the markdown content should be a string.");
|
|
376
|
+
// UTF-8 bytes are never fewer than UTF-16 code units, so the cheap length check only short-circuits huge input.
|
|
377
|
+
const bytes = text.length > maxBytes ? text.length : utf8Length(text);
|
|
378
|
+
if (bytes > maxBytes) return validationError(context, `body failed validation: the markdown content should be ≤ ${maxBytes} bytes, instead was ${text.length > maxBytes ? `more than ${maxBytes}` : bytes}.`);
|
|
379
|
+
const limits = { depth: 0, maxDepth: stored ? MAX_TREE_LEVELS - 1 : MAX_DEPTH, path: "content[0]", blocks: { count: 0 } };
|
|
380
|
+
return toRequestBlocks(parseLines(context, text.replace(/\r\n/g, "\n").split("\n"), limits));
|
|
381
|
+
}
|