@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 +21 -0
- package/README.md +68 -0
- package/dist/index-CgBLzCnj.d.ts +252 -0
- package/dist/index.js +480 -0
- package/package.json +49 -0
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
|
+
}
|