@nast/nast2typst 0.2.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Martí Pardo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # NAST to Typst (nast2typst)
2
+ This package transforms a unified-like Notion Abstract Sintax Tree (NAST) in JSON format into a string of Typst code (Typst is an alternative to LaTeX).
3
+
4
+ ## Approach
5
+
6
+ ### Preamble
7
+
8
+ ```typ
9
+ // #import "@preview/notionly:0.1.0": * // When published
10
+ #show: notionly
11
+ ```
12
+
13
+
14
+ ### Notion page information
15
+ The Notion page information gets simply converted into:
16
+ ```typ
17
+ #set document(title: [Here it goes the title of the Notion page])
18
+
19
+ #align(center)[
20
+ #scale(160%)[✴️] \ // Emoji or icon of the Notion page
21
+ #title() \
22
+ ]
23
+ ```
24
+
25
+ ### Math
26
+ For math we internally use the package `tex2typst` for converting the expressions into Typst math language Sometimes tex2typst fails and then we provide the LaTeX code commented out. Due to the content sometimes being in KaTeX (instead of LaTeX) some things do not work out directly and need to be fixed on our side.
27
+
28
+ ### Callout, toggles, bookmarks, checklists...
29
+
30
+
31
+ The Notionly Typst package (made by the same author of this npm package) internally changes some styles (for example of quotes or code blocks) and also exposes some custom functions (like `#callout`) or defines some custom markup (like [X] for checklists) and some custom variables (like the notion palette colors).
32
+
33
+ ## Mapping (Notion → Typst)
34
+
35
+ ### Rich Text (Inline)
36
+
37
+ | Format | Typst |
38
+ |--------|-------|
39
+ | Bold | `*text*` |
40
+ | Italic | `_text_` |
41
+ | Underline | `#underline[text]` |
42
+ | Strikethrough | `#strike[text]` |
43
+ | Inline code | `` `code` `` |
44
+ | Inline math | `$expression$`|
45
+ | Text color | `#text(fill: text_color)` |
46
+ | Background color | `#highlight(fill: bg_color)` |
47
+
48
+ ### Blocks
49
+
50
+ | Block Type | Typst |
51
+ |------------|-------|
52
+ | Headings | `=`, `==`, `===` |
53
+ | Divider | `#line(length: 100%, stroke: 0.1pt)` |
54
+ | Quote | `#quote[...]` |
55
+ | Code block | \`\`\`py `print("Works like in markdown...")` \`\`\` |
56
+ | Callouts | `#callout(icon: "📌", bg: notion.blue_bg)[...]` |
57
+ | Lists (unordered and ordered) | `-` and `+`|
58
+ | Checklists | `[ ]` and `[X]`
59
+ | Tables | `#table( columns: (1fr, 1fr), align: (left, left), table.header([*Col1*], [*Col2*]), [cell], [cell])` |
60
+ | Images | `#figure(image("images/image-1.png"), caption: [...])` |
61
+ | Media | Links to original Notion page
62
+ | Bookmarks and Embeds | `#link("url")[...]` |
63
+ | Toggles | `#toggle[title][body]` or `#toggle(heading: 2)[title][body]` |
64
+ | Columns | `#columns(n, gutter: 2em)[... #colbreak() ...]` |
65
+ | Math | `$ [...] $` but converted to Typst math using `text2typst`|
66
+
67
+ # License
68
+ [MIT](../LICENSE)
@@ -0,0 +1,252 @@
1
+ //#region src/types.d.ts
2
+ interface NASTRoot {
3
+ type: "root";
4
+ children: NASTNode[];
5
+ data: {
6
+ pageId: string;
7
+ title: string;
8
+ icon?: {
9
+ type: "emoji" | "file" | "external";
10
+ value: string;
11
+ };
12
+ processedAt: string;
13
+ };
14
+ }
15
+ type NASTNode = NASTParagraph | NASTHeading | NASTText | NASTStrong | NASTEmphasis | NASTUnderline | NASTDelete | NASTInlineCode | NASTLink | NASTMention | NASTMath | NASTInlineMath | NASTCode | NASTBlockquote | NASTCallout | NASTToggle | NASTList | NASTListItem | NASTColumnList | NASTColumn | NASTImage | NASTThematicBreak | NASTTable | NASTTableRow | NASTTableCell | NASTChildPage | NASTVideo | NASTFile | NASTPDF | NASTBookmark | NASTEmbed;
16
+ interface NASTParagraph {
17
+ type: "paragraph";
18
+ children: NASTNode[];
19
+ data?: {
20
+ blockId?: string;
21
+ };
22
+ }
23
+ interface NASTHeading {
24
+ type: "heading";
25
+ depth: 1 | 2 | 3;
26
+ children: NASTNode[];
27
+ isToggleable?: boolean;
28
+ data?: {
29
+ blockId?: string;
30
+ };
31
+ }
32
+ interface NASTText {
33
+ type: "text";
34
+ value: string;
35
+ data?: {
36
+ color?: string;
37
+ backgroundColor?: string;
38
+ };
39
+ }
40
+ interface NASTStrong {
41
+ type: "strong";
42
+ children: NASTNode[];
43
+ }
44
+ interface NASTEmphasis {
45
+ type: "emphasis";
46
+ children: NASTNode[];
47
+ }
48
+ interface NASTUnderline {
49
+ type: "underline";
50
+ children: NASTNode[];
51
+ }
52
+ interface NASTDelete {
53
+ type: "delete";
54
+ children: NASTNode[];
55
+ }
56
+ interface NASTInlineCode {
57
+ type: "inlineCode";
58
+ value: string;
59
+ }
60
+ interface NASTLink {
61
+ type: "link";
62
+ url: string;
63
+ children: NASTNode[];
64
+ data?: {
65
+ title?: string;
66
+ iconUrl?: string;
67
+ description?: string;
68
+ provider?: string;
69
+ thumbnailUrl?: string;
70
+ };
71
+ }
72
+ interface NASTMention {
73
+ type: "mention";
74
+ mentionType: "user" | "date" | "page" | "database";
75
+ value: string;
76
+ data: unknown;
77
+ }
78
+ interface NASTMath {
79
+ type: "math";
80
+ value: string;
81
+ data?: {
82
+ blockId?: string;
83
+ };
84
+ }
85
+ interface NASTInlineMath {
86
+ type: "inlineMath";
87
+ value: string;
88
+ }
89
+ interface NASTCode {
90
+ type: "code";
91
+ lang: string;
92
+ value: string;
93
+ data?: {
94
+ caption?: NASTNode[];
95
+ blockId?: string;
96
+ };
97
+ }
98
+ interface NASTBlockquote {
99
+ type: "blockquote";
100
+ children: NASTNode[];
101
+ data?: {
102
+ blockId?: string;
103
+ };
104
+ }
105
+ interface NASTCallout {
106
+ type: "callout";
107
+ data: {
108
+ icon: {
109
+ type: "emoji";
110
+ value: string;
111
+ } | null;
112
+ color: string;
113
+ blockId?: string;
114
+ };
115
+ children: NASTNode[];
116
+ }
117
+ interface NASTToggle {
118
+ type: "toggle";
119
+ children: NASTNode[];
120
+ data?: {
121
+ blockId?: string;
122
+ };
123
+ }
124
+ interface NASTList {
125
+ type: "list";
126
+ ordered: boolean;
127
+ children: NASTListItem[];
128
+ }
129
+ interface NASTListItem {
130
+ type: "listItem";
131
+ children: NASTNode[];
132
+ checked?: boolean | undefined;
133
+ data?: {
134
+ blockId?: string;
135
+ };
136
+ }
137
+ interface NASTColumnList {
138
+ type: "columnList";
139
+ children: NASTColumn[];
140
+ data?: {
141
+ blockId?: string;
142
+ };
143
+ }
144
+ interface NASTColumn {
145
+ type: "column";
146
+ widthRatio: number;
147
+ children: NASTNode[];
148
+ data?: {
149
+ blockId?: string;
150
+ };
151
+ }
152
+ interface NASTImage {
153
+ type: "image";
154
+ url: string;
155
+ title: string | null | undefined;
156
+ alt: string | null | undefined;
157
+ data: {
158
+ fileType: "file" | "external";
159
+ expiryTime?: string;
160
+ caption?: NASTNode[];
161
+ blockId?: string;
162
+ };
163
+ }
164
+ interface NASTThematicBreak {
165
+ type: "thematicBreak";
166
+ data?: {
167
+ blockId?: string;
168
+ };
169
+ }
170
+ interface NASTTable {
171
+ type: "table";
172
+ hasColumnHeader: boolean;
173
+ hasRowHeader: boolean;
174
+ children: NASTTableRow[];
175
+ data?: {
176
+ blockId?: string;
177
+ };
178
+ }
179
+ interface NASTTableRow {
180
+ type: "tableRow";
181
+ children: NASTTableCell[];
182
+ }
183
+ interface NASTTableCell {
184
+ type: "tableCell";
185
+ children: NASTNode[];
186
+ }
187
+ interface NASTChildPage {
188
+ type: "childPage";
189
+ title: string;
190
+ pageId: string;
191
+ data?: {
192
+ blockId?: string;
193
+ };
194
+ }
195
+ interface NASTVideo {
196
+ type: "video";
197
+ url: string;
198
+ data: {
199
+ fileType: "file" | "external";
200
+ expiryTime?: string;
201
+ caption?: NASTNode[];
202
+ blockId?: string;
203
+ };
204
+ }
205
+ interface NASTFile {
206
+ type: "file";
207
+ url: string;
208
+ name: string;
209
+ data: {
210
+ fileType: "file" | "external";
211
+ expiryTime?: string;
212
+ caption?: NASTNode[];
213
+ blockId?: string;
214
+ };
215
+ }
216
+ interface NASTPDF {
217
+ type: "pdf";
218
+ url: string;
219
+ data: {
220
+ fileType: "file" | "external";
221
+ expiryTime?: string;
222
+ caption?: NASTNode[];
223
+ blockId?: string;
224
+ };
225
+ }
226
+ interface NASTBookmark {
227
+ type: "bookmark";
228
+ url: string;
229
+ data?: {
230
+ caption?: NASTNode[];
231
+ blockId?: string;
232
+ };
233
+ }
234
+ interface NASTEmbed {
235
+ type: "embed";
236
+ url: string;
237
+ data?: {
238
+ caption?: NASTNode[];
239
+ blockId?: string;
240
+ };
241
+ }
242
+ //#endregion
243
+ //#region src/index.d.ts
244
+ /**
245
+ * Converts a NAST (Unified-like Notion Abstract Syntax Tree) to Typst markup.
246
+ *
247
+ * @param root - The NAST root node
248
+ * @returns Typst markup string
249
+ */
250
+ declare function nast2typst(root: NASTRoot): string;
251
+ //#endregion
252
+ export { NASTBlockquote, NASTBookmark, NASTCallout, NASTChildPage, NASTCode, NASTColumn, NASTColumnList, NASTDelete, NASTEmbed, NASTEmphasis, NASTFile, NASTHeading, NASTImage, NASTInlineCode, NASTInlineMath, NASTLink, NASTList, NASTListItem, NASTMath, NASTMention, NASTNode, NASTPDF, NASTParagraph, NASTRoot, NASTStrong, NASTTable, NASTTableCell, NASTTableRow, NASTText, NASTThematicBreak, NASTToggle, NASTUnderline, NASTVideo, nast2typst };
package/dist/index.js ADDED
@@ -0,0 +1,480 @@
1
+ import { tex2typst } from "tex2typst";
2
+
3
+ //#region src/lib/handlers/utils.ts
4
+ /**
5
+ * Utility functions for NAST to Typst conversion
6
+ */
7
+ /**
8
+ * Escapes special characters in Typst text
9
+ */
10
+ function escapeTypstText(text) {
11
+ return text.replace(/\\/g, "\\\\").replace(/#/g, "\\#").replace(/\$/g, "\\$").replace(/@/g, "\\@");
12
+ }
13
+ /**
14
+ * Converts Notion color names to Typst color references
15
+ */
16
+ function notionColorToTypst(notionColor) {
17
+ return {
18
+ "gray": "notion.gray_text",
19
+ "brown": "notion.brown_text",
20
+ "orange": "notion.orange_text",
21
+ "yellow": "notion.yellow_text",
22
+ "green": "notion.green_text",
23
+ "blue": "notion.blue_text",
24
+ "purple": "notion.purple_text",
25
+ "pink": "notion.pink_text",
26
+ "red": "notion.red_text",
27
+ "gray_background": "notion.gray_bg",
28
+ "brown_background": "notion.brown_bg",
29
+ "orange_background": "notion.orange_bg",
30
+ "yellow_background": "notion.yellow_bg",
31
+ "green_background": "notion.green_bg",
32
+ "blue_background": "notion.blue_bg",
33
+ "purple_background": "notion.purple_bg",
34
+ "pink_background": "notion.pink_bg",
35
+ "red_background": "notion.red_bg"
36
+ }[notionColor] || notionColor;
37
+ }
38
+ /**
39
+ * Processing context that tracks state during conversion
40
+ */
41
+ var ProcessingContext = class {
42
+ imageCounter = 0;
43
+ pageId;
44
+ constructor(pageId) {
45
+ this.pageId = pageId;
46
+ }
47
+ /**
48
+ * Get the next image path and increment counter
49
+ */
50
+ getNextImagePath(url) {
51
+ this.imageCounter++;
52
+ const extension = getFileExtension(url);
53
+ return `images/image-${this.imageCounter}${extension}`;
54
+ }
55
+ /**
56
+ * Get the Notion page URL for this page
57
+ */
58
+ getNotionPageUrl() {
59
+ return `https://notion.so/${this.pageId}`;
60
+ }
61
+ };
62
+ /**
63
+ * Strips query parameters from a URL
64
+ */
65
+ function stripUrlParameters(url) {
66
+ const urlObj = new URL(url);
67
+ return urlObj.origin + urlObj.pathname;
68
+ }
69
+ /**
70
+ * Extracts the clean AWS URL without parameters
71
+ */
72
+ function extractCleanAwsUrl(url) {
73
+ try {
74
+ return stripUrlParameters(url);
75
+ } catch {
76
+ return url;
77
+ }
78
+ }
79
+ /**
80
+ * Determines if a URL is an AWS signed URL
81
+ */
82
+ function isAwsSignedUrl(url) {
83
+ return url.includes("prod-files-secure.s3") || url.includes("amazonaws.com");
84
+ }
85
+ /**
86
+ * Extracts file extension from a URL
87
+ */
88
+ function getFileExtension(url) {
89
+ try {
90
+ const match = (url.split("?")[0].split("/").pop() || "").match(/\.(\w+)$/);
91
+ return match ? match[0] : "";
92
+ } catch {
93
+ return "";
94
+ }
95
+ }
96
+ /**
97
+ * Fixes minor KaTeX-specific issues in converted Typst math
98
+ */
99
+ function fixKatexToTypstConversion(typstMath) {
100
+ let result = typstMath;
101
+ for (const cmd of [
102
+ "Huge",
103
+ "huge",
104
+ "LARGE",
105
+ "Large",
106
+ "large",
107
+ "normalsize",
108
+ "small",
109
+ "footnotesize",
110
+ "scriptsize",
111
+ "tiny"
112
+ ]) result = result.replace(new RegExp(cmd, "g"), "");
113
+ for (const cmd of [
114
+ "big",
115
+ "Big",
116
+ "bigg",
117
+ "Bigg"
118
+ ]) result = result.replace(new RegExp(cmd, "g"), "");
119
+ for (const cmd of [
120
+ "vphantom",
121
+ "hphantom",
122
+ "displaystyle",
123
+ "textstyle",
124
+ "scriptstyle"
125
+ ]) result = result.replace(new RegExp(cmd, "g"), "");
126
+ result = result.replace(/xrightarrow /g, "stretch(->)^");
127
+ return result;
128
+ }
129
+
130
+ //#endregion
131
+ //#region src/lib/handlers/text.ts
132
+ function handleText(node) {
133
+ let text = escapeTypstText(node.value);
134
+ if (node.data?.color) text = `#text(fill: ${notionColorToTypst(node.data.color)})[${text}]`;
135
+ if (node.data?.backgroundColor) text = `#highlight(fill: ${notionColorToTypst(node.data.backgroundColor)})[${text}]`;
136
+ return text;
137
+ }
138
+ function handleStrong(node, context) {
139
+ return `*${node.children.map((child) => processNode(child, context)).join("")}*`;
140
+ }
141
+ function handleEmphasis(node, context) {
142
+ return `_${node.children.map((child) => processNode(child, context)).join("")}_`;
143
+ }
144
+ function handleUnderline(node, context) {
145
+ return `#underline[${node.children.map((child) => processNode(child, context)).join("")}]`;
146
+ }
147
+ function handleDelete(node, context) {
148
+ return `#strike[${node.children.map((child) => processNode(child, context)).join("")}]`;
149
+ }
150
+ function handleInlineCode(node) {
151
+ return `\`${node.value}\``;
152
+ }
153
+
154
+ //#endregion
155
+ //#region src/lib/handlers/math.ts
156
+ function handleMath(node) {
157
+ try {
158
+ let typstMath = tex2typst(node.value);
159
+ typstMath = fixKatexToTypstConversion(typstMath);
160
+ return `$\n${typstMath}\n$\n`;
161
+ } catch (error) {
162
+ return `/*\nFailed to convert the following LaTeX to Typst:\n$\n${node.value}\n$\n*/\n`;
163
+ }
164
+ }
165
+ function handleInlineMath(node) {
166
+ try {
167
+ let typstMath = tex2typst(node.value);
168
+ typstMath = fixKatexToTypstConversion(typstMath);
169
+ return `$${typstMath}$`;
170
+ } catch (error) {
171
+ return `/*\nFailed to convert the following LaTeX to Typst:\n$${node.value}$\n*/\n`;
172
+ }
173
+ }
174
+
175
+ //#endregion
176
+ //#region src/lib/handlers/blocks.ts
177
+ function handleParagraph(node, context) {
178
+ return node.children.map((child) => processNode(child, context)).join("") + "\\\n";
179
+ }
180
+ function handleBlockquote(node, context) {
181
+ return `#quote[\n${node.children.map((child) => processNode(child, context)).join("")}]\n`;
182
+ }
183
+ function handleThematicBreak(node) {
184
+ return `#line(length: 100%, stroke: 0.1pt)\n`;
185
+ }
186
+ function handleCallout(node, context) {
187
+ const icon = node.data.icon?.value || null;
188
+ const bgColor = notionColorToTypst(node.data.color);
189
+ let params = [];
190
+ if (icon) params.push(`icon: "${icon}"`);
191
+ params.push(`bg: ${bgColor}`);
192
+ const content = node.children.map((child) => processNode(child, context)).join("");
193
+ return `#callout(${params.join(", ")})[\n${content}]\n`;
194
+ }
195
+ function handleCode(node) {
196
+ return `\`\`\`${node.lang || ""}\n${node.value}\n\`\`\`\n`;
197
+ }
198
+
199
+ //#endregion
200
+ //#region src/lib/handlers/lists.ts
201
+ let currentListDepth = 0;
202
+ function handleList(node, context) {
203
+ const marker = node.ordered ? "+" : "-";
204
+ const indent = " ".repeat(currentListDepth);
205
+ currentListDepth++;
206
+ const items = node.children.map((child) => handleListItem(child, marker, indent, context));
207
+ currentListDepth--;
208
+ return items.join("") + (currentListDepth === 0 ? "\n" : "");
209
+ }
210
+ function handleListItem(node, marker, indent, context) {
211
+ let result = "";
212
+ let firstBlock = true;
213
+ const checkboxPrefix = node.checked !== void 0 ? node.checked ? "[X] " : "[ ] " : "";
214
+ for (const child of node.children) if (child.type === "paragraph") {
215
+ const content = child.children.map((c) => processNode(c, context)).join("");
216
+ if (firstBlock) {
217
+ result += `${indent}${marker} ${checkboxPrefix}${content}\n`;
218
+ firstBlock = false;
219
+ } else result += `${indent} ${content}\n`;
220
+ } else if (child.type === "list") result += processNode(child, context);
221
+ else {
222
+ const processed = processNode(child, context);
223
+ if (firstBlock) {
224
+ result += `${indent}${marker} ${checkboxPrefix}${processed}`;
225
+ firstBlock = false;
226
+ } else result += `${indent} ${processed}`;
227
+ }
228
+ return result;
229
+ }
230
+
231
+ //#endregion
232
+ //#region src/lib/handlers/headings.ts
233
+ function handleHeading(node, context) {
234
+ if (!(node.isToggleable || false)) return `${"=".repeat(node.depth)} ${node.children.map((child) => processNode(child, context)).join("")}\n`;
235
+ else {
236
+ const toggleTitle = node.children[0] ? processNode(node.children[0], context) : "Toggle";
237
+ const toggleChildren = node.children.slice(1).map((child) => processNode(child, context)).join("");
238
+ return `// Toggle block\n#toggle(heading: ${node.depth})[${toggleTitle}][\n${toggleChildren}\n]`;
239
+ }
240
+ }
241
+
242
+ //#endregion
243
+ //#region src/lib/handlers/toggle.ts
244
+ function handleToggle(node, context) {
245
+ if (node.children.length === 0) return "";
246
+ const firstChild = node.children[0];
247
+ let toggleTitle = "";
248
+ let headingParam = "";
249
+ let bodyStartIndex = 1;
250
+ let isHeadingToggle = false;
251
+ if (firstChild.type === "heading") {
252
+ headingParam = `heading: ${firstChild.depth}`;
253
+ toggleTitle = firstChild.children.map((child) => processNode(child, context)).join("");
254
+ isHeadingToggle = true;
255
+ } else {
256
+ toggleTitle = processNode(firstChild, context);
257
+ bodyStartIndex = 1;
258
+ }
259
+ const toggleBody = node.children.slice(bodyStartIndex).map((child) => processNode(child, context)).join("");
260
+ const toggleParams = headingParam ? `${headingParam}` : "";
261
+ return `// Toggle block\n${toggleParams ? `#toggle(${toggleParams})` : "#toggle"}[${toggleTitle}${isHeadingToggle ? "\n" : ""}][${toggleBody}]`;
262
+ }
263
+
264
+ //#endregion
265
+ //#region src/lib/handlers/table.ts
266
+ function handleTable(node, context) {
267
+ const rows = node.children;
268
+ if (rows.length === 0) return "";
269
+ const columnCount = rows[0].children.length;
270
+ const columns = Array(columnCount).fill("1fr").join(", ");
271
+ let result = "#table(\n";
272
+ result += ` columns: (${columns}),\n`;
273
+ result += ` align: (${Array(columnCount).fill("left").join(", ")}),\n`;
274
+ if (node.hasColumnHeader && rows.length > 0) {
275
+ const headerCells = rows[0].children.map((cell) => {
276
+ return `[*${cell.children.map((child) => processNode(child, context)).join("")}*]`;
277
+ });
278
+ result += ` table.header(${headerCells.join(", ")}),\n`;
279
+ for (let i = 1; i < rows.length; i++) {
280
+ const rowCells = rows[i].children.map((cell, colIndex) => {
281
+ const content = cell.children.map((child) => processNode(child, context)).join("");
282
+ if (colIndex === 0 && node.hasRowHeader) return `[*${content}*]`;
283
+ return `[${content}]`;
284
+ });
285
+ result += ` ${rowCells.join(", ")}${i < rows.length - 1 ? "," : ""}\n`;
286
+ }
287
+ } else rows.forEach((row, rowIndex) => {
288
+ const rowCells = row.children.map((cell, colIndex) => {
289
+ const content = cell.children.map((child) => processNode(child, context)).join("");
290
+ if (colIndex === 0 && node.hasRowHeader) return `[*${content}*]`;
291
+ return `[${content}]`;
292
+ });
293
+ result += ` ${rowCells.join(", ")}${rowIndex < rows.length - 1 ? "," : ""}\n`;
294
+ });
295
+ result += ")\n";
296
+ return result;
297
+ }
298
+
299
+ //#endregion
300
+ //#region src/lib/handlers/media.ts
301
+ function handleImage(node, context) {
302
+ let imagePath;
303
+ let comment = "";
304
+ if (node.data.fileType === "file" && isAwsSignedUrl(node.url)) {
305
+ imagePath = context.getNextImagePath(node.url);
306
+ comment = `// Original file: ${extractCleanAwsUrl(node.url)}\n`;
307
+ } else if (node.data.fileType === "external") {
308
+ imagePath = context.getNextImagePath(node.url);
309
+ comment = `// Source URL: ${node.url}\n`;
310
+ } else imagePath = node.url;
311
+ let result = comment;
312
+ if (node.data.caption && node.data.caption.length > 0) {
313
+ const caption = node.data.caption.map((child) => processNode(child, context)).join("");
314
+ result += `#figure(\n image("${imagePath}"),\n caption: [${caption}]\n)`;
315
+ } else result += `#figure(\n image("${imagePath}")\n)`;
316
+ return result + "\n";
317
+ }
318
+ function handleVideo(node, context) {
319
+ let videoUrl;
320
+ let comment = "";
321
+ if (node.data.fileType === "file" && isAwsSignedUrl(node.url)) {
322
+ videoUrl = context.getNotionPageUrl();
323
+ comment = `// Original file: ${extractCleanAwsUrl(node.url)}\n`;
324
+ } else {
325
+ videoUrl = context.getNotionPageUrl();
326
+ comment = `// Source URL: ${node.url}\n`;
327
+ }
328
+ let result = comment;
329
+ result += `#link("${videoUrl}")[🎥 Video]\n`;
330
+ if (node.data.caption && node.data.caption.length > 0) {
331
+ const caption = node.data.caption.map((child) => processNode(child, context)).join("");
332
+ result += `_${caption}_\n`;
333
+ }
334
+ return result;
335
+ }
336
+ function handleFile(node, context) {
337
+ let fileUrl;
338
+ let comment = "";
339
+ if (node.data.fileType === "file" && isAwsSignedUrl(node.url)) {
340
+ fileUrl = context.getNotionPageUrl();
341
+ comment = `// Original file: ${extractCleanAwsUrl(node.url)}\n`;
342
+ } else {
343
+ fileUrl = context.getNotionPageUrl();
344
+ comment = `// Source URL: ${node.url}\n`;
345
+ }
346
+ const fileName = node.name || "File";
347
+ let result = comment;
348
+ result += `#link("${fileUrl}")[📄 ${fileName}]\n`;
349
+ if (node.data.caption && node.data.caption.length > 0) {
350
+ const caption = node.data.caption.map((child) => processNode(child, context)).join("");
351
+ result += `_${caption}_\n`;
352
+ }
353
+ return result;
354
+ }
355
+ function handlePDF(node, context) {
356
+ let pdfUrl;
357
+ let comment = "";
358
+ if (node.data.fileType === "file" && isAwsSignedUrl(node.url)) {
359
+ pdfUrl = context.getNotionPageUrl();
360
+ comment = `// Original file: ${extractCleanAwsUrl(node.url)}\n`;
361
+ } else {
362
+ pdfUrl = context.getNotionPageUrl();
363
+ comment = `// Source URL: ${node.url}\n`;
364
+ }
365
+ let result = comment;
366
+ result += `#link("${pdfUrl}")[📕 PDF Document]\n`;
367
+ if (node.data.caption && node.data.caption.length > 0) {
368
+ const caption = node.data.caption.map((child) => processNode(child, context)).join("");
369
+ result += `_${caption}_\n`;
370
+ }
371
+ return result;
372
+ }
373
+ function handleBookmark(node, context) {
374
+ let result = `#link("${node.url}")[${node.url}]\n`;
375
+ if (node.data?.caption && node.data.caption.length > 0) {
376
+ const caption = node.data.caption.map((child) => processNode(child, context)).join("");
377
+ result += `_${caption}_\n`;
378
+ }
379
+ return result;
380
+ }
381
+ function handleEmbed(node, context) {
382
+ let result = `// Embedded content: ${node.url}\n`;
383
+ result += `#link("${node.url}")[🔗 Embedded Content]\n`;
384
+ if (node.data?.caption && node.data.caption.length > 0) {
385
+ const caption = node.data.caption.map((child) => processNode(child, context)).join("");
386
+ result += `_${caption}_\n`;
387
+ }
388
+ return result;
389
+ }
390
+
391
+ //#endregion
392
+ //#region src/lib/handlers/misc.ts
393
+ function handleLink(node, context) {
394
+ const content = node.children.map((child) => processNode(child, context)).join("");
395
+ return `#link("${node.url}")[${content}]`;
396
+ }
397
+ function handleMention(node) {
398
+ switch (node.mentionType) {
399
+ case "user": return `\\${node.value}`;
400
+ case "date": return node.value;
401
+ case "page": return `_${node.value}_`;
402
+ case "database": return `_${node.value}_`;
403
+ default: return node.value;
404
+ }
405
+ }
406
+ function handleChildPage(node) {
407
+ return `${node.title}\n#link("https://notion.so/${node.pageId}")[📄 ${node.title}] // Child page\n`;
408
+ }
409
+ function handleColumnList(node, context) {
410
+ return `#columns(${node.children.length}, gutter: 2em)[ \n${node.children.map((column) => handleColumn(column, context)).join("#colbreak()\n")}]\n`;
411
+ }
412
+ function handleColumn(node, context) {
413
+ return node.children.map((child) => processNode(child, context)).join("");
414
+ }
415
+
416
+ //#endregion
417
+ //#region src/lib/processor.ts
418
+ function processNode(node, context) {
419
+ switch (node.type) {
420
+ case "text": return handleText(node);
421
+ case "strong": return handleStrong(node, context);
422
+ case "emphasis": return handleEmphasis(node, context);
423
+ case "underline": return handleUnderline(node, context);
424
+ case "delete": return handleDelete(node, context);
425
+ case "inlineCode": return handleInlineCode(node);
426
+ case "math": return handleMath(node);
427
+ case "inlineMath": return handleInlineMath(node);
428
+ case "paragraph": return handleParagraph(node, context);
429
+ case "heading": return handleHeading(node, context);
430
+ case "blockquote": return handleBlockquote(node, context);
431
+ case "thematicBreak": return handleThematicBreak(node);
432
+ case "callout": return handleCallout(node, context);
433
+ case "code": return handleCode(node);
434
+ case "toggle": return handleToggle(node, context);
435
+ case "list": return handleList(node, context);
436
+ case "columnList": return handleColumnList(node, context);
437
+ case "table": return handleTable(node, context);
438
+ case "image": return handleImage(node, context);
439
+ case "video": return handleVideo(node, context);
440
+ case "file": return handleFile(node, context);
441
+ case "pdf": return handlePDF(node, context);
442
+ case "bookmark": return handleBookmark(node, context);
443
+ case "embed": return handleEmbed(node, context);
444
+ case "link": return handleLink(node, context);
445
+ case "mention": return handleMention(node);
446
+ case "childPage": return handleChildPage(node);
447
+ default:
448
+ console.warn(`Unhandled node type: ${node.type}`);
449
+ return "";
450
+ }
451
+ }
452
+
453
+ //#endregion
454
+ //#region src/index.ts
455
+ const TEMPORAL_PREAMBLE = `
456
+ #import "src/lib.typ": *
457
+ #show: notionly
458
+ `;
459
+ /**
460
+ * Converts a NAST (Unified-like Notion Abstract Syntax Tree) to Typst markup.
461
+ *
462
+ * @param root - The NAST root node
463
+ * @returns Typst markup string
464
+ */
465
+ function nast2typst(root) {
466
+ let result = TEMPORAL_PREAMBLE;
467
+ if (root.data.title) {
468
+ result += `#set document(title: [${root.data.title}])\n\n`;
469
+ result += `#align(center)[\n`;
470
+ if (root.data.icon && root.data.icon.type === "emoji") result += ` #scale(160%)[${root.data.icon.value}] \\\n`;
471
+ result += ` #title() \\\n`;
472
+ result += `]\n\n`;
473
+ }
474
+ const context = new ProcessingContext(root.data.pageId);
475
+ for (const child of root.children) result += processNode(child, context);
476
+ return result;
477
+ }
478
+
479
+ //#endregion
480
+ export { nast2typst };
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@nast/nast2typst",
3
+ "type": "module",
4
+ "version": "0.2.0",
5
+ "description": "Converts NAST (unified-like Notion Abstract Syntax Tree) to Typst markup language.",
6
+ "keywords": [
7
+ "notion",
8
+ "ast",
9
+ "unified",
10
+ "nast",
11
+ "typst",
12
+ "converter"
13
+ ],
14
+ "author": "Mapaor <pardo.marti@gmail.com>",
15
+ "license": "MIT",
16
+ "homepage": "https://github.com/Mapaor/nast/tree/main/packages/nast2typst",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/Mapaor/nast.git",
20
+ "directory": "packages/nast2typst"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/Mapaor/nast/issues"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "exports": {
29
+ ".": "./dist/index.js",
30
+ "./package.json": "./package.json"
31
+ },
32
+ "main": "./dist/index.js",
33
+ "module": "./dist/index.js",
34
+ "types": "./dist/index-CgBLzCnj.d.ts",
35
+ "files": [
36
+ "dist",
37
+ "README.md"
38
+ ],
39
+ "dependencies": {
40
+ "tex2typst": "^0.5.6"
41
+ },
42
+ "scripts": {
43
+ "build": "tsdown",
44
+ "dev": "tsdown --watch",
45
+ "test": "vitest",
46
+ "typecheck": "tsc --noEmit",
47
+ "example": "tsx scripts/example.ts"
48
+ }
49
+ }