@entropicwarrior/sdoc 0.1.3
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 +21 -0
- package/README.md +110 -0
- package/index.js +2 -0
- package/package.json +165 -0
- package/src/notion-renderer.js +451 -0
- package/src/sdoc.js +2223 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
// SDOC Notion Renderer — converts parsed SDOC AST to Notion API block objects.
|
|
2
|
+
//
|
|
3
|
+
// Usage:
|
|
4
|
+
// const { parseSdoc, extractMeta } = require("./sdoc");
|
|
5
|
+
// const { renderNotionBlocks } = require("./notion-renderer");
|
|
6
|
+
// const parsed = parseSdoc(text);
|
|
7
|
+
// const { nodes, meta } = extractMeta(parsed.nodes);
|
|
8
|
+
// const blocks = renderNotionBlocks(nodes);
|
|
9
|
+
|
|
10
|
+
const { parseInline } = require("./sdoc");
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Constants
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
const RICH_TEXT_LIMIT = 2000;
|
|
17
|
+
|
|
18
|
+
const NOTION_LANGUAGES = new Set([
|
|
19
|
+
"abap", "arduino", "bash", "basic", "c", "clojure", "coffeescript", "c++",
|
|
20
|
+
"c#", "css", "dart", "diff", "docker", "elixir", "elm", "erlang",
|
|
21
|
+
"flow", "fortran", "f#", "gherkin", "glsl", "go", "graphql", "groovy",
|
|
22
|
+
"haskell", "html", "java", "javascript", "json", "julia", "kotlin",
|
|
23
|
+
"latex", "less", "lisp", "livescript", "lua", "makefile", "markdown",
|
|
24
|
+
"markup", "matlab", "mermaid", "nix", "objective-c", "ocaml", "pascal",
|
|
25
|
+
"perl", "php", "plain text", "powershell", "prolog", "protobuf",
|
|
26
|
+
"python", "r", "reason", "ruby", "rust", "sass", "scala", "scheme",
|
|
27
|
+
"scss", "shell", "sql", "swift", "typescript", "vb.net", "verilog",
|
|
28
|
+
"vhdl", "visual basic", "webassembly", "xml", "yaml"
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const LANGUAGE_ALIASES = {
|
|
32
|
+
"js": "javascript",
|
|
33
|
+
"ts": "typescript",
|
|
34
|
+
"py": "python",
|
|
35
|
+
"rb": "ruby",
|
|
36
|
+
"sh": "shell",
|
|
37
|
+
"yml": "yaml",
|
|
38
|
+
"txt": "plain text",
|
|
39
|
+
"text": "plain text",
|
|
40
|
+
"cs": "c#",
|
|
41
|
+
"cpp": "c++",
|
|
42
|
+
"objc": "objective-c",
|
|
43
|
+
"dockerfile": "docker",
|
|
44
|
+
"make": "makefile",
|
|
45
|
+
"md": "markdown",
|
|
46
|
+
"rs": "rust",
|
|
47
|
+
"kt": "kotlin"
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Rich text helpers
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
function defaultAnnotations() {
|
|
55
|
+
return {
|
|
56
|
+
bold: false,
|
|
57
|
+
italic: false,
|
|
58
|
+
strikethrough: false,
|
|
59
|
+
underline: false,
|
|
60
|
+
code: false,
|
|
61
|
+
color: "default"
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function richText(content, href, annotations) {
|
|
66
|
+
return {
|
|
67
|
+
type: "text",
|
|
68
|
+
text: {
|
|
69
|
+
content: content,
|
|
70
|
+
link: href ? { url: href } : null
|
|
71
|
+
},
|
|
72
|
+
annotations: { ...annotations }
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function enforceContentLimit(richTexts, limit) {
|
|
77
|
+
const result = [];
|
|
78
|
+
for (const rt of richTexts) {
|
|
79
|
+
const content = rt.text.content;
|
|
80
|
+
if (content.length <= limit) {
|
|
81
|
+
result.push(rt);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
for (let i = 0; i < content.length; i += limit) {
|
|
85
|
+
result.push({
|
|
86
|
+
type: rt.type,
|
|
87
|
+
text: { content: content.slice(i, i + limit), link: rt.text.link },
|
|
88
|
+
annotations: { ...rt.annotations }
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Inline AST → Notion rich text (recursive flattener)
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
function flattenInlineNodes(nodes, annotations, href) {
|
|
100
|
+
const ann = annotations || defaultAnnotations();
|
|
101
|
+
const result = [];
|
|
102
|
+
|
|
103
|
+
for (const node of nodes) {
|
|
104
|
+
switch (node.type) {
|
|
105
|
+
case "text":
|
|
106
|
+
if (node.value) {
|
|
107
|
+
result.push(richText(node.value, href || null, ann));
|
|
108
|
+
}
|
|
109
|
+
break;
|
|
110
|
+
case "strong":
|
|
111
|
+
result.push(...flattenInlineNodes(node.children, { ...ann, bold: true }, href));
|
|
112
|
+
break;
|
|
113
|
+
case "em":
|
|
114
|
+
result.push(...flattenInlineNodes(node.children, { ...ann, italic: true }, href));
|
|
115
|
+
break;
|
|
116
|
+
case "strike":
|
|
117
|
+
result.push(...flattenInlineNodes(node.children, { ...ann, strikethrough: true }, href));
|
|
118
|
+
break;
|
|
119
|
+
case "code":
|
|
120
|
+
result.push(richText(node.value, href || null, { ...ann, code: true }));
|
|
121
|
+
break;
|
|
122
|
+
case "link":
|
|
123
|
+
result.push(...flattenInlineNodes(node.children, ann, node.href));
|
|
124
|
+
break;
|
|
125
|
+
case "ref":
|
|
126
|
+
result.push(richText("@" + node.id, href || null, ann));
|
|
127
|
+
break;
|
|
128
|
+
case "image":
|
|
129
|
+
// Images are extracted at the block level; skip here.
|
|
130
|
+
break;
|
|
131
|
+
default:
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function flattenInline(text) {
|
|
140
|
+
if (!text) return [];
|
|
141
|
+
const nodes = parseInline(text);
|
|
142
|
+
return enforceContentLimit(flattenInlineNodes(nodes), RICH_TEXT_LIMIT);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Image extraction from inline content
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
function extractImagesFromInline(text) {
|
|
150
|
+
const inlineNodes = parseInline(text);
|
|
151
|
+
const textNodes = [];
|
|
152
|
+
const images = [];
|
|
153
|
+
|
|
154
|
+
for (const node of inlineNodes) {
|
|
155
|
+
if (node.type === "image") {
|
|
156
|
+
images.push(node);
|
|
157
|
+
} else {
|
|
158
|
+
textNodes.push(node);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const rt = enforceContentLimit(flattenInlineNodes(textNodes), RICH_TEXT_LIMIT);
|
|
163
|
+
const hasText = rt.some((r) => r.text.content.trim() !== "");
|
|
164
|
+
|
|
165
|
+
const imageBlocks = [];
|
|
166
|
+
for (const img of images) {
|
|
167
|
+
// Notion requires absolute URLs for external images
|
|
168
|
+
if (!/^https?:\/\//i.test(img.src)) continue;
|
|
169
|
+
imageBlocks.push({
|
|
170
|
+
type: "image",
|
|
171
|
+
image: {
|
|
172
|
+
type: "external",
|
|
173
|
+
external: { url: img.src },
|
|
174
|
+
caption: img.alt ? [richText(img.alt, null, defaultAnnotations())] : []
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { richText: hasText ? rt : [], imageBlocks };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Language mapping
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
function mapNotionLanguage(lang) {
|
|
187
|
+
if (!lang) return "plain text";
|
|
188
|
+
const lower = lang.toLowerCase();
|
|
189
|
+
if (NOTION_LANGUAGES.has(lower)) return lower;
|
|
190
|
+
return LANGUAGE_ALIASES[lower] || "plain text";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Block factories
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
function headingBlock(level, text, children) {
|
|
198
|
+
const key = "heading_" + level;
|
|
199
|
+
const block = {
|
|
200
|
+
type: key,
|
|
201
|
+
[key]: {
|
|
202
|
+
rich_text: flattenInline(text),
|
|
203
|
+
is_toggleable: !!(children && children.length > 0)
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
if (children && children.length > 0) {
|
|
207
|
+
block[key].children = children;
|
|
208
|
+
}
|
|
209
|
+
return block;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Block rendering
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
// Notion API allows max 2 levels of block nesting per append request.
|
|
217
|
+
// nestLevel tracks position in the Notion block hierarchy (0 = top-level
|
|
218
|
+
// in the request, 1 = children of those, 2 = grandchildren). Blocks at
|
|
219
|
+
// nestLevel 2 CANNOT have children. This is separate from document depth
|
|
220
|
+
// (which determines heading level).
|
|
221
|
+
// Notion allows max 2 levels of nesting, but complex block types (tables,
|
|
222
|
+
// lists with children) cannot appear at level 2. Setting MAX_NEST to 1
|
|
223
|
+
// means only heading_1 is a toggle heading — its children sit at level 1
|
|
224
|
+
// where tables and nested lists work correctly.
|
|
225
|
+
const MAX_NEST = 1;
|
|
226
|
+
|
|
227
|
+
function renderNotionBlocks(nodes) {
|
|
228
|
+
// Unwrap document scope wrapper (single root scope)
|
|
229
|
+
if (nodes.length === 1 && nodes[0].type === "scope" && nodes[0].children) {
|
|
230
|
+
const doc = nodes[0];
|
|
231
|
+
if (doc.hasHeading && doc.title) {
|
|
232
|
+
// Document title scope: render as top-level toggle heading
|
|
233
|
+
const childBlocks = renderChildren(doc.children, 2, 1);
|
|
234
|
+
return [headingBlock(1, doc.title, childBlocks)];
|
|
235
|
+
}
|
|
236
|
+
return renderChildren(doc.children, 1, 0);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return renderChildren(nodes, 1, 0);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function renderChildren(nodes, depth, nestLevel) {
|
|
243
|
+
const blocks = [];
|
|
244
|
+
for (const node of nodes) {
|
|
245
|
+
blocks.push(...renderNode(node, depth, nestLevel));
|
|
246
|
+
}
|
|
247
|
+
return blocks;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function renderNode(node, depth, nestLevel) {
|
|
251
|
+
switch (node.type) {
|
|
252
|
+
case "scope":
|
|
253
|
+
return renderScope(node, depth, nestLevel);
|
|
254
|
+
case "paragraph":
|
|
255
|
+
return renderParagraph(node);
|
|
256
|
+
case "list":
|
|
257
|
+
return renderList(node, depth, nestLevel);
|
|
258
|
+
case "table":
|
|
259
|
+
return renderTable(node);
|
|
260
|
+
case "code":
|
|
261
|
+
return renderCode(node);
|
|
262
|
+
case "blockquote":
|
|
263
|
+
return renderBlockquote(node);
|
|
264
|
+
case "hr":
|
|
265
|
+
return [{ type: "divider", divider: {} }];
|
|
266
|
+
default:
|
|
267
|
+
return [];
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function renderScope(scope, depth, nestLevel) {
|
|
272
|
+
const level = Math.min(3, Math.max(1, depth));
|
|
273
|
+
|
|
274
|
+
if (scope.hasHeading === false) {
|
|
275
|
+
return renderChildren(scope.children, depth + 1, nestLevel);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Can this heading have children (toggle)? Only if we're below the nest limit.
|
|
279
|
+
if (nestLevel < MAX_NEST) {
|
|
280
|
+
const childBlocks = renderChildren(scope.children, depth + 1, nestLevel + 1);
|
|
281
|
+
return [headingBlock(level, scope.title, childBlocks)];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// At the nest limit: emit flat heading, children as siblings at same level
|
|
285
|
+
const childBlocks = renderChildren(scope.children, depth + 1, nestLevel);
|
|
286
|
+
return [headingBlock(level, scope.title, null), ...childBlocks];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function renderParagraph(node) {
|
|
290
|
+
const { richText: rt, imageBlocks } = extractImagesFromInline(node.text);
|
|
291
|
+
const blocks = [];
|
|
292
|
+
|
|
293
|
+
if (rt.length > 0) {
|
|
294
|
+
blocks.push({
|
|
295
|
+
type: "paragraph",
|
|
296
|
+
paragraph: { rich_text: rt }
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
blocks.push(...imageBlocks);
|
|
301
|
+
return blocks;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function renderList(list, depth, nestLevel) {
|
|
305
|
+
const blocks = [];
|
|
306
|
+
for (const item of list.items) {
|
|
307
|
+
if (item.task) {
|
|
308
|
+
blocks.push(renderToDoItem(item, depth, nestLevel));
|
|
309
|
+
} else {
|
|
310
|
+
blocks.push(renderListItem(item, list.listType, depth, nestLevel));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return blocks;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function renderListItem(item, listType, depth, nestLevel) {
|
|
317
|
+
const blockType = listType === "number" ? "numbered_list_item" : "bulleted_list_item";
|
|
318
|
+
const titleRt = flattenInline(item.title);
|
|
319
|
+
|
|
320
|
+
const block = {
|
|
321
|
+
type: blockType,
|
|
322
|
+
[blockType]: {
|
|
323
|
+
rich_text: titleRt
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
if (item.children && item.children.length > 0 && nestLevel < MAX_NEST) {
|
|
328
|
+
const childBlocks = renderChildren(item.children, depth + 1, nestLevel + 1);
|
|
329
|
+
if (childBlocks.length > 0) {
|
|
330
|
+
block[blockType].children = childBlocks;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return block;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function renderToDoItem(item, depth, nestLevel) {
|
|
338
|
+
const titleRt = flattenInline(item.title);
|
|
339
|
+
|
|
340
|
+
const block = {
|
|
341
|
+
type: "to_do",
|
|
342
|
+
to_do: {
|
|
343
|
+
rich_text: titleRt,
|
|
344
|
+
checked: item.task.checked
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
if (item.children && item.children.length > 0 && nestLevel < MAX_NEST) {
|
|
349
|
+
const childBlocks = renderChildren(item.children, depth + 1, nestLevel + 1);
|
|
350
|
+
if (childBlocks.length > 0) {
|
|
351
|
+
block.to_do.children = childBlocks;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return block;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function renderTable(table) {
|
|
359
|
+
const hasHeader = table.headers.length > 0;
|
|
360
|
+
const colCount = hasHeader
|
|
361
|
+
? table.headers.length
|
|
362
|
+
: (table.rows.length > 0 ? table.rows[0].length : 0);
|
|
363
|
+
const rows = [];
|
|
364
|
+
|
|
365
|
+
if (hasHeader) {
|
|
366
|
+
rows.push({
|
|
367
|
+
type: "table_row",
|
|
368
|
+
table_row: {
|
|
369
|
+
cells: table.headers.map((cell) => flattenInline(cell))
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
for (const row of table.rows) {
|
|
375
|
+
const cells = [];
|
|
376
|
+
for (let i = 0; i < colCount; i++) {
|
|
377
|
+
cells.push(flattenInline(row[i] || ""));
|
|
378
|
+
}
|
|
379
|
+
rows.push({
|
|
380
|
+
type: "table_row",
|
|
381
|
+
table_row: { cells }
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return [{
|
|
386
|
+
type: "table",
|
|
387
|
+
table: {
|
|
388
|
+
table_width: colCount,
|
|
389
|
+
has_column_header: hasHeader,
|
|
390
|
+
has_row_header: false,
|
|
391
|
+
children: rows
|
|
392
|
+
}
|
|
393
|
+
}];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function renderCode(code) {
|
|
397
|
+
const text = code.text || "";
|
|
398
|
+
const richTexts = [];
|
|
399
|
+
for (let i = 0; i < text.length; i += RICH_TEXT_LIMIT) {
|
|
400
|
+
richTexts.push({
|
|
401
|
+
type: "text",
|
|
402
|
+
text: { content: text.slice(i, i + RICH_TEXT_LIMIT), link: null },
|
|
403
|
+
annotations: defaultAnnotations()
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
// Empty code block: ensure at least one rich text object
|
|
407
|
+
if (richTexts.length === 0) {
|
|
408
|
+
richTexts.push({
|
|
409
|
+
type: "text",
|
|
410
|
+
text: { content: "", link: null },
|
|
411
|
+
annotations: defaultAnnotations()
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return [{
|
|
416
|
+
type: "code",
|
|
417
|
+
code: {
|
|
418
|
+
rich_text: richTexts,
|
|
419
|
+
language: mapNotionLanguage(code.lang)
|
|
420
|
+
}
|
|
421
|
+
}];
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function renderBlockquote(bq) {
|
|
425
|
+
const richTexts = [];
|
|
426
|
+
|
|
427
|
+
for (let i = 0; i < bq.paragraphs.length; i++) {
|
|
428
|
+
if (i > 0) {
|
|
429
|
+
richTexts.push(richText("\n", null, defaultAnnotations()));
|
|
430
|
+
}
|
|
431
|
+
richTexts.push(...flattenInline(bq.paragraphs[i]));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return [{
|
|
435
|
+
type: "quote",
|
|
436
|
+
quote: {
|
|
437
|
+
rich_text: enforceContentLimit(richTexts, RICH_TEXT_LIMIT)
|
|
438
|
+
}
|
|
439
|
+
}];
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ---------------------------------------------------------------------------
|
|
443
|
+
// Exports
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
|
|
446
|
+
module.exports = {
|
|
447
|
+
renderNotionBlocks,
|
|
448
|
+
flattenInline,
|
|
449
|
+
flattenInlineNodes,
|
|
450
|
+
mapNotionLanguage
|
|
451
|
+
};
|