Skip to content

Commit

Permalink
LexicalMarkdownPlugin: say hello to markdown exports (#50)
Browse files Browse the repository at this point in the history
Summary:
This is a first pass at implementing Markdown support for lexical.

It depends on Apple's swift-markdown package.

It needs tests and covering more edges cases (embedded lists, indentations, etc) - but is enough to get started with.

I am also working on a markdown import for our use case, which I will add to this plugin in the next day or so.

Pull Request resolved: #50

Differential Revision: D51525285

Pulled By: amyworrall

fbshipit-source-id: 04065ac4fbf79c058e4285a511aa806fe9da8b59
  • Loading branch information
mansimransingh authored and facebook-github-bot committed Nov 22, 2023
1 parent 0328af1 commit cdb0194
Show file tree
Hide file tree
Showing 9 changed files with 346 additions and 9 deletions.
22 changes: 22 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,13 @@ let package = Package(
.library(
name: "EditorHistoryPlugin",
targets: ["EditorHistoryPlugin"]),
.library(
name: "LexicalMarkdown",
targets: ["LexicalMarkdown"]),
],
dependencies: [
.package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.6.0"),
.package(url: "https://github.com/apple/swift-markdown.git", branch: "main"),
],
targets: [
.target(
Expand Down Expand Up @@ -103,5 +107,23 @@ let package = Package(
name: "EditorHistoryPluginTests",
dependencies: ["Lexical", "EditorHistoryPlugin"],
path: "./Plugins/EditorHistoryPlugin/EditorHistoryPluginTests"),

.target(
name: "LexicalMarkdown",
dependencies: [
"Lexical",
"LexicalLinkPlugin",
"LexicalListPlugin",
.product(name: "SwiftMarkdown", package: "swift-markdown")
],
path: "./Plugins/LexicalMarkdown/LexicalMarkdown"),
.testTarget(
name: "LexicalMarkdownTests",
dependencies: [
"Lexical",
"LexicalMarkdown",
.product(name: "SwiftMarkdown", package: "swift-markdown"),
],
path: "./Plugins/LexicalMarkdown/LexicalMarkdownTests"),
]
)
7 changes: 7 additions & 0 deletions Playground/LexicalPlayground.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
06CB47B429DE0139005AC7BD /* NodeHierarchyViewPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06CB47B329DE0139005AC7BD /* NodeHierarchyViewPlugin.swift */; };
06F7CF942A542E4E0024CD5A /* LexicalInlineImagePlugin in Frameworks */ = {isa = PBXBuildFile; productRef = 06F7CF932A542E4E0024CD5A /* LexicalInlineImagePlugin */; };
06F7CF962A545C7C0024CD5A /* lexical-logo.png in Resources */ = {isa = PBXBuildFile; fileRef = 06F7CF952A545C7C0024CD5A /* lexical-logo.png */; };
3C48FEE42B04F2C5009BBFA2 /* LexicalMarkdown in Frameworks */ = {isa = PBXBuildFile; productRef = 3C48FEE32B04F2C5009BBFA2 /* LexicalMarkdown */; };
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
Expand All @@ -45,6 +46,7 @@
buildActionMask = 2147483647;
files = (
0656CFA229D48391009CA08F /* LexicalListPlugin in Frameworks */,
3C48FEE42B04F2C5009BBFA2 /* LexicalMarkdown in Frameworks */,
065B5C862A22291C003A38DB /* SelectableDecoratorNode in Frameworks */,
069E59DB2A1EBF1700CA4296 /* LexicalHTML in Frameworks */,
065F9DF72A5EC1D600C95087 /* EditorHistoryPlugin in Frameworks */,
Expand Down Expand Up @@ -133,6 +135,7 @@
06F7CF932A542E4E0024CD5A /* LexicalInlineImagePlugin */,
065B5C852A22291C003A38DB /* SelectableDecoratorNode */,
065F9DF62A5EC1D600C95087 /* EditorHistoryPlugin */,
3C48FEE32B04F2C5009BBFA2 /* LexicalMarkdown */,
);
productName = LexicalPlayground;
productReference = 0656CF7529D1E438009CA08F /* LexicalPlayground.app */;
Expand Down Expand Up @@ -446,6 +449,10 @@
isa = XCSwiftPackageProductDependency;
productName = LexicalInlineImagePlugin;
};
3C48FEE32B04F2C5009BBFA2 /* LexicalMarkdown */ = {
isa = XCSwiftPackageProductDependency;
productName = LexicalMarkdown;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 0656CF6D29D1E438009CA08F /* Project object */;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
{
"pins" : [
{
"identity" : "swift-cmark",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-cmark.git",
"state" : {
"branch" : "gfm",
"revision" : "3bc2f3e25df0cecc5dc269f7ccae65d0f386f06a"
}
},
{
"identity" : "swift-markdown",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-markdown.git",
"state" : {
"branch" : "main",
"revision" : "c211079c200ce2c5f68160bf75ded005b1c945f1"
}
},
{
"identity" : "swiftsoup",
"kind" : "remoteSourceControl",
Expand Down
32 changes: 30 additions & 2 deletions Playground/LexicalPlayground/ExportOutputViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,21 @@
import Lexical
import LexicalHTML
import LexicalListPlugin
import LexicalMarkdown
import UIKit

internal enum OutputFormat {
internal enum OutputFormat: CaseIterable {
case html
case json
case markdown

var title: String {
switch self {
case .html: return "HTML"
case .json: return "JSON"
case .markdown: return "Markdown"
}
}
}

class ExportOutputViewController: UIViewController {
Expand All @@ -25,6 +35,8 @@ class ExportOutputViewController: UIViewController {
generateHTML(editor: editor)
case .json:
generateJSON(editor: editor)
case .markdown:
generateMarkdown(editor: editor)
}
}

Expand All @@ -34,14 +46,30 @@ class ExportOutputViewController: UIViewController {

func generateHTML(editor: Editor) {
try? editor.read {
self.output = try generateHTMLFromNodes(editor: editor, selection: nil)
do {
self.output = try generateHTMLFromNodes(editor: editor, selection: nil)
} catch let error {
self.output = error.localizedDescription
}
}
}

func generateMarkdown(editor: Editor) {
try? editor.read {
do {
self.output = try LexicalMarkdown.generateMarkdown(from: editor, selection: nil)
} catch let error {
self.output = error.localizedDescription
}
}
}

func generateJSON(editor: Editor) {
let currentEditorState = editor.getEditorState()
if let jsonString = try? currentEditorState.toJSON() {
output = jsonString
} else {
output = "Failed to generate JSON output"
}
}

Expand Down
11 changes: 4 additions & 7 deletions Playground/LexicalPlayground/ViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,14 +117,11 @@ class ViewController: UIViewController, UIToolbarDelegate {
}

func setUpExportMenu() {
let menuItems = [
UIAction(title: "Export HTML", handler: { [weak self] action in
self?.showExportScreen(.html)
}),
UIAction(title: "Export JSON", handler: { [weak self] ction in
self?.showExportScreen(.json)
let menuItems = OutputFormat.allCases.map { outputFormat in
UIAction(title: "Export \(outputFormat.title)", handler: { [weak self] action in
self?.showExportScreen(outputFormat)
})
]
}
let menu = UIMenu(title: "Export as…", children: menuItems)
let barButtonItem = UIBarButtonItem(title: "Export", style: .plain, target: nil, action: nil)
barButtonItem.menu = menu
Expand Down
32 changes: 32 additions & 0 deletions Plugins/LexicalMarkdown/LexicalMarkdown/LexicalMarkdown.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import Foundation
import Lexical
import SwiftMarkdown

open class LexicalMarkdown: Plugin {
public init() {}

weak var editor: Editor?

public func setUp(editor: Editor) {
self.editor = editor
}

public func tearDown() {
}

public class func generateMarkdown(from editor: Editor,
selection: BaseSelection?) throws -> String {
guard let root = editor.getEditorState().getRootNode() else {
return ""
}

return SwiftMarkdown.Document(root.getChildren().exportAsBlockMarkdown()).format()
}
}
175 changes: 175 additions & 0 deletions Plugins/LexicalMarkdown/LexicalMarkdown/LexicalMarkdownSupport.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import Foundation
import Lexical
import LexicalLinkPlugin
import LexicalListPlugin
import SwiftMarkdown

private func makeIndentation(_ count: Int) -> String {
String(repeating: "\u{009}", count: count)
}

public protocol NodeMarkdownBlockSupport: Lexical.Node {
func exportBlockMarkdown() throws -> SwiftMarkdown.BlockMarkup
}

public protocol NodeMarkdownInlineSupport: Lexical.Node {
func exportInlineMarkdown(indentation: Int) throws -> SwiftMarkdown.InlineMarkup
}

extension Lexical.ParagraphNode: NodeMarkdownBlockSupport {
public func exportBlockMarkdown() throws -> SwiftMarkdown.BlockMarkup {
return SwiftMarkdown.Paragraph(getChildren().exportAsInlineMarkdown(indentation: getIndent()))
}
}

extension Lexical.TextNode: NodeMarkdownInlineSupport {
public func exportInlineMarkdown(indentation: Int) throws -> SwiftMarkdown.InlineMarkup {
let format = getFormat()
var node: SwiftMarkdown.InlineMarkup = SwiftMarkdown.Text(makeIndentation(indentation) + getTextPart())

if format.code {
// NOTE (mani) - code must always come first
node = SwiftMarkdown.InlineCode(makeIndentation(indentation) + getTextPart())
}

if format.bold {
node = SwiftMarkdown.Strong(node)
}

if format.strikethrough {
node = SwiftMarkdown.Strikethrough(node)
}

if format.italic {
// TODO (mani) - underline + italic both use Emphasis node
// should we create a separate node?
node = SwiftMarkdown.Emphasis(node)
}

if format.underline {
// TODO (mani) - underline + italic both use Emphasis node
// should we create a separate node?
node = SwiftMarkdown.Emphasis(node)
}

if format.superScript {
// NOTE (mani) - unsupported
}

if format.subScript {
// NOTE (mani) - unsupported
}

return node
}
}

extension LexicalListPlugin.ListNode: NodeMarkdownBlockSupport {
// NOTE (mani) - there are some oddities when converting lists
// especially when lists have sub lists.
// Sometimes Lexical will not realise that the top level list has been deleted
// and so it will look like `List -> ListItem -> List -> [ListItem]` which outputs
// incorrect markdown. Assume indentations are not properly supported.
// Also, no support for checkmarks in Lexical AFAIK.

public func exportBlockMarkdown() throws -> SwiftMarkdown.BlockMarkup {
let children = getChildren().exportAsBlockMarkdown()
.compactMap { $0 as? SwiftMarkdown.ListItem }
switch getListType() {
case .bullet:
return SwiftMarkdown.UnorderedList(children)
case .check:
// TODO (mani) - how does lexical mark a checked item?
return SwiftMarkdown.UnorderedList(children)
case .number:
var list = SwiftMarkdown.OrderedList(children)
let start = getStart()
if start > 0 {
list.startIndex = UInt(start)
}
return list
}
}
}

extension LexicalListPlugin.ListItemNode: NodeMarkdownBlockSupport {
public func exportBlockMarkdown() throws -> SwiftMarkdown.BlockMarkup {
let children: [SwiftMarkdown.BlockMarkup] = getChildren().compactMap {
if let inline = try? ($0 as? NodeMarkdownInlineSupport)?.exportInlineMarkdown(indentation: getIndent()) {
return SwiftMarkdown.Paragraph(inline)
} else {
return try? ($0 as? NodeMarkdownBlockSupport)?.exportBlockMarkdown()
}
}

if let parent = getParent() as? ListNode, parent.getListType() == .check {
// TODO (mani) - how does lexical mark a checked item?
return SwiftMarkdown.ListItem(checkbox: nil, children)
} else {
return SwiftMarkdown.ListItem(children)
}
}
}

extension LexicalLinkPlugin.LinkNode: NodeMarkdownInlineSupport {
public func exportInlineMarkdown(indentation: Int) throws -> SwiftMarkdown.InlineMarkup {
SwiftMarkdown.Link(destination: getURL(),
getChildren()
.exportAsInlineMarkdown(indentation: getIndent())
.compactMap { $0 as? SwiftMarkdown.RecurringInlineMarkup })
}
}

extension Lexical.CodeNode: NodeMarkdownBlockSupport {
public func exportBlockMarkdown() throws -> SwiftMarkdown.BlockMarkup {
// TODO (mani) - do code blocks have formatting?
// TODO (mani) - indentation for codeblocks?
SwiftMarkdown.CodeBlock(getTextContent())
}
}

extension Lexical.LineBreakNode: NodeMarkdownInlineSupport {
public func exportInlineMarkdown(indentation: Int) throws -> SwiftMarkdown.InlineMarkup {
SwiftMarkdown.LineBreak()
}
}

extension Lexical.QuoteNode: NodeMarkdownBlockSupport {
public func exportBlockMarkdown() throws -> SwiftMarkdown.BlockMarkup {
SwiftMarkdown.BlockQuote(
getChildren()
.exportAsInlineMarkdown(indentation: getIndent())
.map {
SwiftMarkdown.Paragraph($0)
}
)
}
}

extension Lexical.HeadingNode: NodeMarkdownBlockSupport {
public func exportBlockMarkdown() throws -> SwiftMarkdown.BlockMarkup {
SwiftMarkdown.Heading(
level: getTag().intValue,
getChildren().exportAsInlineMarkdown(indentation: getIndent())
)
}
}

private extension HeadingTagType {
var intValue: Int {
switch self {
case .h1: return 1
case .h2: return 2
case .h3: return 3
case .h4: return 4
case .h5: return 5
}
}
}
Loading

0 comments on commit cdb0194

Please sign in to comment.