github.com/cozy/cozy-stack@v0.0.0-20240327093429-939e4a21320e/model/note/markdown.go (about) 1 package note 2 3 import ( 4 "fmt" 5 "strconv" 6 "time" 7 8 "github.com/cozy/cozy-stack/model/note/custom" 9 "github.com/cozy/prosemirror-go/markdown" 10 "github.com/cozy/prosemirror-go/model" 11 "github.com/yuin/goldmark/ast" 12 "github.com/yuin/goldmark/extension" 13 extensionast "github.com/yuin/goldmark/extension/ast" 14 "github.com/yuin/goldmark/parser" 15 "github.com/yuin/goldmark/util" 16 ) 17 18 func markdownSerializer(images []*Image) *markdown.Serializer { 19 vanilla := markdown.DefaultSerializer 20 nodes := map[string]markdown.NodeSerializerFunc{ 21 "paragraph": func(state *markdown.SerializerState, node, _parent *model.Node, _index int) { 22 for _, mark := range node.Marks { 23 if mark.Type.Name == "alignment" { 24 switch mark.Attrs["align"] { 25 case "center": 26 state.Write("[^]{.center}") 27 case "end": 28 state.Write("[^]{.right}") 29 } 30 } 31 } 32 state.RenderInline(node) 33 state.CloseBlock(node) 34 }, 35 "text": vanilla.Nodes["text"], 36 "bulletList": vanilla.Nodes["bullet_list"], 37 "orderedList": vanilla.Nodes["ordered_list"], 38 "listItem": vanilla.Nodes["list_item"], 39 "decisionList": func(state *markdown.SerializerState, node, _parent *model.Node, _index int) { 40 if node.Attrs == nil { 41 node.Attrs = map[string]interface{}{} 42 } 43 node.Attrs["tight"] = true 44 state.RenderList(node, " ", func(_ int) string { return "✍ " }) 45 }, 46 "decisionItem": vanilla.Nodes["list_item"], 47 "taskList": func(state *markdown.SerializerState, node, _parent *model.Node, _index int) { 48 state.RenderList(node, " ", func(_ int) string { return "- " }) 49 }, 50 "taskItem": func(state *markdown.SerializerState, node, _parent *model.Node, _index int) { 51 if node.Attrs["state"] == "DONE" { 52 state.Write("[X] ") 53 } else { 54 state.Write("[ ] ") 55 } 56 state.RenderContent(node) 57 }, 58 "heading": vanilla.Nodes["heading"], 59 "blockquote": vanilla.Nodes["blockquote"], 60 "rule": vanilla.Nodes["horizontal_rule"], 61 "hardBreak": vanilla.Nodes["hard_break"], 62 "image": vanilla.Nodes["image"], 63 "codeBlock": func(state *markdown.SerializerState, node, _parent *model.Node, _index int) { 64 lang, _ := node.Attrs["language"].(string) 65 state.Write("```" + lang + "\n") 66 state.Text(node.TextContent(), false) 67 state.EnsureNewLine() 68 state.Write("```") 69 state.CloseBlock(node) 70 }, 71 "panel": func(state *markdown.SerializerState, node, _parent *model.Node, _index int) { 72 if typ, ok := node.Attrs["panelType"].(string); ok { 73 state.Write(":" + typ + ": ") 74 } 75 state.RenderContent(node) 76 }, 77 "table": func(state *markdown.SerializerState, node, _parent *model.Node, _index int) { 78 var attrs string 79 if node.Attrs["layout"] == "wide" { 80 attrs += ` layout="wide"` 81 } else if node.Attrs["layout"] == "full-width" { 82 attrs += ` layout="full-width"` 83 } 84 if node.Attrs["isNumberColumnEnabled"] == true { 85 attrs += " number=true" 86 } 87 state.Write("________________________________________{.table" + attrs + "}\n\n") 88 state.RenderContent(node) 89 state.EnsureNewLine() 90 state.Write("________________________________________{.tableEnd}\n") 91 state.CloseBlock(node) 92 }, 93 "tableRow": func(state *markdown.SerializerState, node, _parent *model.Node, _index int) { 94 state.Write("________________________________________{.tableRow}\n\n") 95 state.RenderContent(node) 96 state.EnsureNewLine() 97 state.CloseBlock(node) 98 }, 99 "tableHeader": func(state *markdown.SerializerState, node, _parent *model.Node, _index int) { 100 attrs := cellMarkup(node) 101 state.Write("____________________{.tableHeader" + attrs + "}\n\n") 102 state.RenderContent(node) 103 }, 104 "tableCell": func(state *markdown.SerializerState, node, _parent *model.Node, _index int) { 105 attrs := cellMarkup(node) 106 state.Write("____________________{.tableCell" + attrs + "}\n\n") 107 state.RenderContent(node) 108 }, 109 "status": func(state *markdown.SerializerState, node, _parent *model.Node, _index int) { 110 if txt, ok := node.Attrs["text"].(string); ok { 111 state.Write("[") 112 state.Text(txt) 113 state.Write("]") 114 color, _ := node.Attrs["color"].(string) 115 id, _ := node.Attrs["localId"].(string) 116 state.Text(fmt.Sprintf(`{.status color="%s" localId="%s"}`, color, id)) 117 } 118 }, 119 "date": func(state *markdown.SerializerState, node, _parent *model.Node, _index int) { 120 if ts, ok := node.Attrs["timestamp"].(string); ok { 121 if seconds, err := strconv.ParseInt(ts, 10, 64); err == nil { 122 txt := time.Unix(seconds/1000, 0).Format("2006-01-02") 123 state.Write("[") 124 state.Text(txt) 125 state.Write("]") 126 state.Text(fmt.Sprintf(`{.date ts="%s"}`, ts)) 127 } 128 } 129 }, 130 "mediaSingle": func(state *markdown.SerializerState, node, _parent *model.Node, _index int) { 131 state.RenderContent(node) 132 }, 133 "media": func(state *markdown.SerializerState, node, _parent *model.Node, _index int) { 134 var alt string 135 src, _ := node.Attrs["url"].(string) 136 for _, img := range images { 137 if img.DocID == src { 138 alt = img.Name 139 img.seen = true 140 } 141 } 142 state.Write(fmt.Sprintf("\n", state.Esc(alt), state.Esc(src))) 143 }, 144 } 145 marks := map[string]markdown.MarkSerializerSpec{ 146 "em": vanilla.Marks["em"], 147 "strong": vanilla.Marks["strong"], 148 "link": vanilla.Marks["link"], 149 "code": vanilla.Marks["code"], 150 "strike": {Open: "~~", Close: "~~", ExpelEnclosingWhitespace: true}, 151 "indentation": {Open: " ", Close: "", ExpelEnclosingWhitespace: true}, 152 "breakout": {Open: "", Close: "", ExpelEnclosingWhitespace: true}, 153 "underline": {Open: "[", Close: "]{.underlined}", ExpelEnclosingWhitespace: true}, 154 "subsup": { 155 Open: "[", 156 Close: func(state *markdown.SerializerState, mark *model.Mark, parent *model.Node, index int) string { 157 typ, _ := mark.Attrs["type"].(string) 158 return fmt.Sprintf("]{.%s}", typ) 159 }, 160 }, 161 "textColor": { 162 Open: "[", 163 Close: func(state *markdown.SerializerState, mark *model.Mark, parent *model.Node, index int) string { 164 color, _ := mark.Attrs["color"].(string) 165 return fmt.Sprintf(`]{.color rgb="%s"}`, color) 166 }, 167 }, 168 } 169 return markdown.NewSerializer(nodes, marks) 170 } 171 172 func cellMarkup(node *model.Node) string { 173 var attrs string 174 if color, ok := node.Attrs["background"].(string); ok && color[0] == '#' { 175 attrs += fmt.Sprintf(` background="%s"`, color) 176 } 177 if span, ok := node.Attrs["rowspan"].(float64); ok && span > 1 { 178 attrs += fmt.Sprintf(" rowspan=%d", int(span)) 179 } 180 if span, ok := node.Attrs["colspan"].(float64); ok && span > 1 { 181 attrs += fmt.Sprintf(" colspan=%d", int(span)) 182 } 183 return attrs 184 } 185 186 func isTableCell(item *markdown.StackItem) bool { 187 name := item.Type.Name 188 return name == "tableHeader" || name == "tableCell" 189 } 190 191 func cellAttributes(node ast.Node) map[string]interface{} { 192 background, ok := node.AttributeString("background") 193 if !ok { 194 background = nil 195 } 196 colspan, ok := node.AttributeString("colspan") 197 if !ok { 198 colspan = 1 199 } 200 rowspan, ok := node.AttributeString("rowspan") 201 if !ok { 202 rowspan = 1 203 } 204 return map[string]interface{}{ 205 "background": background, 206 "colspan": colspan, 207 "rowspan": rowspan, 208 } 209 } 210 211 func markdownNodeMapper() markdown.NodeMapper { 212 vanilla := markdown.DefaultNodeMapper 213 return markdown.NodeMapper{ 214 // Blocks 215 ast.KindDocument: vanilla[ast.KindDocument], 216 ast.KindParagraph: vanilla[ast.KindParagraph], 217 ast.KindHeading: vanilla[ast.KindHeading], 218 ast.KindList: func(state *markdown.MarkdownParseState, node ast.Node, entering bool) error { 219 if entering { 220 isATaskList := false 221 listItem := node.FirstChild() 222 if listItem != nil { 223 isATaskList = true 224 for listItem != nil { 225 para := listItem.FirstChild() 226 if para == nil { 227 isATaskList = false 228 break 229 } 230 checkbox := para.FirstChild() 231 if checkbox == nil || checkbox.Kind() != extensionast.KindTaskCheckBox { 232 isATaskList = false 233 break 234 } 235 listItem = listItem.NextSibling() 236 } 237 } 238 if isATaskList { 239 typ, err := state.Schema.NodeType("taskList") 240 if err != nil { 241 return err 242 } 243 state.OpenNode(typ, nil) 244 return nil 245 } 246 return vanilla[ast.KindList](state, node, entering) 247 } 248 _, err := state.CloseNode() 249 return err 250 }, 251 ast.KindListItem: func(state *markdown.MarkdownParseState, node ast.Node, entering bool) error { 252 inTaskList := false 253 for _, item := range state.Stack { 254 if item.Type.Name == "taskList" { 255 inTaskList = true 256 } 257 } 258 if inTaskList { 259 if entering { 260 typ, err := state.Schema.NodeType("taskItem") 261 if err != nil { 262 return err 263 } 264 state.OpenNode(typ, nil) 265 return nil 266 } else { 267 // taskItem have their content directly inside them, no paragraphs 268 item := state.Top() 269 paragraphs := item.Content 270 item.Content = nil 271 for _, paragraph := range paragraphs { 272 item.Content = append(item.Content, paragraph.Content.Content...) 273 } 274 _, err := state.CloseNode() 275 return err 276 } 277 } 278 return vanilla[ast.KindListItem](state, node, entering) 279 }, 280 ast.KindTextBlock: vanilla[ast.KindTextBlock], 281 ast.KindBlockquote: vanilla[ast.KindBlockquote], 282 ast.KindCodeBlock: vanilla[ast.KindCodeBlock], 283 ast.KindFencedCodeBlock: vanilla[ast.KindFencedCodeBlock], 284 ast.KindThematicBreak: vanilla[ast.KindThematicBreak], 285 custom.KindDecisionList: markdown.GenericBlockHandler("decisionList"), 286 custom.KindDecisionItem: func(state *markdown.MarkdownParseState, node ast.Node, entering bool) error { 287 if entering { 288 typ, err := state.Schema.NodeType("decisionItem") 289 if err != nil { 290 return err 291 } 292 state.OpenNode(typ, nil) 293 } else { 294 // decisionItem have their content directly inside them, no paragraphs 295 item := state.Top() 296 paragraphs := item.Content 297 item.Content = nil 298 for _, paragraph := range paragraphs { 299 item.Content = append(item.Content, paragraph.Content.Content...) 300 } 301 if _, err := state.CloseNode(); err != nil { 302 return err 303 } 304 } 305 return nil 306 }, 307 custom.KindPanel: func(state *markdown.MarkdownParseState, node ast.Node, entering bool) error { 308 if entering { 309 typ, err := state.Schema.NodeType("panel") 310 if err != nil { 311 return err 312 } 313 attrs := map[string]interface{}{ 314 "panelType": node.(*custom.Panel).PanelType, 315 } 316 state.OpenNode(typ, attrs) 317 } else { 318 if _, err := state.CloseNode(); err != nil { 319 return err 320 } 321 } 322 return nil 323 }, 324 custom.KindTable: func(state *markdown.MarkdownParseState, node ast.Node, entering bool) error { 325 tableType, ok := node.AttributeString("class") 326 if entering || !ok { 327 return nil 328 } 329 330 var attrs map[string]interface{} 331 switch tableType { 332 case "table": 333 number, ok := node.AttributeString("number") 334 if !ok { 335 number = false 336 } 337 layout, ok := node.AttributeString("layout") 338 if !ok { 339 layout = "default" 340 } 341 attrs = map[string]interface{}{ 342 "__autosize": false, 343 "isNumberColumnEnabled": number, 344 "layout": layout, 345 } 346 case "tableEnd": 347 if isTableCell(state.Top()) { 348 if _, err := state.CloseNode(); err != nil { // Cell 349 return err 350 } 351 if _, err := state.CloseNode(); err != nil { // Row 352 return err 353 } 354 } 355 if _, err := state.CloseNode(); err != nil { // Table 356 return err 357 } 358 return nil 359 case "tableRow": 360 if isTableCell(state.Top()) { 361 if _, err := state.CloseNode(); err != nil { // Cell 362 return err 363 } 364 if _, err := state.CloseNode(); err != nil { // Row 365 return err 366 } 367 } 368 case "tableHeader": 369 if isTableCell(state.Top()) { 370 if _, err := state.CloseNode(); err != nil { 371 return err 372 } 373 } 374 attrs = cellAttributes(node) 375 case "tableCell": 376 if isTableCell(state.Top()) { 377 if _, err := state.CloseNode(); err != nil { 378 return err 379 } 380 } 381 attrs = cellAttributes(node) 382 default: 383 return nil 384 } 385 typ, err := state.Schema.NodeType(tableType.(string)) 386 if err != nil { 387 return err 388 } 389 state.OpenNode(typ, attrs) 390 return nil 391 }, 392 393 // Inlines 394 ast.KindText: vanilla[ast.KindText], 395 ast.KindString: vanilla[ast.KindString], 396 ast.KindAutoLink: vanilla[ast.KindAutoLink], 397 ast.KindLink: vanilla[ast.KindLink], 398 ast.KindImage: func(state *markdown.MarkdownParseState, node ast.Node, entering bool) error { 399 if entering { 400 return nil 401 } 402 paragraph := state.Pop() 403 var text string 404 for _, n := range paragraph.Content { 405 if n.Text != nil { 406 text += *n.Text 407 } 408 } 409 typeMS, err := state.Schema.NodeType("mediaSingle") 410 if err != nil { 411 return err 412 } 413 state.OpenNode(typeMS, map[string]interface{}{"layout": "center"}) 414 typ, err := state.Schema.NodeType("media") 415 if err != nil { 416 return err 417 } 418 n := node.(*ast.Image) 419 attrs := map[string]interface{}{ 420 "url": string(n.Destination), 421 "alt": text, 422 "id": "", 423 "type": "external", 424 } 425 state.OpenNode(typ, attrs) 426 if _, err := state.CloseNode(); err != nil { // media 427 return err 428 } 429 if _, err := state.CloseNode(); err != nil { // mediaSingle 430 return err 431 } 432 state.OpenNode(paragraph.Type, paragraph.Attrs) 433 return nil 434 }, 435 ast.KindCodeSpan: vanilla[ast.KindCodeSpan], 436 ast.KindEmphasis: vanilla[ast.KindEmphasis], 437 extensionast.KindStrikethrough: vanilla[extensionast.KindStrikethrough], 438 extensionast.KindTaskCheckBox: func(state *markdown.MarkdownParseState, node ast.Node, entering bool) error { 439 if entering { 440 if len(state.Stack) <= 2 { 441 return nil 442 } 443 grandparent := state.Stack[len(state.Stack)-2] 444 if grandparent.Type.Name == "taskItem" { 445 s := "TODO" 446 n := node.(*extensionast.TaskCheckBox) 447 if n.IsChecked { 448 s = "DONE" 449 } 450 if grandparent.Attrs == nil { 451 grandparent.Attrs = map[string]interface{}{} 452 } 453 grandparent.Attrs["state"] = s 454 } 455 } 456 return nil 457 }, 458 custom.KindSpan: func(state *markdown.MarkdownParseState, node ast.Node, entering bool) error { 459 text := node.(*custom.Span).Value 460 461 var markType, nodeType string 462 var attrs map[string]interface{} 463 if class, ok := node.AttributeString("class"); ok { 464 switch class { 465 case "underlined": 466 markType = "underline" 467 case "sub": 468 markType = "subsup" 469 attrs = map[string]interface{}{"type": "sub"} 470 case "sup": 471 markType = "subsup" 472 attrs = map[string]interface{}{"type": "sup"} 473 case "color": 474 if color, ok := node.AttributeString("rgb"); ok { 475 markType = "textColor" 476 attrs = map[string]interface{}{"color": color} 477 } 478 case "status": 479 if color, ok := node.AttributeString("color"); ok { 480 id, _ := node.AttributeString("localId") 481 nodeType = "status" 482 attrs = map[string]interface{}{ 483 "color": color, 484 "localId": id, 485 "text": text, 486 } 487 } 488 case "date": 489 nodeType = "date" 490 ts, _ := node.AttributeString("ts") 491 attrs = map[string]interface{}{"timestamp": ts} 492 case "center", "right": 493 markType = "alignment" 494 align := "center" 495 if class == "right" { 496 align = "end" 497 } 498 attrs = map[string]interface{}{"align": align} 499 } 500 } 501 502 if markType != "" { 503 typ, err := state.Schema.MarkType(markType) 504 if err != nil { 505 return err 506 } 507 mark := typ.Create(attrs) 508 if entering { 509 state.OpenMark(mark) 510 state.AddText(text) 511 } else { 512 state.CloseMark(mark) 513 } 514 } else if nodeType != "" { 515 if entering { 516 typ, err := state.Schema.NodeType(nodeType) 517 if err != nil { 518 return err 519 } 520 state.OpenNode(typ, attrs) 521 } else { 522 if _, err := state.CloseNode(); err != nil { 523 return err 524 } 525 } 526 } else { 527 if entering { 528 state.AddText(text) 529 } 530 return nil 531 } 532 return nil 533 }, 534 } 535 } 536 537 func markdownParser() parser.Parser { 538 return parser.NewParser( 539 parser.WithBlockParsers( 540 util.Prioritized(custom.NewTableParser(), 50), 541 util.Prioritized(parser.NewSetextHeadingParser(), 100), 542 util.Prioritized(parser.NewThematicBreakParser(), 200), 543 util.Prioritized(parser.NewListParser(), 300), 544 util.Prioritized(parser.NewListItemParser(), 350), 545 util.Prioritized(custom.NewDecisionListParser(), 400), 546 util.Prioritized(custom.NewDecisionItemParser(), 450), 547 util.Prioritized(parser.NewCodeBlockParser(), 500), 548 util.Prioritized(parser.NewATXHeadingParser(), 600), 549 util.Prioritized(parser.NewFencedCodeBlockParser(), 700), 550 util.Prioritized(parser.NewBlockquoteParser(), 800), 551 util.Prioritized(custom.NewPanelParser(), 900), 552 util.Prioritized(parser.NewParagraphParser(), 1000), 553 ), 554 parser.WithInlineParsers( 555 util.Prioritized(extension.NewTaskCheckBoxParser(), 0), 556 util.Prioritized(custom.NewSpanParser(), 50), 557 util.Prioritized(parser.NewCodeSpanParser(), 100), 558 util.Prioritized(parser.NewLinkParser(), 200), 559 util.Prioritized(parser.NewAutoLinkParser(), 300), 560 util.Prioritized(parser.NewEmphasisParser(), 400), 561 util.Prioritized(extension.NewStrikethroughParser(), 500), 562 ), 563 parser.WithParagraphTransformers( 564 util.Prioritized(parser.LinkReferenceParagraphTransformer, 100), 565 ), 566 ) 567 }