@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.
@@ -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
+ };