From a80874c41fee7bdfd31b6b51b8c9f88cad11aa50 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 10:19:36 +0100 Subject: [PATCH 001/201] feat: expand native editor fidelity --- .../Editor/NativeEditorBlockKind.swift | 11 +- .../Editor/NativeEditorBodyView.swift | 6 +- .../Editor/NativeEditorCommand+Behavior.swift | 28 ++ .../NativeEditorCommand+BlockKind.swift | 128 ++++++ .../NativeEditorCommand+RichBlocks.swift | 120 ++++- .../Features/Editor/NativeEditorCommand.swift | 296 +++++++++--- .../NativeEditorDocument+Decoding.swift | 2 + .../NativeEditorDocument+Encoding.swift | 18 +- .../NativeEditorDocument+Payloads.swift | 28 +- .../NativeEditorDocument+PreviewText.swift | 6 +- ...iveEditorMarkdownParser+DocmostLinks.swift | 264 +++++++++++ ...tiveEditorMarkdownParser+InlineMarks.swift | 42 ++ ...ativeEditorMarkdownParser+RichBlocks.swift | 427 ++++++++++++++++++ .../Editor/NativeEditorMarkdownParser.swift | 91 +++- .../NativeEditorRichBlockPayloads.swift | 59 +++ .../NativeEditorRichBlockPreviewView.swift | 10 +- .../Editor/NativeEditorSlashCommandMenu.swift | 7 +- ...EditorUploadedAttachmentBlockFactory.swift | 24 +- ...tiveRichEditorViewModel+BlockEditing.swift | 71 ++- ...ativeRichEditorViewModel+MediaBlocks.swift | 4 + ...NativeRichEditorViewModel+RichBlocks.swift | 9 + .../NativeRichEditorViewModel+Tables.swift | 13 + .../Features/PageReader/PageReaderView.swift | 3 +- .../Editor/NativeEditorDocumentFixtures.swift | 1 + .../Editor/NativeEditorDocumentTests.swift | 1 + ...ativeEditorInlineMarkdownExportTests.swift | 54 +++ .../NativeEditorMentionMarkdownTests.swift | 75 +++ .../NativeEditorRichMarkdownExportTests.swift | 191 ++++++++ .../NativeEditorSlashCommandTests.swift | 140 ++++++ .../NativeEditorTablePayloadTests.swift | 56 +++ .../NativeRichEditorMechanicsTests.swift | 91 ++++ .../NativeRichEditorMediaBlockTests.swift | 43 +- .../Editor/NativeRichEditorTableTests.swift | 34 ++ .../NativeRichEditorViewModelTests.swift | 194 +++++++- 34 files changed, 2418 insertions(+), 129 deletions(-) create mode 100644 docmostly/Features/Editor/NativeEditorCommand+Behavior.swift create mode 100644 docmostly/Features/Editor/NativeEditorCommand+BlockKind.swift create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift create mode 100644 docmostlyTests/Editor/NativeEditorInlineMarkdownExportTests.swift create mode 100644 docmostlyTests/Editor/NativeEditorMentionMarkdownTests.swift create mode 100644 docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift create mode 100644 docmostlyTests/Editor/NativeEditorSlashCommandTests.swift create mode 100644 docmostlyTests/Editor/NativeEditorTablePayloadTests.swift diff --git a/docmostly/Features/Editor/NativeEditorBlockKind.swift b/docmostly/Features/Editor/NativeEditorBlockKind.swift index 416663b..74a2822 100644 --- a/docmostly/Features/Editor/NativeEditorBlockKind.swift +++ b/docmostly/Features/Editor/NativeEditorBlockKind.swift @@ -22,6 +22,7 @@ nonisolated enum NativeEditorBlockKind: Equatable, Sendable { case subpages case transclusionSource(NativeEditorTransclusionSourceBlock) case transclusionReference(NativeEditorTransclusionReferenceBlock) + case base(NativeEditorBaseBlock) case embed(NativeEditorEmbedBlock) case drawio(NativeEditorDiagramBlock) case excalidraw(NativeEditorDiagramBlock) @@ -33,8 +34,8 @@ nonisolated enum NativeEditorBlockKind: Equatable, Sendable { case .paragraph, .heading, .bulletListItem, .orderedListItem, .taskListItem, .blockquote, .codeBlock: return true case .table, .image, .video, .audio, .pdf, .attachment, .callout, .details, .pageBreak, .divider, - .columns, .subpages, .transclusionSource, .transclusionReference, .embed, .drawio, .excalidraw, - .mathBlock, .unsupported: + .columns, .subpages, .transclusionSource, .transclusionReference, .base, .embed, .drawio, + .excalidraw, .mathBlock, .unsupported: return false } } @@ -48,8 +49,8 @@ nonisolated enum NativeEditorBlockKind: Equatable, Sendable { case .paragraph, .bulletListItem, .orderedListItem, .taskListItem, .blockquote: .body case .table, .image, .video, .audio, .pdf, .attachment, .callout, .details, .pageBreak, .divider, - .columns, .subpages, .transclusionSource, .transclusionReference, .embed, .drawio, .excalidraw, - .mathBlock, .unsupported: + .columns, .subpages, .transclusionSource, .transclusionReference, .base, .embed, .drawio, + .excalidraw, .mathBlock, .unsupported: .body } } @@ -98,6 +99,8 @@ nonisolated enum NativeEditorBlockKind: Equatable, Sendable { "Synced block" case .transclusionReference: "Synced block reference" + case .base(let base): + base.pageID == nil ? base.previewText : "Base" case .embed(let embed): embed.provider.map { "\($0) embed" } ?? "Embed" case .drawio: diff --git a/docmostly/Features/Editor/NativeEditorBodyView.swift b/docmostly/Features/Editor/NativeEditorBodyView.swift index 9bbc0c8..072e0d3 100644 --- a/docmostly/Features/Editor/NativeEditorBodyView.swift +++ b/docmostly/Features/Editor/NativeEditorBodyView.swift @@ -4,6 +4,7 @@ struct NativeEditorBodyView: View { @Bindable var viewModel: NativeRichEditorViewModel let focusedField: FocusState.Binding var isAuthoringEnabled = true + var importAttachment: (NativeEditorAttachmentImportKind) -> Void = { _ in } var body: some View { LazyVStack(alignment: .leading, spacing: 10) { @@ -73,7 +74,10 @@ struct NativeEditorBodyView: View { } if authoringIsAvailable, viewModel.activeBlockID == block.id, viewModel.isShowingSlashCommands { - NativeEditorSlashCommandMenu(viewModel: viewModel) + NativeEditorSlashCommandMenu( + viewModel: viewModel, + importAttachment: importAttachment + ) .padding(.leading, 34) } diff --git a/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift b/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift new file mode 100644 index 0000000..0d81972 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift @@ -0,0 +1,28 @@ +import Foundation + +extension NativeEditorCommand { + var attachmentImportKind: NativeEditorAttachmentImportKind? { + switch self { + case .image: + .image + case .video: + .video + case .audio: + .audio + case .pdf: + .pdf + case .fileAttachment: + .file + default: + nil + } + } + + func matches(query: String) -> Bool { + guard query.isEmpty == false else { return true } + + return title.localizedStandardContains(query) || + subtitle.localizedStandardContains(query) || + rawValue.localizedStandardContains(query) + } +} diff --git a/docmostly/Features/Editor/NativeEditorCommand+BlockKind.swift b/docmostly/Features/Editor/NativeEditorCommand+BlockKind.swift new file mode 100644 index 0000000..c31913c --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorCommand+BlockKind.swift @@ -0,0 +1,128 @@ +import Foundation + +extension NativeEditorCommand { + var blockKind: NativeEditorBlockKind { + switch self { + case .paragraph: + .paragraph + case .heading1: + .heading(level: 1) + case .heading2: + .heading(level: 2) + case .heading3: + .heading(level: 3) + case .bulletedList: + .bulletListItem + case .numberedList: + .orderedListItem(ordinal: 1) + case .todoList: + .taskListItem(isChecked: false) + case .quote: + .blockquote + case .codeBlock: + .codeBlock(language: nil) + case .image: + .image(NativeEditorMediaBlock.placeholder) + case .video: + .video(NativeEditorMediaBlock.placeholder) + case .audio: + .audio(NativeEditorMediaBlock.placeholder) + case .pdf: + .pdf(NativeEditorPDFBlock.placeholder) + case .fileAttachment: + .attachment(NativeEditorAttachmentBlock.placeholder) + case .table: + .table(NativeEditorTable(rows: defaultTableRows)) + case .baseInline: + .base(NativeEditorBaseBlock(pageID: nil, pendingKey: nil, previewText: "Base")) + case .kanban: + .base(NativeEditorBaseBlock(pageID: nil, pendingKey: nil, previewText: "Kanban")) + case .callout: + .callout(NativeEditorCalloutBlock(style: "info", icon: "lightbulb", previewText: "Callout")) + case .details: + .details(NativeEditorDetailsBlock(summary: "Details", previewText: "Details", isOpen: true)) + case .mathInline: + .paragraph + case .pageBreak: + .pageBreak + case .divider: + .divider + case .columns: + columnsBlock(layout: "two_equal", columnCount: 2) + case .columns3: + columnsBlock(layout: "three_equal", columnCount: 3) + case .columns4: + columnsBlock(layout: "four_equal", columnCount: 4) + case .columns5: + columnsBlock(layout: "five_equal", columnCount: 5) + case .subpages: + .subpages + case .syncedBlock: + .transclusionSource(NativeEditorTransclusionSourceBlock( + identifier: "sync", + previewText: "Synced block" + )) + case .embed: + .embed(NativeEditorEmbedBlock( + source: "https://example.com", + provider: "Embed", + alignment: nil, + width: nil, + height: nil + )) + case .iframeEmbed: + embedBlock(provider: "iframe") + case .airtableEmbed: + embedBlock(provider: "airtable") + case .loomEmbed: + embedBlock(provider: "loom") + case .figmaEmbed: + embedBlock(provider: "figma") + case .typeformEmbed: + embedBlock(provider: "typeform") + case .miroEmbed: + embedBlock(provider: "miro") + case .youtubeEmbed: + embedBlock(provider: "youtube") + case .vimeoEmbed: + embedBlock(provider: "vimeo") + case .framerEmbed: + embedBlock(provider: "framer") + case .googleDriveEmbed: + embedBlock(provider: "gdrive") + case .googleSheetsEmbed: + embedBlock(provider: "gsheets") + case .mathBlock: + .mathBlock(NativeEditorMathBlock(text: "E = mc^2")) + case .mermaid: + .codeBlock(language: "mermaid") + case .drawio: + .drawio(NativeEditorDiagramBlock.placeholder) + case .excalidraw: + .excalidraw(NativeEditorDiagramBlock.placeholder) + case .date, .time, .status, .emoji: + .paragraph + } + } + + private func columnsBlock(layout: String, columnCount: Int) -> NativeEditorBlockKind { + let labels = (1...columnCount).map { "Column \($0)" } + return .columns(NativeEditorColumnsBlock( + layout: layout, + widthMode: "wide", + columnCount: columnCount, + previewText: labels.joined(separator: " "), + columnTexts: labels + )) + } + + private func embedBlock(provider: String) -> NativeEditorBlockKind { + .embed(NativeEditorEmbedBlock( + source: nil, + provider: provider, + alignment: nil, + width: nil, + height: nil + )) + } +} diff --git a/docmostly/Features/Editor/NativeEditorCommand+RichBlocks.swift b/docmostly/Features/Editor/NativeEditorCommand+RichBlocks.swift index 75d11a6..f3ece32 100644 --- a/docmostly/Features/Editor/NativeEditorCommand+RichBlocks.swift +++ b/docmostly/Features/Editor/NativeEditorCommand+RichBlocks.swift @@ -3,6 +3,10 @@ import SwiftUI extension NativeEditorCommand { func replacementBlock(reusing id: UUID) -> NativeEditorBlock? { + if let mediaBlock = mediaReplacementBlock(reusing: id) { + return mediaBlock + } + if let structuralBlock = structuralReplacementBlock(reusing: id) { return structuralBlock } @@ -14,10 +18,41 @@ extension NativeEditorCommand { return textReplacementBlock(reusing: id) } + private func mediaReplacementBlock(reusing id: UUID) -> NativeEditorBlock? { + switch self { + case .image: + mediaBlock(reusing: id, type: "image", kind: blockKind) + case .video: + mediaBlock(reusing: id, type: "video", kind: blockKind) + case .audio: + mediaBlock(reusing: id, type: "audio", kind: blockKind) + case .pdf: + richBlock( + id: id, + kind: blockKind, + rawNode: NativeEditorRichBlockNodeFactory.pdfNode(from: .placeholder) + ) + case .fileAttachment: + richBlock( + id: id, + kind: blockKind, + rawNode: NativeEditorRichBlockNodeFactory.attachmentNode(from: .placeholder) + ) + default: + nil + } + } + private func structuralReplacementBlock(reusing id: UUID) -> NativeEditorBlock? { switch self { case .table: richBlock(id: id, kind: blockKind, rawNode: tableNode) + case .baseInline, .kanban: + richBlock( + id: id, + kind: blockKind, + rawNode: NativeEditorRichBlockNodeFactory.baseNode(from: baseBlock) + ) case .callout: richBlock(id: id, kind: blockKind, rawNode: calloutNode) case .details: @@ -26,8 +61,12 @@ extension NativeEditorCommand { richBlock(id: id, kind: blockKind, rawNode: ProseMirrorNode(type: "pageBreak")) case .divider: richBlock(id: id, kind: blockKind, rawNode: ProseMirrorNode(type: "horizontalRule")) - case .columns: - richBlock(id: id, kind: blockKind, rawNode: columnsNode) + case .columns, .columns3, .columns4, .columns5: + richBlock( + id: id, + kind: blockKind, + rawNode: NativeEditorRichBlockNodeFactory.columnsNode(from: columnsBlock) + ) case .subpages: richBlock(id: id, kind: blockKind, rawNode: ProseMirrorNode(type: "subpages")) case .syncedBlock: @@ -39,10 +78,19 @@ extension NativeEditorCommand { private func embeddedReplacementBlock(reusing id: UUID) -> NativeEditorBlock? { switch self { - case .embed: - richBlock(id: id, kind: blockKind, rawNode: embedNode) + case .embed, .iframeEmbed, .airtableEmbed, .loomEmbed, .figmaEmbed, .typeformEmbed, .miroEmbed, + .youtubeEmbed, .vimeoEmbed, .framerEmbed, .googleDriveEmbed, .googleSheetsEmbed: + richBlock( + id: id, + kind: blockKind, + rawNode: NativeEditorRichBlockNodeFactory.embedNode(from: embedBlock) + ) case .mathBlock: richBlock(id: id, kind: blockKind, rawNode: mathBlockNode) + case .drawio: + richBlock(id: id, kind: blockKind, rawNode: diagramNode(type: "drawio")) + case .excalidraw: + richBlock(id: id, kind: blockKind, rawNode: diagramNode(type: "excalidraw")) default: nil } @@ -72,6 +120,14 @@ extension NativeEditorCommand { ] } + private var baseBlock: NativeEditorBaseBlock { + if case .base(let base) = blockKind { + return base + } + + return NativeEditorBaseBlock(pageID: nil, pendingKey: nil, previewText: "Base") + } + private var defaultSyncedBlockID: String { "sync-\(UUID().uuidString)" } @@ -86,6 +142,18 @@ extension NativeEditorCommand { ) } + private func mediaBlock(id: UUID, type: String, kind: NativeEditorBlockKind) -> NativeEditorBlock { + richBlock( + id: id, + kind: kind, + rawNode: NativeEditorRichBlockNodeFactory.mediaNode(from: .placeholder, type: type) + ) + } + + private func mediaBlock(reusing id: UUID, type: String, kind: NativeEditorBlockKind) -> NativeEditorBlock { + mediaBlock(id: id, type: type, kind: kind) + } + private var tableNode: ProseMirrorNode { ProseMirrorNode(type: "table", content: [ tableRowNode(cellType: "tableHeader", texts: ["Column 1", "Column 2"]), @@ -127,22 +195,17 @@ extension NativeEditorCommand { ) } - private var columnsNode: ProseMirrorNode { - ProseMirrorNode( - type: "columns", - attrs: ["layout": .string("two_equal")], - content: [ - columnNode(text: "Left"), - columnNode(text: "Right") - ] - ) - } + private var columnsBlock: NativeEditorColumnsBlock { + if case .columns(let columns) = blockKind { + return columns + } - private func columnNode(text: String) -> ProseMirrorNode { - ProseMirrorNode( - type: "column", - attrs: ["width": .int(1)], - content: [paragraphNode(text)] + return NativeEditorColumnsBlock( + layout: "two_equal", + widthMode: "wide", + columnCount: 2, + previewText: "Column 1 Column 2", + columnTexts: ["Column 1", "Column 2"] ) } @@ -166,10 +229,17 @@ extension NativeEditorCommand { ) } - private var embedNode: ProseMirrorNode { - ProseMirrorNode( - type: "embed", - attrs: ["src": .string("https://example.com"), "provider": .string("Embed")] + private var embedBlock: NativeEditorEmbedBlock { + if case .embed(let embed) = blockKind { + return embed + } + + return NativeEditorEmbedBlock( + source: nil, + provider: "Embed", + alignment: nil, + width: nil, + height: nil ) } @@ -177,6 +247,10 @@ extension NativeEditorCommand { ProseMirrorNode(type: "mathBlock", attrs: ["text": .string("E = mc^2")]) } + private func diagramNode(type: String) -> ProseMirrorNode { + NativeEditorRichBlockNodeFactory.diagramNode(from: .placeholder, type: type) + } + private func paragraphNode(_ text: String) -> ProseMirrorNode { ProseMirrorNode( type: "paragraph", diff --git a/docmostly/Features/Editor/NativeEditorCommand.swift b/docmostly/Features/Editor/NativeEditorCommand.swift index 6161e67..cbb1cf4 100644 --- a/docmostly/Features/Editor/NativeEditorCommand.swift +++ b/docmostly/Features/Editor/NativeEditorCommand.swift @@ -4,22 +4,51 @@ enum NativeEditorCommand: String, CaseIterable, Identifiable { case paragraph case heading1 case heading2 + case heading3 case bulletedList case numberedList case todoList case quote case codeBlock + case image + case video + case audio + case pdf + case fileAttachment case table + case baseInline + case kanban case callout case details + case mathInline case pageBreak case divider case columns + case columns3 + case columns4 + case columns5 case subpages case syncedBlock case embed + case iframeEmbed + case airtableEmbed + case loomEmbed + case figmaEmbed + case typeformEmbed + case miroEmbed + case youtubeEmbed + case vimeoEmbed + case framerEmbed + case googleDriveEmbed + case googleSheetsEmbed case mathBlock case mermaid + case drawio + case excalidraw + case date + case time + case status + case emoji var id: String { rawValue } @@ -27,6 +56,7 @@ enum NativeEditorCommand: String, CaseIterable, Identifiable { .paragraph, .heading1, .heading2, + .heading3, .bulletedList, .numberedList, .todoList, @@ -35,17 +65,40 @@ enum NativeEditorCommand: String, CaseIterable, Identifiable { ] static let richCases: [NativeEditorCommand] = [ + .image, + .video, + .audio, + .pdf, + .fileAttachment, .table, + .baseInline, + .kanban, .callout, .details, .pageBreak, .divider, .columns, + .columns3, + .columns4, + .columns5, .subpages, .syncedBlock, .embed, + .iframeEmbed, + .airtableEmbed, + .loomEmbed, + .figmaEmbed, + .typeformEmbed, + .miroEmbed, + .youtubeEmbed, + .vimeoEmbed, + .framerEmbed, + .googleDriveEmbed, + .googleSheetsEmbed, .mathBlock, - .mermaid + .mermaid, + .drawio, + .excalidraw ] var title: String { @@ -56,6 +109,8 @@ enum NativeEditorCommand: String, CaseIterable, Identifiable { "Heading 1" case .heading2: "Heading 2" + case .heading3: + "Heading 3" case .bulletedList: "Bulleted List" case .numberedList: @@ -66,28 +121,84 @@ enum NativeEditorCommand: String, CaseIterable, Identifiable { "Quote" case .codeBlock: "Code Block" + case .image: + "Image" + case .video: + "Video" + case .audio: + "Audio" + case .pdf: + "PDF" + case .fileAttachment: + "File" case .table: "Table" + case .baseInline: + "Base (Inline)" + case .kanban: + "Kanban" case .callout: "Callout" case .details: "Details" + case .mathInline: + "Math Inline" case .pageBreak: "Page Break" case .divider: "Divider" case .columns: - "Columns" + "2 Columns" + case .columns3: + "3 Columns" + case .columns4: + "4 Columns" + case .columns5: + "5 Columns" case .subpages: "Subpages" case .syncedBlock: "Synced Block" case .embed: "Embed" + case .iframeEmbed: + "Iframe embed" + case .airtableEmbed: + "Airtable" + case .loomEmbed: + "Loom" + case .figmaEmbed: + "Figma" + case .typeformEmbed: + "Typeform" + case .miroEmbed: + "Miro" + case .youtubeEmbed: + "YouTube" + case .vimeoEmbed: + "Vimeo" + case .framerEmbed: + "Framer" + case .googleDriveEmbed: + "Google Drive" + case .googleSheetsEmbed: + "Google Sheets" case .mathBlock: "Math Block" case .mermaid: "Mermaid" + case .drawio: + "Draw.io" + case .excalidraw: + "Excalidraw" + case .date: + "Date" + case .time: + "Time" + case .status: + "Status" + case .emoji: + "Emoji" } } @@ -99,6 +210,8 @@ enum NativeEditorCommand: String, CaseIterable, Identifiable { "Large section heading" case .heading2: "Medium section heading" + case .heading3: + "Small section heading" case .bulletedList: "Simple unordered list" case .numberedList: @@ -109,28 +222,84 @@ enum NativeEditorCommand: String, CaseIterable, Identifiable { "Quoted callout" case .codeBlock: "Preformatted code" + case .image: + "Image placeholder" + case .video: + "Video placeholder" + case .audio: + "Audio placeholder" + case .pdf: + "PDF placeholder" + case .fileAttachment: + "File attachment placeholder" case .table: "Two-column table" + case .baseInline: + "Inline base placeholder" + case .kanban: + "Kanban base placeholder" case .callout: "Highlighted note" case .details: "Collapsible detail section" + case .mathInline: + "Inline equation" case .pageBreak: "Print page break" case .divider: "Horizontal divider" case .columns: "Two-column layout" + case .columns3: + "Three-column layout" + case .columns4: + "Four-column layout" + case .columns5: + "Five-column layout" case .subpages: "Child page list" case .syncedBlock: "Reusable synced content" case .embed: "External URL embed" + case .iframeEmbed: + "Iframe embed" + case .airtableEmbed: + "Airtable embed" + case .loomEmbed: + "Loom video embed" + case .figmaEmbed: + "Figma file embed" + case .typeformEmbed: + "Typeform embed" + case .miroEmbed: + "Miro board embed" + case .youtubeEmbed: + "YouTube video embed" + case .vimeoEmbed: + "Vimeo video embed" + case .framerEmbed: + "Framer prototype embed" + case .googleDriveEmbed: + "Google Drive embed" + case .googleSheetsEmbed: + "Google Sheets embed" case .mathBlock: "Display equation" case .mermaid: "Mermaid diagram code" + case .drawio: + "Draw.io diagram" + case .excalidraw: + "Excalidraw whiteboard" + case .date: + "Insert current date" + case .time: + "Insert current time" + case .status: + "Inline status badge" + case .emoji: + "Start emoji entry" } } @@ -142,6 +311,8 @@ enum NativeEditorCommand: String, CaseIterable, Identifiable { "h1" case .heading2: "h2" + case .heading3: + "h3" case .bulletedList: "list.bullet" case .numberedList: @@ -152,94 +323,85 @@ enum NativeEditorCommand: String, CaseIterable, Identifiable { "quote.opening" case .codeBlock: "curlybraces" + case .image: + "photo" + case .video: + "play.rectangle" + case .audio: + "waveform" + case .pdf: + "doc.richtext" + case .fileAttachment: + "paperclip" case .table: "tablecells" + case .baseInline: + "tablecells.badge.ellipsis" + case .kanban: + "rectangle.3.group" case .callout: "lightbulb" case .details: "chevron.right.circle" + case .mathInline: + "x.squareroot" case .pageBreak: "doc.text" case .divider: "minus" case .columns: "square.split.2x1" + case .columns3: + "square.split.2x2" + case .columns4: + "square.grid.2x2" + case .columns5: + "square.grid.3x3" case .subpages: "doc.on.doc" case .syncedBlock: "arrow.triangle.2.circlepath" case .embed: "link.badge.plus" + case .iframeEmbed: + "appwindow" + case .airtableEmbed: + "tablecells" + case .loomEmbed: + "video" + case .figmaEmbed: + "paintpalette" + case .typeformEmbed: + "list.clipboard" + case .miroEmbed: + "rectangle.and.pencil.and.ellipsis" + case .youtubeEmbed: + "play.rectangle" + case .vimeoEmbed: + "video.badge.waveform" + case .framerEmbed: + "shippingbox" + case .googleDriveEmbed: + "externaldrive" + case .googleSheetsEmbed: + "tablecells" case .mathBlock: "function" case .mermaid: "point.3.connected.trianglepath.dotted" + case .drawio: + "flowchart" + case .excalidraw: + "scribble.variable" + case .date: + "calendar" + case .time: + "clock" + case .status: + "tag" + case .emoji: + "face.smiling" } } - var blockKind: NativeEditorBlockKind { - switch self { - case .paragraph: - .paragraph - case .heading1: - .heading(level: 1) - case .heading2: - .heading(level: 2) - case .bulletedList: - .bulletListItem - case .numberedList: - .orderedListItem(ordinal: 1) - case .todoList: - .taskListItem(isChecked: false) - case .quote: - .blockquote - case .codeBlock: - .codeBlock(language: nil) - case .table: - .table(NativeEditorTable(rows: defaultTableRows)) - case .callout: - .callout(NativeEditorCalloutBlock(style: "info", icon: "lightbulb", previewText: "Callout")) - case .details: - .details(NativeEditorDetailsBlock(summary: "Details", previewText: "Details", isOpen: true)) - case .pageBreak: - .pageBreak - case .divider: - .divider - case .columns: - .columns(NativeEditorColumnsBlock( - layout: "two_equal", - widthMode: "wide", - columnCount: 2, - previewText: "Left Right", - columnTexts: ["Left", "Right"] - )) - case .subpages: - .subpages - case .syncedBlock: - .transclusionSource(NativeEditorTransclusionSourceBlock( - identifier: "sync", - previewText: "Synced block" - )) - case .embed: - .embed(NativeEditorEmbedBlock( - source: "https://example.com", - provider: "Embed", - alignment: nil, - width: nil, - height: nil - )) - case .mathBlock: - .mathBlock(NativeEditorMathBlock(text: "E = mc^2")) - case .mermaid: - .codeBlock(language: "mermaid") - } - } - - func matches(query: String) -> Bool { - guard query.isEmpty == false else { return true } - - return title.localizedStandardContains(query) || - subtitle.localizedStandardContains(query) || - rawValue.localizedStandardContains(query) - } } diff --git a/docmostly/Features/Editor/NativeEditorDocument+Decoding.swift b/docmostly/Features/Editor/NativeEditorDocument+Decoding.swift index c0677f6..bd5b33e 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+Decoding.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+Decoding.swift @@ -204,6 +204,8 @@ nonisolated extension NativeEditorDocument { richBlock(kind: .transclusionSource(transclusionSourceBlock(from: node)), node: node) case "transclusionReference": richBlock(kind: .transclusionReference(transclusionReferenceBlock(from: node)), node: node) + case "base": + richBlock(kind: .base(baseBlock(from: node)), node: node) default: nil } diff --git a/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift b/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift index 92561c8..14aa881 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift @@ -233,6 +233,8 @@ nonisolated extension NativeEditorDocument { NativeEditorRichBlockNodeFactory.transclusionSourceNode(from: source) case .transclusionReference(let reference): NativeEditorRichBlockNodeFactory.transclusionReferenceNode(from: reference) + case .base(let base): + NativeEditorRichBlockNodeFactory.baseNode(from: base) default: nil } @@ -240,14 +242,14 @@ nonisolated extension NativeEditorDocument { private static func embeddedFallbackNode(from block: NativeEditorBlock) -> ProseMirrorNode { switch block.kind { - case .embed: - ProseMirrorNode(type: "embed") - case .drawio: - ProseMirrorNode(type: "drawio") - case .excalidraw: - ProseMirrorNode(type: "excalidraw") - case .mathBlock: - ProseMirrorNode(type: "mathBlock") + case .embed(let embed): + NativeEditorRichBlockNodeFactory.embedNode(from: embed) + case .drawio(let diagram): + NativeEditorRichBlockNodeFactory.diagramNode(from: diagram, type: "drawio") + case .excalidraw(let diagram): + NativeEditorRichBlockNodeFactory.diagramNode(from: diagram, type: "excalidraw") + case .mathBlock(let math): + NativeEditorRichBlockNodeFactory.mathBlockNode(from: math) case .unsupported: textContainerNode(type: "paragraph", block: block) default: diff --git a/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift b/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift index fa7ed1c..a545e16 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift @@ -17,6 +17,7 @@ nonisolated extension NativeEditorDocument { NativeEditorMediaBlock( source: node.attrs?["src"]?.stringValue, alternativeText: node.attrs?["alt"]?.stringValue, + title: node.attrs?["title"]?.stringValue, attachmentID: node.attrs?["attachmentId"]?.stringValue, sizeInBytes: node.attrs?["size"]?.intValue, width: node.attrs?["width"]?.displayString, @@ -97,6 +98,15 @@ nonisolated extension NativeEditorDocument { ) } + static func baseBlock(from node: ProseMirrorNode) -> NativeEditorBaseBlock { + let pageID = node.attrs?["pageId"]?.stringValue + return NativeEditorBaseBlock( + pageID: pageID, + pendingKey: node.attrs?["pendingKey"]?.stringValue, + previewText: "Base" + ) + } + static func embedBlock(from node: ProseMirrorNode) -> NativeEditorEmbedBlock { NativeEditorEmbedBlock( source: node.attrs?["src"]?.stringValue, @@ -151,28 +161,36 @@ nonisolated extension NativeEditorDocument { .filter { cellTypes.contains($0.type) } .prefix(NativeEditorTable.maximumColumnCount) .map { cell in + let columnWidths = tableColumnWidths(from: cell.attrs) NativeEditorTableCell( plainText: plainText(in: cell.content ?? []), isHeader: cell.type == "tableHeader", backgroundColorName: cell.attrs?["backgroundColorName"]?.stringValue, - columnWidth: tableColumnWidth(from: cell.attrs) + columnWidth: columnWidths.first, + columnSpan: normalizedTableSpan(cell.attrs?["colspan"]?.intValue), + rowSpan: normalizedTableSpan(cell.attrs?["rowspan"]?.intValue), + columnWidths: columnWidths ) } } - private static func tableColumnWidth(from attrs: [String: ProseMirrorJSONValue]?) -> Int? { + private static func tableColumnWidths(from attrs: [String: ProseMirrorJSONValue]?) -> [Int] { guard let value = attrs?["colwidth"] ?? attrs?["colWidth"] else { - return nil + return [] } switch value { case .array(let values): - return values.compactMap(\.intValue).first + return values.compactMap(\.intValue) default: - return value.intValue + return value.intValue.map { [$0] } ?? [] } } + private static func normalizedTableSpan(_ value: Int?) -> Int { + max(value ?? 1, 1) + } + private static func youtubeProviderName(for node: ProseMirrorNode) -> String? { node.type == "youtube" ? "YouTube" : nil } diff --git a/docmostly/Features/Editor/NativeEditorDocument+PreviewText.swift b/docmostly/Features/Editor/NativeEditorDocument+PreviewText.swift index d4e27a9..e0c7d04 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+PreviewText.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+PreviewText.swift @@ -22,9 +22,9 @@ nonisolated extension NativeEditorDocument { case .table(let table): "\(table.rows.count) rows, \(table.columnCount) columns" case .image(let media), .video(let media): - media.alternativeText ?? media.source ?? kind.accessibilityLabel + media.alternativeText ?? media.title ?? media.source ?? kind.accessibilityLabel case .audio(let media): - media.source ?? "Audio" + media.title ?? media.source ?? "Audio" case .pdf(let pdf): pdf.name ?? pdf.source ?? "PDF" case .attachment(let attachment): @@ -52,6 +52,8 @@ nonisolated extension NativeEditorDocument { source.previewText case .transclusionReference(let reference): reference.transclusionID ?? reference.sourcePageID ?? "Synced block reference" + case .base(let base): + base.pageID ?? base.previewText default: nil } diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift new file mode 100644 index 0000000..e08411a --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift @@ -0,0 +1,264 @@ +import Foundation + +extension NativeEditorMarkdownParser { + private struct DocmostPageLink { + var slugID: String + var label: String + var anchorID: String? + } + + private struct DocmostMarkdownLink { + var range: Range + var label: String + var pageLink: DocmostPageLink + } + + static func appendMarkdownText(_ markdown: String, to result: inout AttributedString) { + guard markdown.isEmpty == false else { return } + + var remaining = markdown[...] + while let link = nextDocmostPageMarkdownLink(in: remaining) { + appendMarkdownTextWithBareDocmostPageLinks(String(remaining[.. String { + guard mention.entityType == "page", let slugID = mention.slugID, slugID.isEmpty == false else { + return fallbackText + } + + let label = escapedMarkdownLinkText(mention.label ?? fallbackText) + let anchor = mention.anchorID.map { "#\($0)" } ?? "" + return "[\(label)](/p/\(slugID)\(anchor))" + } + + private static func appendMarkdownTextWithBareDocmostPageLinks( + _ markdown: String, + to result: inout AttributedString + ) { + guard markdown.isEmpty == false else { return } + + var remaining = markdown[...] + while let link = nextBareDocmostPageLink(in: remaining) { + appendPlainMarkdownText(String(remaining[.. String { + let attributedText = (try? AttributedString(markdown: markdown)) ?? AttributedString(markdown) + return String(attributedText.characters) + } + + private static func nextDocmostPageMarkdownLink(in markdown: Substring) -> DocmostMarkdownLink? { + var searchStart = markdown.startIndex + + while searchStart < markdown.endIndex, + let openLabelIndex = markdown[searchStart...].firstIndex(of: "[") { + if isImageMarkdownMarker(before: openLabelIndex, in: markdown) { + searchStart = markdown.index(after: openLabelIndex) + continue + } + + guard + let closeLabelIndex = markdown[markdown.index(after: openLabelIndex)...].firstIndex(of: "]"), + markdown.index(after: closeLabelIndex) < markdown.endIndex, + markdown[markdown.index(after: closeLabelIndex)] == "(", + let closeDestinationIndex = markdown[ + markdown.index(after: markdown.index(after: closeLabelIndex))... + ].firstIndex(of: ")") + else { + return nil + } + + let destinationStartIndex = markdown.index(after: markdown.index(after: closeLabelIndex)) + let destination = String(markdown[destinationStartIndex.. DocmostMarkdownLink? { + var searchStart = markdown.startIndex + + while searchStart < markdown.endIndex, + let tokenRange = nextMarkdownTokenRange(in: markdown, startingAt: searchStart) { + let candidateRange = trimmedBareLinkRange(tokenRange, in: markdown) + if candidateRange.isEmpty == false { + let candidate = String(markdown[candidateRange]) + if let pageLink = docmostPageLink(from: candidate) { + return DocmostMarkdownLink( + range: candidateRange, + label: pageLink.label, + pageLink: pageLink + ) + } + } + + searchStart = tokenRange.upperBound + } + + return nil + } + + private static func nextMarkdownTokenRange( + in markdown: Substring, + startingAt searchStart: String.Index + ) -> Range? { + var tokenStart = searchStart + + while tokenStart < markdown.endIndex, markdown[tokenStart].isWhitespace { + tokenStart = markdown.index(after: tokenStart) + } + + guard tokenStart < markdown.endIndex else { return nil } + + var tokenEnd = tokenStart + while tokenEnd < markdown.endIndex, markdown[tokenEnd].isWhitespace == false { + tokenEnd = markdown.index(after: tokenEnd) + } + + return tokenStart.., + in markdown: Substring + ) -> Range { + var lowerBound = range.lowerBound + var upperBound = range.upperBound + + while lowerBound < upperBound, + markdown[lowerBound].isBareLinkBoundaryPunctuation { + lowerBound = markdown.index(after: lowerBound) + } + + while lowerBound < upperBound { + let previousIndex = markdown.index(before: upperBound) + guard markdown[previousIndex].isBareLinkBoundaryPunctuation else { break } + upperBound = previousIndex + } + + return lowerBound.. Bool { + guard index > markdown.startIndex else { return false } + return markdown[markdown.index(before: index)] == "!" + } + + private static func docmostPageLink(from destination: String) -> DocmostPageLink? { + let source = markdownLinkDestination(from: destination) + guard source.isEmpty == false else { return nil } + + let components = URLComponents(string: source) + let path = components?.path ?? source.components(separatedBy: "#").first ?? source + let anchorID = components?.fragment? + .components(separatedBy: "#") + .first + .flatMap { $0.isEmpty ? nil : $0 } + let pathComponents = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init) + guard + let pageMarkerIndex = pathComponents.lastIndex(of: "p"), + pathComponents.index(after: pageMarkerIndex) < pathComponents.endIndex + else { + return nil + } + + let routeSlug = pathComponents[pathComponents.index(after: pageMarkerIndex)] + let slugID = extractDocmostPageSlugID(from: routeSlug) + guard slugID.isEmpty == false else { return nil } + + return DocmostPageLink( + slugID: slugID, + label: docmostPageLinkLabel(from: routeSlug, slugID: slugID), + anchorID: anchorID + ) + } + + private static func markdownLinkDestination(from destination: String) -> String { + let trimmedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) + + if trimmedDestination.hasPrefix("<"), + let closeIndex = trimmedDestination.firstIndex(of: ">") { + let sourceStartIndex = trimmedDestination.index(after: trimmedDestination.startIndex) + return String(trimmedDestination[sourceStartIndex.. String { + if UUID(uuidString: slug) != nil { + return slug + } + + let parts = slug.split(separator: "-", omittingEmptySubsequences: true).map(String.init) + return parts.count > 1 ? parts[parts.count - 1] : slug + } + + private static func docmostPageLinkLabel(from routeSlug: String, slugID: String) -> String { + let parts = routeSlug.split(separator: "-", omittingEmptySubsequences: true).map(String.init) + guard parts.count > 1 else { return slugID } + return parts.dropLast().joined(separator: "-") + } + + private static func escapedMarkdownLinkText(_ text: String) -> String { + text + .replacing("\\", with: "\\\\") + .replacing("[", with: "\\[") + .replacing("]", with: "\\]") + } +} + +private extension Character { + var isBareLinkBoundaryPunctuation: Bool { + switch self { + case "(", ")", "[", "]", "<", ">", ",", ".", ";", ":", "\"", "'": + true + default: + false + } + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift new file mode 100644 index 0000000..089f667 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift @@ -0,0 +1,42 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func inlineRunMarkdown(from run: AttributedString.Runs.Run, text: String) -> String { + var output = text + let intent = run.inlinePresentationIntent ?? [] + + if intent.contains(.code) { + output = codeMarkdown(from: output) + } else { + if intent.contains(.stronglyEmphasized) { + output = "**\(output)**" + } + + if intent.contains(.emphasized) { + output = "*\(output)*" + } + + if intent.contains(.strikethrough) { + output = "~~\(output)~~" + } + } + + if let href = run.link?.absoluteString { + output = "[\(escapedMarkdownLinkLabel(output))](\(href))" + } + + return output + } + + private static func codeMarkdown(from text: String) -> String { + let delimiter = text.contains("`") ? "``" : "`" + return "\(delimiter)\(text)\(delimiter)" + } + + private static func escapedMarkdownLinkLabel(_ text: String) -> String { + text + .replacing("\\", with: "\\\\") + .replacing("[", with: "\\[") + .replacing("]", with: "\\]") + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift new file mode 100644 index 0000000..f0a84b3 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift @@ -0,0 +1,427 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func richBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + if let mathBlock = mathFenceBlock(in: lines, startingAt: index) { + return mathBlock + } + + if let calloutBlock = calloutFenceBlock(in: lines, startingAt: index) { + return calloutBlock + } + + return detailsHTMLBlock(in: lines, startingAt: index) + } + + static func singleLineRichBlock(from line: String) -> NativeEditorBlock? { + imageMarkdownBlock(from: line) + } + + static func richMarkdownLine(from block: NativeEditorBlock) -> String? { + if let mediaMarkdown = mediaMarkdownLine(from: block) { + return mediaMarkdown + } + + if let structuralMarkdown = structuralMarkdownLine(from: block) { + return structuralMarkdown + } + + return embeddedMarkdownLine(from: block) + } + + private static func mediaMarkdownLine(from block: NativeEditorBlock) -> String? { + switch block.kind { + case .image(let media): + imageMarkdown(from: media) + case .video(let media): + mediaLinkMarkdown(from: media, fallbackTitle: "Video") + case .audio(let media): + mediaLinkMarkdown(from: media, fallbackTitle: "Audio") + case .pdf(let pdf): + linkMarkdown(title: pdf.name ?? pdf.source ?? "PDF", url: pdf.source) + case .attachment(let attachment): + linkMarkdown(title: attachment.name ?? attachment.url ?? "Attachment", url: attachment.url) + default: + nil + } + } + + private static func structuralMarkdownLine(from block: NativeEditorBlock) -> String? { + switch block.kind { + case .callout(let callout): + calloutMarkdown(from: callout) + case .details(let details): + detailsMarkdown(from: details) + case .pageBreak: + #"
"# + case .columns(let columns): + columnsMarkdown(from: columns) + case .subpages: + "" + case .transclusionSource(let source): + transclusionSourceMarkdown(from: source) + case .transclusionReference(let reference): + transclusionReferenceMarkdown(from: reference) + default: + nil + } + } + + private static func embeddedMarkdownLine(from block: NativeEditorBlock) -> String? { + switch block.kind { + case .embed(let embed): + linkMarkdown(title: embed.provider ?? embed.source ?? "Embed", url: embed.source) + case .drawio(let diagram): + diagramMarkdown(from: diagram, fallbackTitle: "Draw.io diagram") + case .excalidraw(let diagram): + diagramMarkdown(from: diagram, fallbackTitle: "Excalidraw diagram") + case .mathBlock(let math): + mathMarkdown(from: math) + case .unsupported: + String(block.text.characters) + default: + nil + } + } + + private static func mathFenceBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + guard lines[index].trimmingCharacters(in: .whitespaces) == "$$" else { return nil } + + var content: [String] = [] + var currentIndex = lines.index(after: index) + + while currentIndex < lines.endIndex { + let line = lines[currentIndex] + if line.trimmingCharacters(in: .whitespaces) == "$$" { + let math = NativeEditorMathBlock(text: content.joined(separator: "\n").trimmedMarkdownBlockText) + return ( + richBlock( + kind: .mathBlock(math), + rawNode: NativeEditorRichBlockNodeFactory.mathBlockNode(from: math) + ), + lines.index(after: currentIndex) + ) + } + + content.append(line) + currentIndex = lines.index(after: currentIndex) + } + + return nil + } + + private static func calloutFenceBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + let line = lines[index].trimmingCharacters(in: .whitespaces) + guard line.hasPrefix(":::") else { return nil } + + let header = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces) + guard header.isEmpty == false else { return nil } + + let headerParts = header.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) + guard let styleText = headerParts.first else { return nil } + + var content = headerParts.dropFirst().map(String.init) + var currentIndex = lines.index(after: index) + + while currentIndex < lines.endIndex { + let currentLine = lines[currentIndex] + if currentLine.trimmingCharacters(in: .whitespaces) == ":::" { + let callout = NativeEditorCalloutBlock( + style: sanitizedCalloutStyle(String(styleText)), + icon: nil, + previewText: content.joined(separator: "\n").trimmedMarkdownBlockText + ) + return ( + richBlock( + kind: .callout(callout), + rawNode: NativeEditorRichBlockNodeFactory.calloutNode(from: callout) + ), + lines.index(after: currentIndex) + ) + } + + content.append(currentLine) + currentIndex = lines.index(after: currentIndex) + } + + return nil + } + + private static func detailsHTMLBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + let line = lines[index].trimmingCharacters(in: .whitespaces) + guard line.localizedCaseInsensitiveCompare("
") == .orderedSame else { + return nil + } + + var summary = "Details" + var body: [String] = [] + var currentIndex = lines.index(after: index) + + while currentIndex < lines.endIndex { + let line = lines[currentIndex] + let trimmedLine = line.trimmingCharacters(in: .whitespaces) + if trimmedLine.localizedCaseInsensitiveCompare("
") == .orderedSame { + let details = NativeEditorDetailsBlock( + summary: summary, + previewText: body.joined(separator: "\n").trimmedMarkdownBlockText, + isOpen: true + ) + return ( + richBlock( + kind: .details(details), + rawNode: NativeEditorRichBlockNodeFactory.detailsNode(from: details) + ), + lines.index(after: currentIndex) + ) + } + + if let parsedSummary = summaryText(from: trimmedLine) { + summary = parsedSummary + } else { + body.append(line) + } + currentIndex = lines.index(after: currentIndex) + } + + return nil + } + + private static func imageMarkdownBlock(from line: String) -> NativeEditorBlock? { + guard + line.hasPrefix("!["), + let closeAltIndex = line.firstIndex(of: "]") + else { + return nil + } + + let openDestinationIndex = line.index(after: closeAltIndex) + guard + openDestinationIndex < line.endIndex, + line[openDestinationIndex] == "(", + let closeDestinationIndex = line.lastIndex(of: ")"), + closeDestinationIndex > openDestinationIndex + else { + return nil + } + + let altStartIndex = line.index(line.startIndex, offsetBy: 2) + let altText = unescapedMarkdownLinkText(String(line[altStartIndex.. NativeEditorBlock { + NativeEditorBlock( + kind: kind, + text: AttributedString(NativeEditorDocument.previewText(for: kind)), + alignment: .left, + rawNode: rawNode + ) + } + + private static func imageMarkdown(from media: NativeEditorMediaBlock) -> String { + guard let source = media.source, source.isEmpty == false else { + return media.alternativeText ?? "Image" + } + + return "![\(escapedMarkdownLinkText(media.alternativeText ?? ""))](\(source))" + } + + private static func mediaLinkMarkdown(from media: NativeEditorMediaBlock, fallbackTitle: String) -> String { + linkMarkdown(title: media.alternativeText ?? media.title ?? media.source ?? fallbackTitle, url: media.source) + } + + private static func calloutMarkdown(from callout: NativeEditorCalloutBlock) -> String { + """ + :::\(sanitizedCalloutStyle(callout.style)) + \(callout.previewText.trimmedMarkdownBlockText) + ::: + """ + } + + private static func detailsMarkdown(from details: NativeEditorDetailsBlock) -> String { + """ +
+ \(escapedHTMLText(details.summary)) + + \(details.previewText.trimmedMarkdownBlockText) + +
+ """ + } + + private static func columnsMarkdown(from columns: NativeEditorColumnsBlock) -> String { + let columnTexts = columns.columnTexts.isEmpty ? [columns.previewText] : columns.columnTexts + return columnTexts.enumerated().map { index, text in + "### Column \(index + 1)\n\(text.trimmedMarkdownBlockText)" + }.joined(separator: "\n\n") + } + + private static func transclusionSourceMarkdown(from source: NativeEditorTransclusionSourceBlock) -> String { + if let identifier = source.identifier, identifier.isEmpty == false { + return "\n\(source.previewText.trimmedMarkdownBlockText)" + } + + return source.previewText.trimmedMarkdownBlockText + } + + private static func transclusionReferenceMarkdown( + from reference: NativeEditorTransclusionReferenceBlock + ) -> String { + let identifier = reference.transclusionID ?? reference.sourcePageID ?? "unknown" + return "" + } + + private static func diagramMarkdown(from diagram: NativeEditorDiagramBlock, fallbackTitle: String) -> String { + linkMarkdown( + title: diagram.title ?? diagram.alternativeText ?? diagram.source ?? fallbackTitle, + url: diagram.source + ) + } + + private static func mathMarkdown(from math: NativeEditorMathBlock) -> String { + """ + $$ + \(math.text.trimmedMarkdownBlockText) + $$ + """ + } + + private static func linkMarkdown(title: String, url: String?) -> String { + guard let url, url.isEmpty == false else { return title } + return "[\(escapedMarkdownLinkText(title))](\(url))" + } + + private static func sanitizedCalloutStyle(_ value: String) -> String { + let sanitizedScalars = value.lowercased().unicodeScalars.filter { + CharacterSet.alphanumerics.contains($0) + } + let sanitized = String(String.UnicodeScalarView(sanitizedScalars)) + return sanitized.isEmpty ? "info" : sanitized + } + + private static func summaryText(from line: String) -> String? { + guard + line.localizedCaseInsensitiveContains(""), + line.localizedCaseInsensitiveContains("") + else { + return nil + } + + let lowercasedLine = line.lowercased() + guard + let startRange = lowercasedLine.range(of: ""), + let endRange = lowercasedLine.range(of: "") + else { + return nil + } + + let contentStartOffset = lowercasedLine.distance( + from: lowercasedLine.startIndex, + to: startRange.upperBound + ) + let contentEndOffset = lowercasedLine.distance( + from: lowercasedLine.startIndex, + to: endRange.lowerBound + ) + let contentStart = line.index(line.startIndex, offsetBy: contentStartOffset) + let contentEnd = line.index(line.startIndex, offsetBy: contentEndOffset) + guard contentStart <= contentEnd else { return nil } + return unescapedHTMLText(String(line[contentStart.. String { + var source = destination.trimmingCharacters(in: .whitespacesAndNewlines) + + if source.hasPrefix("<"), source.hasSuffix(">") { + source.removeFirst() + source.removeLast() + } + + if let titleRange = source.range(of: " \"") { + source = String(source[.. String { + text.replacing("\\", with: "\\\\") + .replacing("[", with: "\\[") + .replacing("]", with: "\\]") + .replacing("!", with: "\\!") + .replacing("\r", with: " ") + .replacing("\n", with: " ") + } + + private static func unescapedMarkdownLinkText(_ text: String) -> String { + var result = "" + var isEscaped = false + + for character in text { + if isEscaped { + result.append(character) + isEscaped = false + } else if character == "\\" { + isEscaped = true + } else { + result.append(character) + } + } + + if isEscaped { + result.append("\\") + } + + return result + } + + private static func escapedHTMLText(_ text: String) -> String { + text.replacing("&", with: "&") + .replacing("<", with: "<") + .replacing(">", with: ">") + } + + private static func unescapedHTMLText(_ text: String) -> String { + text.replacing("<", with: "<") + .replacing(">", with: ">") + .replacing("&", with: "&") + } +} + +private extension String { + var trimmedMarkdownBlockText: String { + trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift index ccdcc18..08e4638 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift @@ -18,6 +18,12 @@ enum NativeEditorMarkdownParser { continue } + if let richBlock = richBlock(in: lines, startingAt: index) { + blocks.append(richBlock.block) + index = richBlock.endIndex + continue + } + if let table = tableBlock(in: lines, startingAt: index) { blocks.append(table.block) index = table.endIndex @@ -34,6 +40,10 @@ enum NativeEditorMarkdownParser { } static func inputRule(from text: String) -> NativeEditorMarkdownInputRule? { + if isDivider(text.trimmingCharacters(in: .whitespaces)) { + return NativeEditorMarkdownInputRule(kind: .divider, text: "Divider") + } + if let codeRule = codeInputRule(from: text) { return codeRule } @@ -50,6 +60,10 @@ enum NativeEditorMarkdownParser { let trimmedLine = line.trimmingCharacters(in: .whitespaces) guard trimmedLine.isEmpty == false else { return nil } + if let richBlock = singleLineRichBlock(from: trimmedLine) { + return richBlock + } + if isDivider(trimmedLine) { return NativeEditorBlock(kind: .divider, text: AttributedString("Divider"), alignment: .left) } @@ -122,6 +136,7 @@ enum NativeEditorMarkdownParser { private static func simpleInputRule(from text: String) -> NativeEditorMarkdownInputRule? { let rules: [(String, NativeEditorBlockKind)] = [ + ("### ", .heading(level: 3)), ("## ", .heading(level: 2)), ("# ", .heading(level: 1)), ("- ", .bulletListItem), @@ -177,12 +192,63 @@ enum NativeEditorMarkdownParser { } private static func inlineText(from markdown: String) -> AttributedString { - (try? AttributedString(markdown: markdown)) ?? AttributedString(markdown) + var result = AttributedString("") + var remaining = markdown[...] + + while let inlineDelimiter = nextInlineMathDelimiter(in: remaining) { + let openRange = inlineDelimiter.range + appendMarkdownText(String(remaining[.. (range: Range, value: String)? { + let singleDollarRange = markdown.range(of: "$") + let doubleDollarRange = markdown.range(of: "$$") + + if let doubleDollarRange, doubleDollarRange.lowerBound == singleDollarRange?.lowerBound { + return (doubleDollarRange, "$$") + } + + if let singleDollarRange { + return (singleDollarRange, "$") + } + + return nil } private static func markdownLine(from block: NativeEditorBlock) -> String { let indent = String(repeating: " ", count: block.indentLevel) - let text = String(block.text.characters) + let plainText = String(block.text.characters) + let text = inlineMarkdown(from: block.text) switch block.kind { case .heading(let level): @@ -196,13 +262,13 @@ enum NativeEditorMarkdownParser { case .blockquote: return "> \(text)" case .codeBlock(let language): - return codeMarkdown(language: language, text: text) + return codeMarkdown(language: language, text: plainText) case .divider: return "---" case .table(let table): return tableMarkdown(from: table) default: - return text + return richMarkdownLine(from: block) ?? text } } @@ -234,6 +300,23 @@ enum NativeEditorMarkdownParser { return min(columns / 2, 8) } + + private static func inlineMarkdown(from text: AttributedString) -> String { + var output = "" + + for run in text.runs { + let runText = String(text[run.range].characters) + if let math = run[NativeEditorMathInlineAttribute.self] { + output += "$\(math.text.replacing("$", with: "\\$"))$" + } else if let mention = run[NativeEditorMentionAttribute.self] { + output += mentionMarkdown(from: mention, fallbackText: runText) + } else { + output += inlineRunMarkdown(from: run, text: runText) + } + } + + return output + } } private extension NativeEditorBlockKind { diff --git a/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift b/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift index 93ee086..6512c04 100644 --- a/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift +++ b/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift @@ -30,11 +30,15 @@ nonisolated struct NativeEditorTableCell: Equatable, Hashable, Sendable { var isHeader: Bool var backgroundColorName: String? var columnWidth: Int? + var columnSpan: Int = 1 + var rowSpan: Int = 1 + var columnWidths: [Int] = [] } nonisolated struct NativeEditorMediaBlock: Equatable, Hashable, Sendable { var source: String? var alternativeText: String? + var title: String? var attachmentID: String? var sizeInBytes: Int? var width: String? @@ -43,6 +47,20 @@ nonisolated struct NativeEditorMediaBlock: Equatable, Hashable, Sendable { var alignment: String? } +nonisolated extension NativeEditorMediaBlock { + static let placeholder = NativeEditorMediaBlock( + source: nil, + alternativeText: nil, + title: nil, + attachmentID: nil, + sizeInBytes: nil, + width: nil, + height: nil, + aspectRatio: nil, + alignment: nil + ) +} + nonisolated struct NativeEditorMediaBlockUpdate: Equatable, Hashable, Sendable { var source: String var alternativeText: String @@ -60,6 +78,17 @@ nonisolated struct NativeEditorPDFBlock: Equatable, Hashable, Sendable { var height: String? } +nonisolated extension NativeEditorPDFBlock { + static let placeholder = NativeEditorPDFBlock( + source: nil, + name: nil, + attachmentID: nil, + sizeInBytes: nil, + width: nil, + height: nil + ) +} + nonisolated struct NativeEditorAttachmentBlock: Equatable, Hashable, Sendable { var url: String? var name: String? @@ -68,6 +97,16 @@ nonisolated struct NativeEditorAttachmentBlock: Equatable, Hashable, Sendable { var attachmentID: String? } +nonisolated extension NativeEditorAttachmentBlock { + static let placeholder = NativeEditorAttachmentBlock( + url: nil, + name: nil, + mimeType: nil, + sizeInBytes: nil, + attachmentID: nil + ) +} + nonisolated struct NativeEditorCalloutBlock: Equatable, Hashable, Sendable { var style: String var icon: String? @@ -98,6 +137,12 @@ nonisolated struct NativeEditorTransclusionReferenceBlock: Equatable, Hashable, var transclusionID: String? } +nonisolated struct NativeEditorBaseBlock: Equatable, Hashable, Sendable { + var pageID: String? + var pendingKey: String? + var previewText: String +} + nonisolated struct NativeEditorEmbedBlock: Equatable, Hashable, Sendable { var source: String? var provider: String? @@ -118,6 +163,20 @@ nonisolated struct NativeEditorDiagramBlock: Equatable, Hashable, Sendable { var alignment: String? } +nonisolated extension NativeEditorDiagramBlock { + static let placeholder = NativeEditorDiagramBlock( + source: nil, + title: nil, + alternativeText: nil, + attachmentID: nil, + sizeInBytes: nil, + width: nil, + height: nil, + aspectRatio: nil, + alignment: nil + ) +} + nonisolated struct NativeEditorMathBlock: Equatable, Hashable, Sendable { var text: String } diff --git a/docmostly/Features/Editor/NativeEditorRichBlockPreviewView.swift b/docmostly/Features/Editor/NativeEditorRichBlockPreviewView.swift index 24f5d1c..c07081f 100644 --- a/docmostly/Features/Editor/NativeEditorRichBlockPreviewView.swift +++ b/docmostly/Features/Editor/NativeEditorRichBlockPreviewView.swift @@ -38,14 +38,14 @@ struct NativeEditorRichBlockPreviewView: View { previewShell( systemImage: "play.rectangle", title: "Video", - subtitle: media.alternativeText ?? media.source + subtitle: media.alternativeText ?? media.title ?? media.source ) { if let richBlockActions { NativeEditorMediaBlockEditor(blockID: block.id, media: media, actions: richBlockActions) } } case .audio(let media): - previewShell(systemImage: "waveform", title: "Audio", subtitle: media.source) { + previewShell(systemImage: "waveform", title: "Audio", subtitle: media.title ?? media.source) { if let richBlockActions { NativeEditorMediaBlockEditor(blockID: block.id, media: media, actions: richBlockActions) } @@ -132,6 +132,12 @@ struct NativeEditorRichBlockPreviewView: View { ) } } + case .base(let base): + previewShell( + systemImage: "tablecells", + title: base.previewText, + subtitle: base.pageID ?? "Base page pending" + ) case .embed(let embed): previewShell( systemImage: "rectangle.connected.to.line.below", diff --git a/docmostly/Features/Editor/NativeEditorSlashCommandMenu.swift b/docmostly/Features/Editor/NativeEditorSlashCommandMenu.swift index 1581672..9cd0453 100644 --- a/docmostly/Features/Editor/NativeEditorSlashCommandMenu.swift +++ b/docmostly/Features/Editor/NativeEditorSlashCommandMenu.swift @@ -2,6 +2,7 @@ import SwiftUI struct NativeEditorSlashCommandMenu: View { @Bindable var viewModel: NativeRichEditorViewModel + var importAttachment: (NativeEditorAttachmentImportKind) -> Void = { _ in } var body: some View { let commands = viewModel.filteredSlashCommands @@ -21,7 +22,11 @@ struct NativeEditorSlashCommandMenu: View { } else { ForEach(commands) { command in Button { - viewModel.applySlashCommand(command) + if let importKind = command.attachmentImportKind { + importAttachment(importKind) + } else { + viewModel.applySlashCommand(command) + } } label: { HStack(spacing: 10) { Image(systemName: command.systemImage) diff --git a/docmostly/Features/Editor/NativeEditorUploadedAttachmentBlockFactory.swift b/docmostly/Features/Editor/NativeEditorUploadedAttachmentBlockFactory.swift index bc4cdee..c30665d 100644 --- a/docmostly/Features/Editor/NativeEditorUploadedAttachmentBlockFactory.swift +++ b/docmostly/Features/Editor/NativeEditorUploadedAttachmentBlockFactory.swift @@ -42,10 +42,9 @@ enum NativeEditorAttachmentBlockFactory { private static func videoBlock(id: UUID, context: NativeEditorAttachmentContext) -> NativeEditorBlock { mediaBlock( id: id, - kind: .video(context.mediaPayload), + kind: .video(context.mediaPayload(title: context.attachment.fileName)), type: "video", - context: context, - title: context.attachment.fileName + context: context ) } @@ -58,11 +57,10 @@ enum NativeEditorAttachmentBlockFactory { kind: NativeEditorBlockKind, type: String, context: NativeEditorAttachmentContext, - title: String? = nil, dimensions: NativeEditorMediaDimensions? = nil ) -> NativeEditorBlock { var attrs = context.sourceAttrs - if let title { + if let title = kind.mediaTitle { attrs["title"] = .string(title) } if let dimensions { @@ -156,6 +154,17 @@ enum NativeEditorAttachmentBlockFactory { } } +private extension NativeEditorBlockKind { + var mediaTitle: String? { + switch self { + case .image(let media), .video(let media), .audio(let media): + media.title + default: + nil + } + } +} + private struct NativeEditorAttachmentContext { let attachment: DocmostAttachment let source: String @@ -163,9 +172,14 @@ private struct NativeEditorAttachmentContext { let imageDimensions: NativeEditorMediaDimensions? var mediaPayload: NativeEditorMediaBlock { + mediaPayload(title: nil) + } + + func mediaPayload(title: String?) -> NativeEditorMediaBlock { NativeEditorMediaBlock( source: source, alternativeText: nil, + title: title, attachmentID: attachment.id, sizeInBytes: size, width: imageDimensions?.width.description, diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift index eff00fa..a5b8559 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift @@ -9,7 +9,11 @@ extension NativeRichEditorViewModel { } } - func applySlashCommand(_ command: NativeEditorCommand) { + func applySlashCommand(_ command: NativeEditorCommand, now: Date = .now) { + if applyInlineSlashCommand(command, now: now) { + return + } + performUndoableEdit { guard let index = activeBlockIndex else { return } @@ -26,6 +30,71 @@ extension NativeRichEditorViewModel { } } + @discardableResult + private func applyInlineSlashCommand(_ command: NativeEditorCommand, now: Date) -> Bool { + guard let segment = inlineSegment(for: command, now: now) else { return false } + + performUndoableEdit { + guard let index = activeBlockIndex else { return } + + if activeSlashCommandQuery != nil { + document.blocks[index].text = segment + } else { + insert(segment, into: &document.blocks[index]) + } + + document.blocks[index].selection = AttributedTextSelection() + } + + return true + } + + private func inlineSegment(for command: NativeEditorCommand, now: Date) -> AttributedString? { + switch command { + case .date: + AttributedString(now.formatted(date: .long, time: .omitted)) + case .time: + AttributedString(now.formatted(date: .omitted, time: .shortened)) + case .status: + statusSegment(text: "Status", color: "gray") + case .emoji: + AttributedString(":") + case .mathInline: + mathInlineSegment(text: "x = y") + default: + nil + } + } + + private func statusSegment(text: String, color: String) -> AttributedString { + let status = NativeEditorStatusBadge(text: text, color: color) + var segment = AttributedString(text) + segment[NativeEditorStatusAttribute.self] = status + segment.inlinePresentationIntent = .stronglyEmphasized + return segment + } + + private func mathInlineSegment(text: String) -> AttributedString { + let math = NativeEditorMathInline(text: text) + var segment = AttributedString(text) + segment[NativeEditorMathInlineAttribute.self] = math + segment.inlinePresentationIntent = .code + return segment + } + + private func insert(_ segment: AttributedString, into block: inout NativeEditorBlock) { + switch block.selection.indices(in: block.text) { + case .ranges(let ranges): + if let range = ranges.ranges.first { + block.text.replaceSubrange(range, with: segment) + } else { + block.text.insert(segment, at: block.text.endIndex) + } + case .insertionPoint(let insertionIndex): + block.text.insert(segment, at: insertionIndex) + } + } + func setActiveAlignment(_ alignment: NativeEditorTextAlignment) { performUndoableEdit { guard let index = activeBlockIndex else { return } diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+MediaBlocks.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+MediaBlocks.swift index e7f15f0..78fc183 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+MediaBlocks.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+MediaBlocks.swift @@ -10,6 +10,7 @@ extension NativeRichEditorViewModel { let media = NativeEditorMediaBlock( source: Self.trimmedOptional(update.source), alternativeText: Self.trimmedOptional(update.alternativeText), + title: currentMedia.title, attachmentID: currentMedia.attachmentID, sizeInBytes: currentMedia.sizeInBytes, width: Self.trimmedOptional(update.width), @@ -131,6 +132,9 @@ nonisolated extension NativeEditorRichBlockNodeFactory { if let alternativeText = media.alternativeText { attrs["alt"] = .string(alternativeText) } + if let title = media.title { + attrs["title"] = .string(title) + } appendDimensions(width: media.width, height: media.height, aspectRatio: media.aspectRatio, to: &attrs) if let alignment = media.alignment { attrs["align"] = .string(alignment) diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+RichBlocks.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+RichBlocks.swift index 1c85e0c..675f3a7 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+RichBlocks.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+RichBlocks.swift @@ -244,6 +244,15 @@ nonisolated enum NativeEditorRichBlockNodeFactory { ) } + static func baseNode(from base: NativeEditorBaseBlock) -> ProseMirrorNode { + var attrs: [String: ProseMirrorJSONValue] = ["pageId": base.pageID.map(ProseMirrorJSONValue.string) ?? .null] + if let pendingKey = base.pendingKey, pendingKey.isEmpty == false { + attrs["pendingKey"] = .string(pendingKey) + } + + return ProseMirrorNode(type: "base", attrs: attrs) + } + static func embedNode(from embed: NativeEditorEmbedBlock) -> ProseMirrorNode { var attrs: [String: ProseMirrorJSONValue] = [ "src": .string(embed.source ?? ""), diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+Tables.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+Tables.swift index 40328f0..38f5f3f 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+Tables.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+Tables.swift @@ -51,6 +51,7 @@ extension NativeRichEditorViewModel { for rowIndex in table.rows.indices where table.rows[rowIndex].cells.indices.contains(columnIndex) { table.rows[rowIndex].cells[columnIndex].columnWidth = clampedWidth + table.rows[rowIndex].cells[columnIndex].columnWidths = [clampedWidth] } } } @@ -142,6 +143,18 @@ nonisolated enum NativeEditorTableNodeFactory { attrs["colwidth"] = .array([.int(columnWidth)]) } + if cell.columnWidths.isEmpty == false { + attrs["colwidth"] = .array(cell.columnWidths.map { .int($0) }) + } + + if cell.columnSpan > 1 { + attrs["colspan"] = .int(cell.columnSpan) + } + + if cell.rowSpan > 1 { + attrs["rowspan"] = .int(cell.rowSpan) + } + return attrs.isEmpty ? nil : attrs } diff --git a/docmostly/Features/PageReader/PageReaderView.swift b/docmostly/Features/PageReader/PageReaderView.swift index c725759..e0f815f 100644 --- a/docmostly/Features/PageReader/PageReaderView.swift +++ b/docmostly/Features/PageReader/PageReaderView.swift @@ -50,7 +50,8 @@ struct PageReaderView: View { NativeEditorBodyView( viewModel: editorViewModel, focusedField: $editorFocusedField, - isAuthoringEnabled: readerMode == .edit + isAuthoringEnabled: readerMode == .edit, + importAttachment: beginAttachmentImport ) AttachmentLinksView( links: viewModel.attachmentLinks, diff --git a/docmostlyTests/Editor/NativeEditorDocumentFixtures.swift b/docmostlyTests/Editor/NativeEditorDocumentFixtures.swift index dcd54e2..53f82ae 100644 --- a/docmostlyTests/Editor/NativeEditorDocumentFixtures.swift +++ b/docmostlyTests/Editor/NativeEditorDocumentFixtures.swift @@ -126,6 +126,7 @@ enum NativeEditorRichBlockFixtures { "attrs": { "src": "/files/video.mp4", "alt": "Demo", + "title": "Launch demo.mp4", "attachmentId": "video-1", "size": 4096, "align": "left" diff --git a/docmostlyTests/Editor/NativeEditorDocumentTests.swift b/docmostlyTests/Editor/NativeEditorDocumentTests.swift index 41dfde9..98201ef 100644 --- a/docmostlyTests/Editor/NativeEditorDocumentTests.swift +++ b/docmostlyTests/Editor/NativeEditorDocumentTests.swift @@ -96,6 +96,7 @@ struct NativeEditorDocumentTests { return } #expect(video.attachmentID == "video-1") + #expect(video.title == "Launch demo.mp4") try expectAudioPDFAndAttachmentBlocks(blocks) } diff --git a/docmostlyTests/Editor/NativeEditorInlineMarkdownExportTests.swift b/docmostlyTests/Editor/NativeEditorInlineMarkdownExportTests.swift new file mode 100644 index 0000000..ce5c5a9 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorInlineMarkdownExportTests.swift @@ -0,0 +1,54 @@ +import Foundation +import SwiftUI +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorInlineMarkdownExportTests { + @Test func documentMarkdownConversionPreservesCommonInlineMarks() throws { + var text = AttributedString("Use ") + var bold = AttributedString("bold") + bold.inlinePresentationIntent = .stronglyEmphasized + var italic = AttributedString("italic") + italic.inlinePresentationIntent = .emphasized + var code = AttributedString("code") + code.inlinePresentationIntent = .code + var link = AttributedString("link") + link.link = try #require(URL(string: "https://example.com/spec")) + + text += bold + text += AttributedString(", ") + text += italic + text += AttributedString(", ") + text += code + text += AttributedString(", and ") + text += link + + let block = NativeEditorBlock(kind: .paragraph, text: text, alignment: .left) + let viewModel = configuredViewModel(blocks: [block]) + + #expect( + viewModel.markdownForDocument() == + "Use **bold**, *italic*, `code`, and [link](https://example.com/spec)" + ) + } + + @Test func documentMarkdownConversionPreservesStrikethroughInlineMark() { + var text = AttributedString("Archive ") + var removed = AttributedString("old plan") + removed.inlinePresentationIntent = .strikethrough + text += removed + + let block = NativeEditorBlock(kind: .paragraph, text: text, alignment: .left) + let viewModel = configuredViewModel(blocks: [block]) + + #expect(viewModel.markdownForDocument() == "Archive ~~old plan~~") + } + + private func configuredViewModel(blocks: [NativeEditorBlock]) -> NativeRichEditorViewModel { + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: blocks) + viewModel.resetEditingHistory() + return viewModel + } +} diff --git a/docmostlyTests/Editor/NativeEditorMentionMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorMentionMarkdownTests.swift new file mode 100644 index 0000000..b80fbb7 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorMentionMarkdownTests.swift @@ -0,0 +1,75 @@ +import Foundation +import SwiftUI +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorMentionMarkdownTests { + @Test func pasteMarkdownDocmostPageLinksCreatesMentionAtoms() throws { + let intro = NativeEditorBlock(kind: .paragraph, text: AttributedString("Intro"), alignment: .left) + let viewModel = configuredViewModel(blocks: [intro]) + viewModel.focus(blockID: intro.id) + + viewModel.pasteMarkdown( + "Discuss [Roadmap](https://docs.example.com/s/product/p/native-roadmap-abc123#shipping) today" + ) + + let inlineNodes = viewModel.document.proseMirrorDocument.content.last?.content ?? [] + #expect(inlineNodes.map(\.type) == ["text", "mention", "text"]) + #expect(inlineNodes[0].text == "Discuss ") + #expect(inlineNodes[2].text == " today") + + let attrs = try #require(inlineNodes[1].attrs) + #expect(attrs["label"] == .string("Roadmap")) + #expect(attrs["entityType"] == .string("page")) + #expect(attrs["entityId"] == .string("abc123")) + #expect(attrs["slugId"] == .string("abc123")) + #expect(attrs["anchorId"] == .string("shipping")) + #expect(attrs["id"]?.stringValue?.isEmpty == false) + } + + @Test func pasteMarkdownBareDocmostPageURLCreatesMentionAtom() throws { + let intro = NativeEditorBlock(kind: .paragraph, text: AttributedString("Intro"), alignment: .left) + let viewModel = configuredViewModel(blocks: [intro]) + viewModel.focus(blockID: intro.id) + + viewModel.pasteMarkdown("https://docs.example.com/s/product/p/native-roadmap-abc123#shipping") + + let inlineNodes = viewModel.document.proseMirrorDocument.content.last?.content ?? [] + #expect(inlineNodes.map(\.type) == ["mention"]) + + let attrs = try #require(inlineNodes[0].attrs) + #expect(attrs["label"] == .string("native-roadmap")) + #expect(attrs["entityType"] == .string("page")) + #expect(attrs["entityId"] == .string("abc123")) + #expect(attrs["slugId"] == .string("abc123")) + #expect(attrs["anchorId"] == .string("shipping")) + } + + @Test func documentMarkdownConversionPreservesMentionAtomsAsDocmostLinks() { + var text = AttributedString("Discuss ") + var mentionText = AttributedString("Roadmap") + mentionText[NativeEditorMentionAttribute.self] = NativeEditorMention( + identifier: "mention-1", + label: "Roadmap", + entityType: "page", + entityID: "page-1", + slugID: "abc123", + anchorID: "shipping" + ) + text += mentionText + text += AttributedString(" today") + + let block = NativeEditorBlock(kind: .paragraph, text: text, alignment: .left) + let viewModel = configuredViewModel(blocks: [block]) + + #expect(viewModel.markdownForDocument() == "Discuss [Roadmap](/p/abc123#shipping) today") + } + + private func configuredViewModel(blocks: [NativeEditorBlock]) -> NativeRichEditorViewModel { + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: blocks) + viewModel.resetEditingHistory() + return viewModel + } +} diff --git a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift new file mode 100644 index 0000000..829ef90 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift @@ -0,0 +1,191 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorRichMarkdownExportTests { + @Test func documentMarkdownConversionPreservesRichBlockMeaning() { + let viewModel = configuredViewModel(blocks: richMarkdownFixtureBlocks()) + + #expect(viewModel.markdownForDocument() == """ + ![Architecture](/files/image.png) + [Launch demo.mp4](/files/demo.mp4) + [Release audio.m4a](/files/audio.m4a) + [Spec.pdf](/files/spec.pdf) + [Archive.zip](/files/archive.zip) + :::warning + Check migration plan + ::: +
+ Release checklist + + Ship native editor + +
+
+ [Example](https://example.com) + $$ + E = mc^2 + $$ + """) + } + + private func configuredViewModel(blocks: [NativeEditorBlock]) -> NativeRichEditorViewModel { + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: blocks) + viewModel.resetEditingHistory() + return viewModel + } + + private func richMarkdownFixtureBlocks() -> [NativeEditorBlock] { + [ + imageMarkdownFixtureBlock(), + videoMarkdownFixtureBlock(), + audioMarkdownFixtureBlock(), + pdfMarkdownFixtureBlock(), + attachmentMarkdownFixtureBlock(), + calloutMarkdownFixtureBlock(), + detailsMarkdownFixtureBlock(), + pageBreakMarkdownFixtureBlock(), + embedMarkdownFixtureBlock(), + mathMarkdownFixtureBlock() + ] + } + + private func imageMarkdownFixtureBlock() -> NativeEditorBlock { + NativeEditorBlock( + kind: .image(NativeEditorMediaBlock( + source: "/files/image.png", + alternativeText: "Architecture", + title: nil, + attachmentID: nil, + sizeInBytes: nil, + width: nil, + height: nil, + aspectRatio: nil, + alignment: nil + )), + text: AttributedString("Architecture"), + alignment: .left + ) + } + + private func videoMarkdownFixtureBlock() -> NativeEditorBlock { + NativeEditorBlock( + kind: .video(NativeEditorMediaBlock( + source: "/files/demo.mp4", + alternativeText: nil, + title: "Launch demo.mp4", + attachmentID: nil, + sizeInBytes: nil, + width: nil, + height: nil, + aspectRatio: nil, + alignment: nil + )), + text: AttributedString("Launch demo.mp4"), + alignment: .left + ) + } + + private func audioMarkdownFixtureBlock() -> NativeEditorBlock { + NativeEditorBlock( + kind: .audio(NativeEditorMediaBlock( + source: "/files/audio.m4a", + alternativeText: nil, + title: "Release audio.m4a", + attachmentID: nil, + sizeInBytes: nil, + width: nil, + height: nil, + aspectRatio: nil, + alignment: nil + )), + text: AttributedString("Release audio.m4a"), + alignment: .left + ) + } + + private func pdfMarkdownFixtureBlock() -> NativeEditorBlock { + NativeEditorBlock( + kind: .pdf(NativeEditorPDFBlock( + source: "/files/spec.pdf", + name: "Spec.pdf", + attachmentID: nil, + sizeInBytes: nil, + width: nil, + height: nil + )), + text: AttributedString("Spec.pdf"), + alignment: .left + ) + } + + private func attachmentMarkdownFixtureBlock() -> NativeEditorBlock { + NativeEditorBlock( + kind: .attachment(NativeEditorAttachmentBlock( + url: "/files/archive.zip", + name: "Archive.zip", + mimeType: "application/zip", + sizeInBytes: nil, + attachmentID: nil + )), + text: AttributedString("Archive.zip"), + alignment: .left + ) + } + + private func calloutMarkdownFixtureBlock() -> NativeEditorBlock { + NativeEditorBlock( + kind: .callout(NativeEditorCalloutBlock( + style: "warning", + icon: nil, + previewText: "Check migration plan" + )), + text: AttributedString("Check migration plan"), + alignment: .left + ) + } + + private func detailsMarkdownFixtureBlock() -> NativeEditorBlock { + NativeEditorBlock( + kind: .details(NativeEditorDetailsBlock( + summary: "Release checklist", + previewText: "Ship native editor", + isOpen: true + )), + text: AttributedString("Release checklist"), + alignment: .left + ) + } + + private func pageBreakMarkdownFixtureBlock() -> NativeEditorBlock { + NativeEditorBlock( + kind: .pageBreak, + text: AttributedString("Page break"), + alignment: .left + ) + } + + private func embedMarkdownFixtureBlock() -> NativeEditorBlock { + NativeEditorBlock( + kind: .embed(NativeEditorEmbedBlock( + source: "https://example.com", + provider: "Example", + alignment: nil, + width: nil, + height: nil + )), + text: AttributedString("Example"), + alignment: .left + ) + } + + private func mathMarkdownFixtureBlock() -> NativeEditorBlock { + NativeEditorBlock( + kind: .mathBlock(NativeEditorMathBlock(text: "E = mc^2")), + text: AttributedString("E = mc^2"), + alignment: .left + ) + } +} diff --git a/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift b/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift new file mode 100644 index 0000000..5b73ea1 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift @@ -0,0 +1,140 @@ +import Foundation +import SwiftUI +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorSlashCommandTests { + @Test func slashCommandInventoryIncludesBaseColumnsAndProviderEmbeds() { + let titles = NativeEditorCommand.allCases.map(\.title) + + #expect(titles.contains("Base (Inline)")) + #expect(titles.contains("Kanban")) + #expect(titles.contains("3 Columns")) + #expect(titles.contains("4 Columns")) + #expect(titles.contains("5 Columns")) + #expect(titles.contains("Iframe embed")) + #expect(titles.contains("Airtable")) + #expect(titles.contains("Loom")) + #expect(titles.contains("Figma")) + #expect(titles.contains("Typeform")) + #expect(titles.contains("Miro")) + #expect(titles.contains("YouTube")) + #expect(titles.contains("Vimeo")) + #expect(titles.contains("Framer")) + #expect(titles.contains("Google Drive")) + #expect(titles.contains("Google Sheets")) + } + + @Test func applyingColumnSlashCommandsCreatesDocmostColumnLayouts() { + let expectations = [ + ColumnCommandExpectation(command: .columns, layout: "two_equal", columnCount: 2), + ColumnCommandExpectation(command: .columns3, layout: "three_equal", columnCount: 3), + ColumnCommandExpectation(command: .columns4, layout: "four_equal", columnCount: 4), + ColumnCommandExpectation(command: .columns5, layout: "five_equal", columnCount: 5) + ] + + for expectation in expectations { + let viewModel = viewModelAfterApplying(expectation.command) + let block = viewModel.document.blocks[0] + + guard case .columns(let columns) = block.kind else { + Issue.record("Expected columns block") + continue + } + + let node = viewModel.document.proseMirrorDocument.content.first + #expect(columns.layout == expectation.layout) + #expect(columns.columnCount == expectation.columnCount) + #expect(node?.type == "columns") + #expect(node?.attrs?["layout"] == .string(expectation.layout)) + #expect(node?.content?.count == expectation.columnCount) + } + } + + @Test func applyingProviderEmbedSlashCommandsCreatesProviderSpecificEmbedNodes() { + let expectations = [ + EmbedCommandExpectation(command: .iframeEmbed, title: "Iframe embed", provider: "iframe"), + EmbedCommandExpectation(command: .airtableEmbed, title: "Airtable", provider: "airtable"), + EmbedCommandExpectation(command: .loomEmbed, title: "Loom", provider: "loom"), + EmbedCommandExpectation(command: .figmaEmbed, title: "Figma", provider: "figma"), + EmbedCommandExpectation(command: .typeformEmbed, title: "Typeform", provider: "typeform"), + EmbedCommandExpectation(command: .miroEmbed, title: "Miro", provider: "miro"), + EmbedCommandExpectation(command: .youtubeEmbed, title: "YouTube", provider: "youtube"), + EmbedCommandExpectation(command: .vimeoEmbed, title: "Vimeo", provider: "vimeo"), + EmbedCommandExpectation(command: .framerEmbed, title: "Framer", provider: "framer"), + EmbedCommandExpectation(command: .googleDriveEmbed, title: "Google Drive", provider: "gdrive"), + EmbedCommandExpectation(command: .googleSheetsEmbed, title: "Google Sheets", provider: "gsheets") + ] + + for expectation in expectations { + let viewModel = viewModelAfterApplying(expectation.command) + let block = viewModel.document.blocks[0] + + guard case .embed(let embed) = block.kind else { + Issue.record("Expected embed block for \(expectation.title)") + continue + } + + let node = viewModel.document.proseMirrorDocument.content.first + #expect(embed.provider == expectation.provider) + #expect(node?.type == "embed") + #expect(node?.attrs?["provider"] == .string(expectation.provider)) + } + } + + @Test func applyingBaseSlashCommandsCreatesRawBaseNodes() { + let expectations = [ + BaseCommandExpectation(command: .baseInline, previewText: "Base"), + BaseCommandExpectation(command: .kanban, previewText: "Kanban") + ] + + for expectation in expectations { + let viewModel = viewModelAfterApplying(expectation.command) + let block = viewModel.document.blocks[0] + + guard case .base(let base) = block.kind else { + Issue.record("Expected base block") + continue + } + + let node = viewModel.document.proseMirrorDocument.content.first + #expect(base.previewText == expectation.previewText) + #expect(base.pageID == nil) + #expect(node?.type == "base") + #expect(node?.attrs?["pageId"] == .null) + } + } + + private func viewModelAfterApplying(_ command: NativeEditorCommand) -> NativeRichEditorViewModel { + let block = NativeEditorBlock( + kind: .paragraph, + text: AttributedString("/\(command.rawValue)"), + alignment: .left + ) + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: [block]) + viewModel.focus(blockID: block.id) + + viewModel.applySlashCommand(command) + + return viewModel + } +} + +private struct ColumnCommandExpectation { + let command: NativeEditorCommand + let layout: String + let columnCount: Int +} + +private struct EmbedCommandExpectation { + let command: NativeEditorCommand + let title: String + let provider: String +} + +private struct BaseCommandExpectation { + let command: NativeEditorCommand + let previewText: String +} diff --git a/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift b/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift new file mode 100644 index 0000000..7239858 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift @@ -0,0 +1,56 @@ +import Foundation +import Testing +@testable import docmostly + +struct NativeEditorTablePayloadTests { + @Test func decodesTableCellSpanAndColumnWidthAttributes() throws { + let data = Data(""" + { + "type": "doc", + "content": [ + { + "type": "table", + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableHeader", + "attrs": { + "colspan": 2, + "rowspan": 3, + "colwidth": [120, 160], + "backgroundColorName": "blue" + }, + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Merged" }] + } + ] + } + ] + } + ] + } + ] + } + """.utf8) + + let document = try NativeEditorDocument(proseMirrorJSONData: data) + + guard case .table(let table) = document.blocks.first?.kind else { + Issue.record("Expected table block") + return + } + + let cell = try #require(table.rows.first?.cells.first) + #expect(cell.plainText == "Merged") + #expect(cell.isHeader == true) + #expect(cell.columnSpan == 2) + #expect(cell.rowSpan == 3) + #expect(cell.columnWidth == 120) + #expect(cell.columnWidths == [120, 160]) + #expect(cell.backgroundColorName == "blue") + } +} diff --git a/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift b/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift index 73620d3..08d4ccf 100644 --- a/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift @@ -43,6 +43,31 @@ struct NativeRichEditorMechanicsTests { #expect(String(viewModel.document.blocks[0].text.characters).isEmpty) } + @Test func markdownInputRuleSupportsDocmostHeadingThreeShortcut() { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString(""), alignment: .left) + let viewModel = configuredViewModel(blocks: [block]) + viewModel.focus(blockID: block.id) + + viewModel.document.blocks[0].text = AttributedString("### Release details") + viewModel.handleDocumentChanged() + + #expect(viewModel.document.blocks[0].kind == .heading(level: 3)) + #expect(String(viewModel.document.blocks[0].text.characters) == "Release details") + } + + @Test func markdownInputRuleSupportsDividerShortcut() { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString(""), alignment: .left) + let viewModel = configuredViewModel(blocks: [block]) + viewModel.focus(blockID: block.id) + + viewModel.document.blocks[0].text = AttributedString("---") + viewModel.handleDocumentChanged() + + #expect(viewModel.document.blocks[0].kind == .divider) + #expect(String(viewModel.document.blocks[0].text.characters) == "Divider") + #expect(viewModel.markdownForDocument() == "---") + } + @Test func pasteMarkdownInsertsNativeBlocksAfterActiveBlock() { let intro = NativeEditorBlock(kind: .paragraph, text: AttributedString("Intro"), alignment: .left) let viewModel = configuredViewModel(blocks: [intro]) @@ -134,6 +159,58 @@ struct NativeRichEditorMechanicsTests { """) } + @Test func pasteMarkdownRichBlocksCreatesNativeBlocks() throws { + let intro = NativeEditorBlock(kind: .paragraph, text: AttributedString("Intro"), alignment: .left) + let viewModel = configuredViewModel(blocks: [intro]) + viewModel.focus(blockID: intro.id) + + viewModel.pasteMarkdown(""" + ![Architecture](/files/image.png) + :::warning + Check migration plan + ::: + $$ + E = mc^2 + $$ + """) + + #expect(viewModel.document.blocks.count == 4) + + guard case .image(let image) = viewModel.document.blocks[1].kind else { + Issue.record("Expected pasted Markdown image to become a native image block.") + return + } + #expect(image.source == "/files/image.png") + #expect(image.alternativeText == "Architecture") + + guard case .callout(let callout) = viewModel.document.blocks[2].kind else { + Issue.record("Expected pasted Markdown callout to become a native callout block.") + return + } + #expect(callout.style == "warning") + #expect(callout.previewText == "Check migration plan") + + guard case .mathBlock(let math) = viewModel.document.blocks[3].kind else { + Issue.record("Expected pasted Markdown math fence to become a native math block.") + return + } + #expect(math.text == "E = mc^2") + } + + @Test func pasteMarkdownInlineMathCreatesDocmostInlineAtom() throws { + let intro = NativeEditorBlock(kind: .paragraph, text: AttributedString("Intro"), alignment: .left) + let viewModel = configuredViewModel(blocks: [intro]) + viewModel.focus(blockID: intro.id) + + viewModel.pasteMarkdown("Formula $E = mc^2$ today") + + let inlineNodes = viewModel.document.proseMirrorDocument.content.last?.content ?? [] + #expect(inlineNodes.map(\.type) == ["text", "mathInline", "text"]) + #expect(inlineNodes[0].text == "Formula ") + #expect(inlineNodes[1].attrs?["text"] == .string("E = mc^2")) + #expect(inlineNodes[2].text == " today") + } + @Test func indentAndOutdentActiveListBlock() { let block = NativeEditorBlock(kind: .bulletListItem, text: AttributedString("Nested"), alignment: .left) let viewModel = configuredViewModel(blocks: [block]) @@ -204,6 +281,20 @@ struct NativeRichEditorMechanicsTests { #expect(viewModel.markdownForDocument() == "# Roadmap\n- [x] Ship editor") } + @Test func documentMarkdownConversionPreservesInlineMathAtom() { + var text = AttributedString("Formula ") + var mathText = AttributedString("E = mc^2") + mathText[NativeEditorMathInlineAttribute.self] = NativeEditorMathInline(text: "E = mc^2") + mathText.inlinePresentationIntent = .code + text += mathText + text += AttributedString(" today") + + let block = NativeEditorBlock(kind: .paragraph, text: text, alignment: .left) + let viewModel = configuredViewModel(blocks: [block]) + + #expect(viewModel.markdownForDocument() == "Formula $E = mc^2$ today") + } + private func configuredViewModel(blocks: [NativeEditorBlock]) -> NativeRichEditorViewModel { let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") viewModel.document = NativeEditorDocument(blocks: blocks) diff --git a/docmostlyTests/Editor/NativeRichEditorMediaBlockTests.swift b/docmostlyTests/Editor/NativeRichEditorMediaBlockTests.swift index d7703f8..4f35ddb 100644 --- a/docmostlyTests/Editor/NativeRichEditorMediaBlockTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorMediaBlockTests.swift @@ -9,6 +9,7 @@ struct NativeRichEditorMediaBlockTests { let imageID = viewModel.document.blocks[0].id let pdfID = viewModel.document.blocks[1].id let attachmentID = viewModel.document.blocks[2].id + let videoID = viewModel.document.blocks[3].id viewModel.updateMediaBlock( blockID: imageID, @@ -20,6 +21,16 @@ struct NativeRichEditorMediaBlockTests { alignment: "center" ) ) + viewModel.updateMediaBlock( + blockID: videoID, + update: NativeEditorMediaBlockUpdate( + source: "/files/demo-v2.mp4", + alternativeText: "Launch demo", + width: "1280", + height: "720", + alignment: "right" + ) + ) viewModel.updatePDFBlock( blockID: pdfID, source: "/files/spec-v2.pdf", @@ -42,6 +53,16 @@ struct NativeRichEditorMediaBlockTests { #expect(nodes[0].attrs?["height"] == .int(768)) #expect(nodes[0].attrs?["align"] == .string("center")) + #expect(nodes[3].type == "video") + #expect(nodes[3].attrs?["src"] == .string("/files/demo-v2.mp4")) + #expect(nodes[3].attrs?["alt"] == .string("Launch demo")) + #expect(nodes[3].attrs?["title"] == .string("Demo original.mp4")) + #expect(nodes[3].attrs?["attachmentId"] == .string("video-1")) + #expect(nodes[3].attrs?["size"] == .int(8192)) + #expect(nodes[3].attrs?["width"] == .int(1280)) + #expect(nodes[3].attrs?["height"] == .int(720)) + #expect(nodes[3].attrs?["align"] == .string("right")) + #expect(nodes[1].type == "pdf") #expect(nodes[1].attrs?["src"] == .string("/files/spec-v2.pdf")) #expect(nodes[1].attrs?["name"] == .string("Spec v2.pdf")) @@ -59,7 +80,8 @@ struct NativeRichEditorMediaBlockTests { viewModel.document = NativeEditorDocument(blocks: [ imageBlock(), pdfBlock(), - attachmentBlock() + attachmentBlock(), + videoBlock() ]) return viewModel } @@ -69,6 +91,7 @@ struct NativeRichEditorMediaBlockTests { kind: .image(NativeEditorMediaBlock( source: "/files/hero-old.png", alternativeText: nil, + title: nil, attachmentID: "image-1", sizeInBytes: 2048, width: nil, @@ -81,6 +104,24 @@ struct NativeRichEditorMediaBlockTests { ) } + private func videoBlock() -> NativeEditorBlock { + NativeEditorBlock( + kind: .video(NativeEditorMediaBlock( + source: "/files/demo.mp4", + alternativeText: nil, + title: "Demo original.mp4", + attachmentID: "video-1", + sizeInBytes: 8192, + width: nil, + height: nil, + aspectRatio: nil, + alignment: nil + )), + text: AttributedString("Demo original.mp4"), + alignment: .left + ) + } + private func pdfBlock() -> NativeEditorBlock { NativeEditorBlock( kind: .pdf(NativeEditorPDFBlock( diff --git a/docmostlyTests/Editor/NativeRichEditorTableTests.swift b/docmostlyTests/Editor/NativeRichEditorTableTests.swift index 4673239..737bbe5 100644 --- a/docmostlyTests/Editor/NativeRichEditorTableTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorTableTests.swift @@ -72,6 +72,40 @@ struct NativeRichEditorTableTests { #expect(firstRowSecondCell?.attrs?["colwidth"] == .array([.int(236)])) } + @Test func editingMergedTableCellPreservesSpanAndColumnWidthAttributes() { + let table = NativeEditorTable(rows: [ + NativeEditorTableRow(cells: [ + NativeEditorTableCell( + plainText: "Merged", + isHeader: true, + backgroundColorName: "blue", + columnWidth: 120, + columnSpan: 2, + rowSpan: 2, + columnWidths: [120, 160] + ), + NativeEditorTableCell(plainText: "Status", isHeader: true, backgroundColorName: nil) + ]), + NativeEditorTableRow(cells: [ + NativeEditorTableCell(plainText: "Native", isHeader: false, backgroundColorName: nil), + NativeEditorTableCell(plainText: "Ready", isHeader: false, backgroundColorName: nil) + ]) + ]) + let block = NativeEditorBlock(kind: .table(table), text: AttributedString("Table"), alignment: .left) + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: [block]) + let blockID = viewModel.document.blocks[0].id + + viewModel.updateTableCell(blockID: blockID, rowIndex: 0, columnIndex: 0, text: "Updated") + + let firstCell = viewModel.document.proseMirrorDocument.content.first?.content?.first?.content?.first + #expect(firstCell?.attrs?["colspan"] == .int(2)) + #expect(firstCell?.attrs?["rowspan"] == .int(2)) + #expect(firstCell?.attrs?["colwidth"] == .array([.int(120), .int(160)])) + #expect(firstCell?.attrs?["backgroundColorName"] == .string("blue")) + #expect(firstCell?.content?.first?.content?.first?.text == "Updated") + } + private func tableViewModel() -> NativeRichEditorViewModel { let block = NativeEditorBlock(kind: .paragraph, text: AttributedString("/table"), alignment: .left) let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") diff --git a/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift b/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift index a2dd465..98400b6 100644 --- a/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift @@ -55,6 +55,20 @@ struct NativeRichEditorViewModelTests { #expect(viewModel.isDirty == true) } + @Test func appliesHeadingThreeSlashCommand() { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString("/heading 3"), alignment: .left) + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: [block]) + viewModel.focus(blockID: block.id) + + #expect(viewModel.filteredSlashCommands.map(\.title).contains("Heading 3")) + + viewModel.applySlashCommand(.heading3) + + #expect(viewModel.document.blocks[0].kind == .heading(level: 3)) + #expect(String(viewModel.document.blocks[0].text.characters).isEmpty) + } + @Test func applyingTableCommandCreatesRawTableBlock() { let block = NativeEditorBlock(kind: .paragraph, text: AttributedString("/table"), alignment: .left) let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") @@ -73,6 +87,68 @@ struct NativeRichEditorViewModelTests { #expect(viewModel.document.proseMirrorDocument.content.first?.content?.count == 2) } + @Test func slashCommandInventoryIncludesMediaFileAndDiagramBlocks() { + let titles = NativeEditorCommand.richCases.map(\.title) + + #expect(titles.contains("Image")) + #expect(titles.contains("Video")) + #expect(titles.contains("Audio")) + #expect(titles.contains("PDF")) + #expect(titles.contains("File")) + #expect(titles.contains("Draw.io")) + #expect(titles.contains("Excalidraw")) + } + + @Test func slashCommandInventoryIncludesInlineEverydayCommands() { + let titles = NativeEditorCommand.allCases.map(\.title) + + #expect(titles.contains("Date")) + #expect(titles.contains("Time")) + #expect(titles.contains("Status")) + #expect(titles.contains("Emoji")) + #expect(titles.contains("Math Inline")) + } + + @Test func mediaSlashCommandsMapToAttachmentImportKinds() { + #expect(NativeEditorCommand.image.attachmentImportKind == .image) + #expect(NativeEditorCommand.video.attachmentImportKind == .video) + #expect(NativeEditorCommand.audio.attachmentImportKind == .audio) + #expect(NativeEditorCommand.pdf.attachmentImportKind == .pdf) + #expect(NativeEditorCommand.fileAttachment.attachmentImportKind == .file) + #expect(NativeEditorCommand.table.attachmentImportKind == nil) + #expect(NativeEditorCommand.drawio.attachmentImportKind == nil) + } + + @Test func applyingMediaSlashCommandsCreatesPlaceholderBlocks() { + let expectations = [ + SlashCommandExpectation(command: .image, nodeType: "image", label: "Image"), + SlashCommandExpectation(command: .video, nodeType: "video", label: "Video"), + SlashCommandExpectation(command: .audio, nodeType: "audio", label: "Audio"), + SlashCommandExpectation(command: .pdf, nodeType: "pdf", label: "PDF"), + SlashCommandExpectation(command: .fileAttachment, nodeType: "attachment", label: "File attachment"), + SlashCommandExpectation(command: .drawio, nodeType: "drawio", label: "Draw.io diagram"), + SlashCommandExpectation(command: .excalidraw, nodeType: "excalidraw", label: "Excalidraw diagram") + ] + + for expectation in expectations { + let block = NativeEditorBlock( + kind: .paragraph, + text: AttributedString("/\(expectation.command.rawValue)"), + alignment: .left + ) + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: [block]) + viewModel.focus(blockID: block.id) + + viewModel.applySlashCommand(expectation.command) + + #expect(viewModel.document.blocks[0].id == block.id) + #expect(viewModel.document.blocks[0].kind.accessibilityLabel == expectation.label) + #expect(viewModel.document.blocks[0].isEditable == false) + #expect(viewModel.document.proseMirrorDocument.content.first?.type == expectation.nodeType) + } + } + @Test func applyingMermaidCommandCreatesEditableMermaidCodeBlock() { let block = NativeEditorBlock(kind: .paragraph, text: AttributedString("/mermaid"), alignment: .left) let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") @@ -87,6 +163,61 @@ struct NativeRichEditorViewModelTests { #expect(viewModel.document.proseMirrorDocument.content.first?.attrs?["language"] == .string("mermaid")) } + @Test func applyingDateTimeAndEmojiSlashCommandsReplacesSlashToken() throws { + var calendar = Calendar(identifier: .gregorian) + let utc = try #require(TimeZone(secondsFromGMT: 0)) + calendar.timeZone = utc + let now = try #require(calendar.date(from: DateComponents( + timeZone: utc, + year: 2026, + month: 6, + day: 28, + hour: 13, + minute: 45 + ))) + + let dateText = inlineSlashCommandText(command: .date, slashText: "/date", now: now) + let timeText = inlineSlashCommandText(command: .time, slashText: "/time", now: now) + let emojiText = inlineSlashCommandText(command: .emoji, slashText: "/emoji", now: now) + + #expect(dateText.contains("2026")) + #expect(dateText.contains("/date") == false) + #expect(timeText.isEmpty == false) + #expect(timeText.contains("/time") == false) + #expect(emojiText == ":") + } + + @Test func applyingStatusSlashCommandCreatesInlineStatusAtom() { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString("/status"), alignment: .left) + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: [block]) + viewModel.focus(blockID: block.id) + + #expect(viewModel.filteredSlashCommands.map(\.title).contains("Status")) + + viewModel.applySlashCommand(.status) + + let inlineNodes = proseMirrorInlineNodes(from: viewModel) + #expect(inlineNodes.map(\.type) == ["status"]) + #expect(inlineNodes.first?.attrs?["text"] == .string("Status")) + #expect(inlineNodes.first?.attrs?["color"] == .string("gray")) + } + + @Test func applyingMathInlineSlashCommandCreatesInlineMathAtom() { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString("/math inline"), alignment: .left) + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: [block]) + viewModel.focus(blockID: block.id) + + #expect(viewModel.filteredSlashCommands.map(\.title).contains("Math Inline")) + + viewModel.applySlashCommand(.mathInline) + + let inlineNodes = proseMirrorInlineNodes(from: viewModel) + #expect(inlineNodes.map(\.type) == ["mathInline"]) + #expect(inlineNodes.first?.attrs?["text"] == .string("x = y")) + } + @Test func insertingUploadedImageReplacesActiveBlockWithDocmostNode() { let block = NativeEditorBlock(kind: .paragraph, text: AttributedString("/image"), alignment: .left) let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") @@ -138,6 +269,36 @@ struct NativeRichEditorViewModelTests { #expect(viewModel.selectedBlockID == updatedBlock.id) } + @Test func insertingUploadedVideoPreservesFileTitleInDocmostNode() { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString("/video"), alignment: .left) + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: [block]) + viewModel.focus(blockID: block.id) + + viewModel.insertUploadedAttachment( + uploadedAttachment(fileName: "Launch demo.mp4", mimeType: "video/mp4", fileExt: "mp4"), + as: .video + ) + + let updatedBlock = viewModel.document.blocks[0] + guard case .video(let video) = updatedBlock.kind else { + Issue.record("Expected video block") + return + } + + let node = viewModel.document.proseMirrorDocument.content.first + #expect(video.source == "/api/files/attachment-1/Launch demo.mp4") + #expect(video.title == "Launch demo.mp4") + #expect(video.attachmentID == "attachment-1") + #expect(video.sizeInBytes == 4096) + #expect(String(updatedBlock.text.characters) == "Launch demo.mp4") + #expect(node?.type == "video") + #expect(node?.attrs?["src"] == .string("/api/files/attachment-1/Launch demo.mp4")) + #expect(node?.attrs?["title"] == .string("Launch demo.mp4")) + #expect(node?.attrs?["attachmentId"] == .string("attachment-1")) + #expect(node?.attrs?["size"] == .int(4096)) + } + @Test func deletesSelectedBlockAndKeepsAdjacentBlockActive() { let firstBlock = NativeEditorBlock(kind: .paragraph, text: AttributedString("First"), alignment: .left) let selectedBlock = NativeEditorBlock(kind: .paragraph, text: AttributedString("Selected"), alignment: .left) @@ -277,14 +438,33 @@ struct NativeRichEditorViewModelTests { viewModel.document.proseMirrorDocument.content.first?.content ?? [] } - private func uploadedAttachment() -> DocmostAttachment { + private func inlineSlashCommandText( + command: NativeEditorCommand, + slashText: String, + now: Date + ) -> String { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString(slashText), alignment: .left) + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: [block]) + viewModel.focus(blockID: block.id) + + viewModel.applySlashCommand(command, now: now) + + return String(viewModel.document.blocks[0].text.characters) + } + + private func uploadedAttachment( + fileName: String = "Report.pdf", + mimeType: String = "application/pdf", + fileExt: String = "pdf" + ) -> DocmostAttachment { DocmostAttachment( id: "attachment-1", - fileName: "Report.pdf", + fileName: fileName, filePath: nil, fileSize: 4096, - fileExt: "pdf", - mimeType: "application/pdf", + fileExt: fileExt, + mimeType: mimeType, type: "file", creatorId: "user-1", pageId: "page-1", @@ -296,3 +476,9 @@ struct NativeRichEditorViewModelTests { ) } } + +private struct SlashCommandExpectation { + let command: NativeEditorCommand + let nodeType: String + let label: String +} From 95e01db5e7dc33d365d3bd8f347da722540ce529 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 10:26:54 +0100 Subject: [PATCH 002/201] fix: preserve overlapping inline comments --- .../NativeEditorAttributedAttributes.swift | 153 ++++++++++++++++++ .../NativeEditorDocument+InlineDecoding.swift | 10 +- .../NativeEditorDocument+InlineEncoding.swift | 18 +-- ...eRichEditorViewModel+InlineAuthoring.swift | 66 ++++++-- .../NativeEditorInlineCommentMarkTests.swift | 51 ++++++ ...chEditorInlineCommentResolutionTests.swift | 33 ++++ 6 files changed, 304 insertions(+), 27 deletions(-) create mode 100644 docmostlyTests/Editor/NativeEditorInlineCommentMarkTests.swift diff --git a/docmostly/Features/Editor/NativeEditorAttributedAttributes.swift b/docmostly/Features/Editor/NativeEditorAttributedAttributes.swift index 6d3d841..bc3d5a2 100644 --- a/docmostly/Features/Editor/NativeEditorAttributedAttributes.swift +++ b/docmostly/Features/Editor/NativeEditorAttributedAttributes.swift @@ -25,6 +25,16 @@ enum NativeEditorCommentResolvedAttribute: CodableAttributedStringKey { static let name = "docmostly.commentResolved" } +struct NativeEditorInlineCommentMark: Equatable, Hashable, Sendable, Codable { + var commentID: String + var isResolved: Bool +} + +enum NativeEditorCommentMarksAttribute: CodableAttributedStringKey { + typealias Value = [NativeEditorInlineCommentMark] + static let name = "docmostly.commentMarks" +} + enum NativeEditorMentionAttribute: CodableAttributedStringKey { typealias Value = NativeEditorMention static let name = "docmostly.mention" @@ -39,3 +49,146 @@ enum NativeEditorMathInlineAttribute: CodableAttributedStringKey { typealias Value = NativeEditorMathInline static let name = "docmostly.mathInline" } + +extension AttributedString { + var nativeEditorInlineComments: [NativeEditorInlineCommentMark] { + if let comments = self[NativeEditorCommentMarksAttribute.self], comments.isEmpty == false { + return comments.normalizedNativeEditorInlineComments + } + + guard let commentID = self[NativeEditorCommentIDAttribute.self], commentID.isEmpty == false else { + return [] + } + + return [ + NativeEditorInlineCommentMark( + commentID: commentID, + isResolved: self[NativeEditorCommentResolvedAttribute.self] ?? false + ) + ] + } + + mutating func addNativeEditorInlineComment(_ comment: NativeEditorInlineCommentMark) { + setNativeEditorInlineComments( + nativeEditorInlineComments.updatingNativeEditorInlineComment(comment) + ) + } + + mutating func setNativeEditorInlineComments(_ comments: [NativeEditorInlineCommentMark]) { + let normalizedComments = comments.normalizedNativeEditorInlineComments + self[NativeEditorCommentMarksAttribute.self] = normalizedComments.isEmpty ? nil : normalizedComments + self[NativeEditorCommentIDAttribute.self] = normalizedComments.first?.commentID + self[NativeEditorCommentResolvedAttribute.self] = normalizedComments.first?.isResolved + } +} + +extension AttributedString.Runs.Run { + var nativeEditorInlineComments: [NativeEditorInlineCommentMark] { + if let comments = self[NativeEditorCommentMarksAttribute.self], comments.isEmpty == false { + return comments.normalizedNativeEditorInlineComments + } + + guard let commentID = self[NativeEditorCommentIDAttribute.self], commentID.isEmpty == false else { + return [] + } + + return [ + NativeEditorInlineCommentMark( + commentID: commentID, + isResolved: self[NativeEditorCommentResolvedAttribute.self] ?? false + ) + ] + } + + func hasNativeEditorInlineComment(commentID: String) -> Bool { + nativeEditorInlineComments.contains { $0.commentID == commentID } + } +} + +extension AttributedSubstring { + var nativeEditorInlineComments: [NativeEditorInlineCommentMark] { + if let comments = self[NativeEditorCommentMarksAttribute.self], comments.isEmpty == false { + return comments.normalizedNativeEditorInlineComments + } + + guard let commentID = self[NativeEditorCommentIDAttribute.self], commentID.isEmpty == false else { + return [] + } + + return [ + NativeEditorInlineCommentMark( + commentID: commentID, + isResolved: self[NativeEditorCommentResolvedAttribute.self] ?? false + ) + ] + } +} + +extension AttributeContainer { + mutating func addNativeEditorInlineComment(_ comment: NativeEditorInlineCommentMark) { + setNativeEditorInlineComments( + nativeEditorInlineComments.updatingNativeEditorInlineComment(comment) + ) + } + + var nativeEditorInlineComments: [NativeEditorInlineCommentMark] { + if let comments = self[NativeEditorCommentMarksAttribute.self], comments.isEmpty == false { + return comments.normalizedNativeEditorInlineComments + } + + guard let commentID = self[NativeEditorCommentIDAttribute.self], commentID.isEmpty == false else { + return [] + } + + return [ + NativeEditorInlineCommentMark( + commentID: commentID, + isResolved: self[NativeEditorCommentResolvedAttribute.self] ?? false + ) + ] + } + + mutating func setNativeEditorInlineComments(_ comments: [NativeEditorInlineCommentMark]) { + let normalizedComments = comments.normalizedNativeEditorInlineComments + self[NativeEditorCommentMarksAttribute.self] = normalizedComments.isEmpty ? nil : normalizedComments + self[NativeEditorCommentIDAttribute.self] = normalizedComments.first?.commentID + self[NativeEditorCommentResolvedAttribute.self] = normalizedComments.first?.isResolved + } +} + +extension Array where Element == NativeEditorInlineCommentMark { + var normalizedNativeEditorInlineComments: [NativeEditorInlineCommentMark] { + var seenCommentIDs: Set = [] + return filter { comment in + let commentID = comment.commentID.trimmingCharacters(in: .whitespacesAndNewlines) + guard commentID.isEmpty == false, seenCommentIDs.contains(commentID) == false else { + return false + } + seenCommentIDs.insert(commentID) + return true + } + } + + func updatingNativeEditorInlineComment( + _ updatedComment: NativeEditorInlineCommentMark + ) -> [NativeEditorInlineCommentMark] { + let normalizedUpdatedComment = NativeEditorInlineCommentMark( + commentID: updatedComment.commentID.trimmingCharacters(in: .whitespacesAndNewlines), + isResolved: updatedComment.isResolved + ) + guard normalizedUpdatedComment.commentID.isEmpty == false else { return self } + + var comments = normalizedNativeEditorInlineComments + if let index = comments.firstIndex(where: { $0.commentID == normalizedUpdatedComment.commentID }) { + comments[index] = normalizedUpdatedComment + } else { + comments.append(normalizedUpdatedComment) + } + return comments + } + + func removingNativeEditorInlineComment(commentID: String) -> [NativeEditorInlineCommentMark] { + let trimmedCommentID = commentID.trimmingCharacters(in: .whitespacesAndNewlines) + return normalizedNativeEditorInlineComments.filter { $0.commentID != trimmedCommentID } + } +} diff --git a/docmostly/Features/Editor/NativeEditorDocument+InlineDecoding.swift b/docmostly/Features/Editor/NativeEditorDocument+InlineDecoding.swift index 297c784..c31a765 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+InlineDecoding.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+InlineDecoding.swift @@ -166,9 +166,13 @@ nonisolated extension NativeEditorDocument { case .superscript: text.baselineOffset = 4 case .comment(let commentID, let isResolved): - text[NativeEditorCommentIDAttribute.self] = commentID - text[NativeEditorCommentResolvedAttribute.self] = isResolved - text.backgroundColor = .yellow.opacity(0.28) + text.addNativeEditorInlineComment(NativeEditorInlineCommentMark( + commentID: commentID, + isResolved: isResolved + )) + text.backgroundColor = text.nativeEditorInlineComments.contains { $0.isResolved == false } + ? .yellow.opacity(0.28) + : .gray.opacity(0.16) case .bold, .italic, .strikethrough, .code, .unknown: return } diff --git a/docmostly/Features/Editor/NativeEditorDocument+InlineEncoding.swift b/docmostly/Features/Editor/NativeEditorDocument+InlineEncoding.swift index 743e557..11747e8 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+InlineEncoding.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+InlineEncoding.swift @@ -110,17 +110,15 @@ nonisolated extension NativeEditorDocument { from run: AttributedString.Runs.Run, to marks: inout [ProseMirrorMark] ) { - guard let commentID = run[NativeEditorCommentIDAttribute.self], commentID.isEmpty == false else { - return + for comment in run.nativeEditorInlineComments { + appendMarkIfMissing( + ProseMirrorMark( + type: "comment", + attrs: commentAttrs(comment.commentID, comment.isResolved) + ), + to: &marks + ) } - - appendMarkIfMissing( - ProseMirrorMark( - type: "comment", - attrs: commentAttrs(commentID, run[NativeEditorCommentResolvedAttribute.self] ?? false) - ), - to: &marks - ) } private static func simpleProseMirrorMark(from mark: NativeEditorTextMark) -> ProseMirrorMark? { diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+InlineAuthoring.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+InlineAuthoring.swift index a8e3870..2968584 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+InlineAuthoring.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+InlineAuthoring.swift @@ -60,8 +60,10 @@ extension NativeRichEditorViewModel { guard trimmedCommentID.isEmpty == false else { return } applyInlineAttributes { attributes in - attributes[NativeEditorCommentIDAttribute.self] = trimmedCommentID - attributes[NativeEditorCommentResolvedAttribute.self] = isResolved + attributes.addNativeEditorInlineComment(NativeEditorInlineCommentMark( + commentID: trimmedCommentID, + isResolved: isResolved + )) attributes.backgroundColor = .yellow.opacity(0.28) } } @@ -81,8 +83,10 @@ extension NativeRichEditorViewModel { guard selection.hasSelectedRanges(in: document.blocks[index].text) else { return } document.blocks[index].text.transformAttributes(in: &selection) { attributes in - attributes[NativeEditorCommentIDAttribute.self] = trimmedCommentID - attributes[NativeEditorCommentResolvedAttribute.self] = isResolved + attributes.addNativeEditorInlineComment(NativeEditorInlineCommentMark( + commentID: trimmedCommentID, + isResolved: isResolved + )) attributes.backgroundColor = .yellow.opacity(0.28) } document.blocks[index].selection = selection @@ -162,16 +166,19 @@ extension NativeRichEditorViewModel { for index in document.blocks.indices { let ranges = document.blocks[index].text.runs.compactMap { run in - run[NativeEditorCommentIDAttribute.self] == commentID ? run.range : nil + run.hasNativeEditorInlineComment(commentID: commentID) ? run.range : nil } guard ranges.isEmpty == false else { continue } didUpdate = true for range in ranges { - document.blocks[index].text[range][NativeEditorCommentResolvedAttribute.self] = isResolved - document.blocks[index].text[range].backgroundColor = isResolved - ? .gray.opacity(0.16) - : .yellow.opacity(0.28) + let comments = document.blocks[index].text[range] + .nativeEditorInlineComments + .updatingNativeEditorInlineComment(NativeEditorInlineCommentMark( + commentID: commentID, + isResolved: isResolved + )) + document.blocks[index].text.setNativeEditorInlineComments(comments, in: range) } } @@ -186,17 +193,21 @@ extension NativeRichEditorViewModel { for index in document.blocks.indices { let ranges = document.blocks[index].text.runs.compactMap { run in - run[NativeEditorCommentIDAttribute.self] == commentID ? run.range : nil + run.hasNativeEditorInlineComment(commentID: commentID) ? run.range : nil } guard ranges.isEmpty == false else { continue } didUpdate = true for range in ranges { let highlightColor = document.blocks[index].text[range][NativeEditorHighlightColorAttribute.self] - let restoredBackgroundColor = highlightColor.flatMap { Color(docmostlyHex: $0) } - document.blocks[index].text[range][NativeEditorCommentIDAttribute.self] = nil - document.blocks[index].text[range][NativeEditorCommentResolvedAttribute.self] = nil - document.blocks[index].text[range].backgroundColor = restoredBackgroundColor + let comments = document.blocks[index].text[range] + .nativeEditorInlineComments + .removingNativeEditorInlineComment(commentID: commentID) + document.blocks[index].text.setNativeEditorInlineComments( + comments, + in: range, + fallbackBackgroundColor: highlightColor.flatMap { Color(docmostlyHex: $0) } + ) } } @@ -269,3 +280,30 @@ extension NativeRichEditorViewModel { } } } + +private extension AttributedString { + mutating func setNativeEditorInlineComments( + _ comments: [NativeEditorInlineCommentMark], + in range: Range, + fallbackBackgroundColor: Color? = nil + ) { + let normalizedComments = comments.normalizedNativeEditorInlineComments + self[range][NativeEditorCommentMarksAttribute.self] = normalizedComments.isEmpty ? nil : normalizedComments + self[range][NativeEditorCommentIDAttribute.self] = normalizedComments.first?.commentID + self[range][NativeEditorCommentResolvedAttribute.self] = normalizedComments.first?.isResolved + self[range].backgroundColor = backgroundColor( + for: normalizedComments, + fallbackBackgroundColor: fallbackBackgroundColor + ) + } + + private func backgroundColor( + for comments: [NativeEditorInlineCommentMark], + fallbackBackgroundColor: Color? + ) -> Color? { + guard comments.isEmpty == false else { return fallbackBackgroundColor } + return comments.contains { $0.isResolved == false } + ? .yellow.opacity(0.28) + : .gray.opacity(0.16) + } +} diff --git a/docmostlyTests/Editor/NativeEditorInlineCommentMarkTests.swift b/docmostlyTests/Editor/NativeEditorInlineCommentMarkTests.swift new file mode 100644 index 0000000..aa1b339 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorInlineCommentMarkTests.swift @@ -0,0 +1,51 @@ +import Foundation +import Testing +@testable import docmostly + +struct NativeEditorInlineCommentMarkTests { + @Test func preservesOverlappingInlineCommentMarks() throws { + let data = Data(""" + { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Overlap", + "marks": [ + { + "type": "comment", + "attrs": { "commentId": "comment-1", "resolved": false } + }, + { + "type": "comment", + "attrs": { "commentId": "comment-2", "resolved": true } + } + ] + } + ] + } + ] + } + """.utf8) + + let document = try NativeEditorDocument(proseMirrorJSONData: data) + let block = try #require(document.blocks.first) + let run = try #require(block.text.runs.first) + + #expect(run[NativeEditorCommentIDAttribute.self] == "comment-1") + #expect(run[NativeEditorCommentResolvedAttribute.self] == false) + #expect(run[NativeEditorCommentMarksAttribute.self] == [ + NativeEditorInlineCommentMark(commentID: "comment-1", isResolved: false), + NativeEditorInlineCommentMark(commentID: "comment-2", isResolved: true) + ]) + + let marks = try #require(document.proseMirrorDocument.content.first?.content?.first?.marks) + #expect(marks.filter { $0.type == "comment" } == [ + ProseMirrorMark(type: "comment", attrs: ["commentId": .string("comment-1"), "resolved": .bool(false)]), + ProseMirrorMark(type: "comment", attrs: ["commentId": .string("comment-2"), "resolved": .bool(true)]) + ]) + } +} diff --git a/docmostlyTests/Editor/NativeRichEditorInlineCommentResolutionTests.swift b/docmostlyTests/Editor/NativeRichEditorInlineCommentResolutionTests.swift index 5d2ee03..dd4e75a 100644 --- a/docmostlyTests/Editor/NativeRichEditorInlineCommentResolutionTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorInlineCommentResolutionTests.swift @@ -42,6 +42,27 @@ struct NativeInlineCommentResolutionTests { ))) } + @Test func removesOneOverlappingInlineCommentWithoutTouchingTheOther() { + let block = NativeEditorBlock( + kind: .paragraph, + text: overlappingMarkedText("Overlap"), + alignment: .left + ) + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: [block]) + + viewModel.removeInlineComment(commentID: "comment-1") + + let marks = proseMirrorTextMarks(from: viewModel) + #expect(marks.contains(ProseMirrorMark( + type: "comment", + attrs: ["commentId": .string("comment-1"), "resolved": .bool(false)] + )) == false) + #expect(marks == [ + ProseMirrorMark(type: "comment", attrs: ["commentId": .string("comment-2"), "resolved": .bool(true)]) + ]) + } + @Test func remoteResolutionUpdatesInlineCommentMarksWhileReadOnly() { let block = NativeEditorBlock( kind: .paragraph, @@ -162,6 +183,18 @@ struct NativeInlineCommentResolutionTests { return attributedText } + private func overlappingMarkedText(_ text: String) -> AttributedString { + var attributedText = AttributedString(text) + attributedText[NativeEditorCommentIDAttribute.self] = "comment-1" + attributedText[NativeEditorCommentResolvedAttribute.self] = false + attributedText[NativeEditorCommentMarksAttribute.self] = [ + NativeEditorInlineCommentMark(commentID: "comment-1", isResolved: false), + NativeEditorInlineCommentMark(commentID: "comment-2", isResolved: true) + ] + attributedText.backgroundColor = .yellow.opacity(0.28) + return attributedText + } + private func proseMirrorTextMarks(from viewModel: NativeRichEditorViewModel) -> [ProseMirrorMark] { viewModel.document.proseMirrorDocument.content.first?.content?.first?.marks ?? [] } From d80e892454d32491215801403a1db0108bbcc620 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 10:29:46 +0100 Subject: [PATCH 003/201] fix: make inline comment marks nonisolated --- .../Features/Editor/NativeEditorAttributedAttributes.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docmostly/Features/Editor/NativeEditorAttributedAttributes.swift b/docmostly/Features/Editor/NativeEditorAttributedAttributes.swift index bc3d5a2..5b00c59 100644 --- a/docmostly/Features/Editor/NativeEditorAttributedAttributes.swift +++ b/docmostly/Features/Editor/NativeEditorAttributedAttributes.swift @@ -25,7 +25,7 @@ enum NativeEditorCommentResolvedAttribute: CodableAttributedStringKey { static let name = "docmostly.commentResolved" } -struct NativeEditorInlineCommentMark: Equatable, Hashable, Sendable, Codable { +nonisolated struct NativeEditorInlineCommentMark: Equatable, Hashable, Sendable, Codable { var commentID: String var isResolved: Bool } From 431286b03351870ebdfec3c889ffbc6a402db31e Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 10:31:29 +0100 Subject: [PATCH 004/201] fix: make prose mirror decoding budget nonisolated --- docmostly/Features/Editor/ProseMirrorNode.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docmostly/Features/Editor/ProseMirrorNode.swift b/docmostly/Features/Editor/ProseMirrorNode.swift index 0a53362..5cf8822 100644 --- a/docmostly/Features/Editor/ProseMirrorNode.swift +++ b/docmostly/Features/Editor/ProseMirrorNode.swift @@ -76,7 +76,7 @@ nonisolated extension CodingUserInfoKey { static let proseMirrorDecodingBudget = CodingUserInfoKey(rawValue: "proseMirrorDecodingBudget")! } -final class ProseMirrorDecodingBudget { +nonisolated final class ProseMirrorDecodingBudget { private var nodeCount = 0 func consumeNode(decoder: Decoder) throws { From 2e5191b6418efe386470260f28ddcbdb1669ddab Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 10:34:27 +0100 Subject: [PATCH 005/201] fix: resolve strict concurrency compile errors --- .../Editor/NativeEditorAttributedAttributes.swift | 10 +++++----- .../Editor/NativeEditorRealtimeEventClient.swift | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorAttributedAttributes.swift b/docmostly/Features/Editor/NativeEditorAttributedAttributes.swift index 5b00c59..9df2b33 100644 --- a/docmostly/Features/Editor/NativeEditorAttributedAttributes.swift +++ b/docmostly/Features/Editor/NativeEditorAttributedAttributes.swift @@ -50,7 +50,7 @@ enum NativeEditorMathInlineAttribute: CodableAttributedStringKey { static let name = "docmostly.mathInline" } -extension AttributedString { +nonisolated extension AttributedString { var nativeEditorInlineComments: [NativeEditorInlineCommentMark] { if let comments = self[NativeEditorCommentMarksAttribute.self], comments.isEmpty == false { return comments.normalizedNativeEditorInlineComments @@ -82,7 +82,7 @@ extension AttributedString { } } -extension AttributedString.Runs.Run { +nonisolated extension AttributedString.Runs.Run { var nativeEditorInlineComments: [NativeEditorInlineCommentMark] { if let comments = self[NativeEditorCommentMarksAttribute.self], comments.isEmpty == false { return comments.normalizedNativeEditorInlineComments @@ -105,7 +105,7 @@ extension AttributedString.Runs.Run { } } -extension AttributedSubstring { +nonisolated extension AttributedSubstring { var nativeEditorInlineComments: [NativeEditorInlineCommentMark] { if let comments = self[NativeEditorCommentMarksAttribute.self], comments.isEmpty == false { return comments.normalizedNativeEditorInlineComments @@ -124,7 +124,7 @@ extension AttributedSubstring { } } -extension AttributeContainer { +nonisolated extension AttributeContainer { mutating func addNativeEditorInlineComment(_ comment: NativeEditorInlineCommentMark) { setNativeEditorInlineComments( nativeEditorInlineComments.updatingNativeEditorInlineComment(comment) @@ -156,7 +156,7 @@ extension AttributeContainer { } } -extension Array where Element == NativeEditorInlineCommentMark { +nonisolated extension Array where Element == NativeEditorInlineCommentMark { var normalizedNativeEditorInlineComments: [NativeEditorInlineCommentMark] { var seenCommentIDs: Set = [] return filter { comment in diff --git a/docmostly/Features/Editor/NativeEditorRealtimeEventClient.swift b/docmostly/Features/Editor/NativeEditorRealtimeEventClient.swift index 09a7b3f..ceb64aa 100644 --- a/docmostly/Features/Editor/NativeEditorRealtimeEventClient.swift +++ b/docmostly/Features/Editor/NativeEditorRealtimeEventClient.swift @@ -119,14 +119,14 @@ actor NativeEditorRealtimeEventClient { guard text.count <= NativeEditorRealtimeSocketFrame.maximumFrameCharacters else { throw NativeEditorRealtimeSocketFrameError.frameTooLarge } - text + return text case .data(let data): guard data.count <= NativeEditorRealtimeSocketFrame.maximumFrameCharacters else { throw NativeEditorRealtimeSocketFrameError.frameTooLarge } - String(bytes: data, encoding: .utf8) ?? "" + return String(bytes: data, encoding: .utf8) ?? "" @unknown default: - "" + return "" } } From e680628880aecb656a490011501a9c3e8cec2db8 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 10:38:38 +0100 Subject: [PATCH 006/201] fix: return decoded table cells explicitly --- .../NativeEditorDocument+Payloads.swift | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift b/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift index a545e16..b72bb91 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift @@ -161,17 +161,17 @@ nonisolated extension NativeEditorDocument { .filter { cellTypes.contains($0.type) } .prefix(NativeEditorTable.maximumColumnCount) .map { cell in - let columnWidths = tableColumnWidths(from: cell.attrs) - NativeEditorTableCell( - plainText: plainText(in: cell.content ?? []), - isHeader: cell.type == "tableHeader", - backgroundColorName: cell.attrs?["backgroundColorName"]?.stringValue, - columnWidth: columnWidths.first, - columnSpan: normalizedTableSpan(cell.attrs?["colspan"]?.intValue), - rowSpan: normalizedTableSpan(cell.attrs?["rowspan"]?.intValue), - columnWidths: columnWidths - ) - } + let columnWidths = tableColumnWidths(from: cell.attrs) + return NativeEditorTableCell( + plainText: plainText(in: cell.content ?? []), + isHeader: cell.type == "tableHeader", + backgroundColorName: cell.attrs?["backgroundColorName"]?.stringValue, + columnWidth: columnWidths.first, + columnSpan: normalizedTableSpan(cell.attrs?["colspan"]?.intValue), + rowSpan: normalizedTableSpan(cell.attrs?["rowspan"]?.intValue), + columnWidths: columnWidths + ) + } } private static func tableColumnWidths(from attrs: [String: ProseMirrorJSONValue]?) -> [Int] { From f614ed7beee275eaa94c61a9c5b989225a58bf6d Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 10:51:36 +0100 Subject: [PATCH 007/201] feat: import markdown media links as native blocks --- ...ativeEditorMarkdownParser+RichBlocks.swift | 134 +++++++++++++++++- .../NativeEditorMarkdownImportTests.swift | 55 +++++++ 2 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift index f0a84b3..6b79166 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift @@ -17,7 +17,7 @@ extension NativeEditorMarkdownParser { } static func singleLineRichBlock(from line: String) -> NativeEditorBlock? { - imageMarkdownBlock(from: line) + imageMarkdownBlock(from: line) ?? linkedFileMarkdownBlock(from: line) } static func richMarkdownLine(from block: NativeEditorBlock) -> String? { @@ -241,6 +241,102 @@ extension NativeEditorMarkdownParser { ) } + private static func linkedFileMarkdownBlock(from line: String) -> NativeEditorBlock? { + guard + line.hasPrefix("["), + let closeTitleIndex = line.firstIndex(of: "]") + else { + return nil + } + + let openDestinationIndex = line.index(after: closeTitleIndex) + guard + openDestinationIndex < line.endIndex, + line[openDestinationIndex] == "(", + let closeDestinationIndex = line.lastIndex(of: ")"), + closeDestinationIndex == line.index(before: line.endIndex) + else { + return nil + } + + let titleStartIndex = line.index(after: line.startIndex) + let title = unescapedMarkdownLinkText(String(line[titleStartIndex.. NativeEditorBlockKind? { + guard let fileExtension = markdownLinkFileExtension(from: source) else { return nil } + let title = title.isEmpty ? nil : title + + if videoFileExtensions.contains(fileExtension) { + return .video(NativeEditorMediaBlock( + source: source, + alternativeText: nil, + title: title, + attachmentID: nil, + sizeInBytes: nil, + width: nil, + height: nil, + aspectRatio: nil, + alignment: nil + )) + } + + if audioFileExtensions.contains(fileExtension) { + return .audio(NativeEditorMediaBlock( + source: source, + alternativeText: nil, + title: title, + attachmentID: nil, + sizeInBytes: nil, + width: nil, + height: nil, + aspectRatio: nil, + alignment: nil + )) + } + + if fileExtension == "pdf" { + return .pdf(NativeEditorPDFBlock( + source: source, + name: title, + attachmentID: nil, + sizeInBytes: nil, + width: nil, + height: nil + )) + } + + return .attachment(NativeEditorAttachmentBlock( + url: source, + name: title, + mimeType: nil, + sizeInBytes: nil, + attachmentID: nil + )) + } + + private static func linkedFileBlock(kind: NativeEditorBlockKind) -> NativeEditorBlock { + switch kind { + case .video(let media): + richBlock(kind: kind, rawNode: NativeEditorRichBlockNodeFactory.mediaNode(from: media, type: "video")) + case .audio(let media): + richBlock(kind: kind, rawNode: NativeEditorRichBlockNodeFactory.mediaNode(from: media, type: "audio")) + case .pdf(let pdf): + richBlock(kind: kind, rawNode: NativeEditorRichBlockNodeFactory.pdfNode(from: pdf)) + case .attachment(let attachment): + richBlock(kind: kind, rawNode: NativeEditorRichBlockNodeFactory.attachmentNode(from: attachment)) + default: + richBlock(kind: kind, rawNode: ProseMirrorNode(type: "paragraph")) + } + } + private static func richBlock(kind: NativeEditorBlockKind, rawNode: ProseMirrorNode) -> NativeEditorBlock { NativeEditorBlock( kind: kind, @@ -376,6 +472,30 @@ extension NativeEditorMarkdownParser { return source.trimmingCharacters(in: .whitespacesAndNewlines) } + private static func markdownLinkFileExtension(from source: String) -> String? { + let pathSource: String + if let components = URLComponents(string: source), components.scheme != nil { + guard let componentPath = components.path.nonEmpty else { return nil } + pathSource = componentPath + } else { + pathSource = source + } + + let path = pathSource.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false) + .first? + .split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false) + .first + .map(String.init) ?? pathSource + guard + let fileExtension = path.split(separator: ".").last?.lowercased(), + fileExtension != path.lowercased() + else { + return nil + } + + return fileExtension + } + private static func escapedMarkdownLinkText(_ text: String) -> String { text.replacing("\\", with: "\\\\") .replacing("[", with: "\\[") @@ -418,10 +538,22 @@ extension NativeEditorMarkdownParser { .replacing(">", with: ">") .replacing("&", with: "&") } + + private static var videoFileExtensions: Set { + ["avi", "m4v", "mkv", "mov", "mp4", "webm"] + } + + private static var audioFileExtensions: Set { + ["aac", "aiff", "flac", "m4a", "mp3", "ogg", "opus", "wav"] + } } private extension String { var trimmedMarkdownBlockText: String { trimmingCharacters(in: .whitespacesAndNewlines) } + + var nonEmpty: String? { + isEmpty ? nil : self + } } diff --git a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift new file mode 100644 index 0000000..aba54ec --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing +@testable import docmostly + +struct NativeEditorMarkdownImportTests { + @Test func markdownLinksImportAsNativeMediaAndAttachmentBlocks() throws { + let blocks = NativeEditorMarkdownParser.blocks(from: """ + [Launch demo.mp4](/files/demo.mp4) + [Release audio.m4a](/files/audio.m4a) + [Spec.pdf](/files/spec.pdf) + [Archive.zip](/files/archive.zip) + """) + + #expect(blocks.count == 4) + + guard case .video(let video) = blocks[0].kind else { + Issue.record("Expected video Markdown link to import as a native video block.") + return + } + #expect(video.source == "/files/demo.mp4") + #expect(video.title == "Launch demo.mp4") + #expect(blocks[0].rawNode?.type == "video") + + guard case .audio(let audio) = blocks[1].kind else { + Issue.record("Expected audio Markdown link to import as a native audio block.") + return + } + #expect(audio.source == "/files/audio.m4a") + #expect(audio.title == "Release audio.m4a") + #expect(blocks[1].rawNode?.type == "audio") + + guard case .pdf(let pdf) = blocks[2].kind else { + Issue.record("Expected PDF Markdown link to import as a native PDF block.") + return + } + #expect(pdf.source == "/files/spec.pdf") + #expect(pdf.name == "Spec.pdf") + #expect(blocks[2].rawNode?.type == "pdf") + + guard case .attachment(let attachment) = blocks[3].kind else { + Issue.record("Expected file Markdown link to import as a native attachment block.") + return + } + #expect(attachment.url == "/files/archive.zip") + #expect(attachment.name == "Archive.zip") + #expect(blocks[3].rawNode?.type == "attachment") + } + + @Test func genericMarkdownLinksRemainEditableParagraphText() throws { + let block = try #require(NativeEditorMarkdownParser.blocks(from: "[Example](https://example.com)").first) + + #expect(block.kind == .paragraph) + #expect(String(block.text.characters) == "Example") + } +} From 0b5f5b967e70bd3fb078844cfc224070336f98ab Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 10:56:33 +0100 Subject: [PATCH 008/201] fix: isolate markdown import tests to main actor --- docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift index aba54ec..6f68854 100644 --- a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift +++ b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import docmostly +@MainActor struct NativeEditorMarkdownImportTests { @Test func markdownLinksImportAsNativeMediaAndAttachmentBlocks() throws { let blocks = NativeEditorMarkdownParser.blocks(from: """ From 2eafcbde1dfe472e58eb46d5d223cf3ac3b7a8a8 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 11:12:42 +0100 Subject: [PATCH 009/201] fix: stabilize editor inline atom encoding --- .../Editor/NativeEditorCommand+Behavior.swift | 18 +++- .../NativeEditorDocument+Encoding.swift | 84 +++++++++++++++++-- ...iveEditorMarkdownParser+DocmostLinks.swift | 49 +++++++++-- .../Editor/NativeEditorMarkdownParser.swift | 8 +- .../Editor/NativeRichEditorViewModel.swift | 9 +- .../NativeEditorInlineAtomEncodingTests.swift | 23 +++++ .../NativeRichEditorViewModelTests.swift | 11 +++ 7 files changed, 177 insertions(+), 25 deletions(-) create mode 100644 docmostlyTests/Editor/NativeEditorInlineAtomEncodingTests.swift diff --git a/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift b/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift index 0d81972..5e8c6fe 100644 --- a/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift +++ b/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift @@ -19,10 +19,20 @@ extension NativeEditorCommand { } func matches(query: String) -> Bool { - guard query.isEmpty == false else { return true } + matchPriority(query: query) != nil + } + + func matchPriority(query: String) -> Int? { + guard query.isEmpty == false else { return 0 } + + if title.localizedStandardContains(query) || rawValue.localizedStandardContains(query) { + return 0 + } + + if subtitle.localizedStandardContains(query) { + return 1 + } - return title.localizedStandardContains(query) || - subtitle.localizedStandardContains(query) || - rawValue.localizedStandardContains(query) + return nil } } diff --git a/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift b/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift index 14aa881..73677dd 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift @@ -281,12 +281,54 @@ nonisolated extension NativeEditorDocument { from run: AttributedString.Runs.Run, in attributedText: AttributedString ) -> [ProseMirrorNode] { - if let atomNode = inlineAtomNode(from: run) { - return [atomNode] + let runText = String(attributedText.characters[run.range]) + + if let atom = inlineAtom(from: run) { + return inlineNodes(from: atom, runText: runText, run: run) } - let runText = String(attributedText.characters[run.range]) - let components = runText.split(separator: "\n", omittingEmptySubsequences: false) + return textNodes(from: runText, marks: marks(from: run)) + } + + private struct InlineAtomEncoding { + var displayText: String + var node: ProseMirrorNode + var presentationMarkType: String? + + init( + displayText: String, + node: ProseMirrorNode, + presentationMarkType: String? = nil + ) { + self.displayText = displayText + self.node = node + self.presentationMarkType = presentationMarkType + } + } + + private static func inlineNodes( + from atom: InlineAtomEncoding, + runText: String, + run: AttributedString.Runs.Run + ) -> [ProseMirrorNode] { + guard + runText != atom.displayText, + atom.displayText.isEmpty == false, + let atomRange = runText.range(of: atom.displayText) + else { + return [atom.node] + } + + let prefix = String(runText[.. [ProseMirrorNode] { + let components = text.split(separator: "\n", omittingEmptySubsequences: false) var nodes: [ProseMirrorNode] = [] for offset in components.indices { @@ -296,28 +338,52 @@ nonisolated extension NativeEditorDocument { let value = String(components[offset]) guard value.isEmpty == false else { continue } - nodes.append(ProseMirrorNode(type: "text", marks: marks(from: run), text: value)) + nodes.append(ProseMirrorNode(type: "text", marks: marks, text: value)) } return nodes } - private static func inlineAtomNode(from run: AttributedString.Runs.Run) -> ProseMirrorNode? { + private static func inlineAtom(from run: AttributedString.Runs.Run) -> InlineAtomEncoding? { if let mention = run[NativeEditorMentionAttribute.self] { - return ProseMirrorNode(type: "mention", attrs: attrs(from: mention)) + return InlineAtomEncoding( + displayText: mention.displayText, + node: ProseMirrorNode(type: "mention", attrs: attrs(from: mention)) + ) } if let status = run[NativeEditorStatusAttribute.self] { - return ProseMirrorNode(type: "status", attrs: statusAttrs(from: status)) + return InlineAtomEncoding( + displayText: status.text, + node: ProseMirrorNode(type: "status", attrs: statusAttrs(from: status)), + presentationMarkType: "bold" + ) } if let math = run[NativeEditorMathInlineAttribute.self] { - return ProseMirrorNode(type: "mathInline", attrs: ["text": .string(math.text)]) + return InlineAtomEncoding( + displayText: math.text, + node: ProseMirrorNode(type: "mathInline", attrs: ["text": .string(math.text)]), + presentationMarkType: "code" + ) } return nil } + private static func marksForTextSurroundingAtom( + from run: AttributedString.Runs.Run, + atom: InlineAtomEncoding + ) -> [ProseMirrorMark]? { + guard var marks = marks(from: run) else { return nil } + + if let presentationMarkType = atom.presentationMarkType { + marks.removeAll { $0.type == presentationMarkType } + } + + return marks.isEmpty ? nil : marks + } + private static func inlineNode(from item: NativeEditorInlineContent) -> ProseMirrorNode { switch item { case .text(let text, let marks): diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift index e08411a..2812554 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift @@ -13,17 +13,31 @@ extension NativeEditorMarkdownParser { var pageLink: DocmostPageLink } - static func appendMarkdownText(_ markdown: String, to result: inout AttributedString) { + static func appendMarkdownText( + _ markdown: String, + to result: inout AttributedString, + parsesInlineMarkdown: Bool = true + ) { guard markdown.isEmpty == false else { return } var remaining = markdown[...] + var didAppendAtom = false while let link = nextDocmostPageMarkdownLink(in: remaining) { - appendMarkdownTextWithBareDocmostPageLinks(String(remaining[.. String { @@ -38,23 +52,42 @@ extension NativeEditorMarkdownParser { private static func appendMarkdownTextWithBareDocmostPageLinks( _ markdown: String, - to result: inout AttributedString + to result: inout AttributedString, + parsesInlineMarkdown: Bool ) { guard markdown.isEmpty == false else { return } var remaining = markdown[...] + var didAppendAtom = false while let link = nextBareDocmostPageLink(in: remaining) { - appendPlainMarkdownText(String(remaining[.. Date: Sun, 28 Jun 2026 11:43:24 +0100 Subject: [PATCH 010/201] test: cover inline markdown marks around atoms --- .../NativeEditorMentionMarkdownTests.swift | 24 +++++++++++++++++++ .../NativeRichEditorMechanicsTests.swift | 16 +++++++++++++ 2 files changed, 40 insertions(+) diff --git a/docmostlyTests/Editor/NativeEditorMentionMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorMentionMarkdownTests.swift index b80fbb7..8b54ab5 100644 --- a/docmostlyTests/Editor/NativeEditorMentionMarkdownTests.swift +++ b/docmostlyTests/Editor/NativeEditorMentionMarkdownTests.swift @@ -46,6 +46,30 @@ struct NativeEditorMentionMarkdownTests { #expect(attrs["anchorId"] == .string("shipping")) } + @Test func pasteMarkdownPageMentionsPreservesSurroundingInlineMarks() { + let intro = NativeEditorBlock(kind: .paragraph, text: AttributedString("Intro"), alignment: .left) + let viewModel = configuredViewModel(blocks: [intro]) + viewModel.focus(blockID: intro.id) + + viewModel.pasteMarkdown( + """ + Discuss **urgent** [spec](https://example.com/spec) before \ + [Roadmap](https://docs.example.com/s/product/p/native-roadmap-abc123) now + """ + ) + + let inlineNodes = viewModel.document.proseMirrorDocument.content.last?.content ?? [] + #expect(inlineNodes.map(\.type) == ["text", "text", "text", "text", "text", "mention", "text"]) + #expect(inlineNodes[1].text == "urgent") + #expect(inlineNodes[1].marks?.contains(ProseMirrorMark(type: "bold")) == true) + #expect(inlineNodes[3].text == "spec") + #expect( + inlineNodes[3].marks?.contains( + ProseMirrorMark(type: "link", attrs: ["href": .string("https://example.com/spec")]) + ) == true + ) + } + @Test func documentMarkdownConversionPreservesMentionAtomsAsDocmostLinks() { var text = AttributedString("Discuss ") var mentionText = AttributedString("Roadmap") diff --git a/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift b/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift index 08d4ccf..ab8e00a 100644 --- a/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift @@ -211,6 +211,22 @@ struct NativeRichEditorMechanicsTests { #expect(inlineNodes[2].text == " today") } + @Test func pasteMarkdownInlineMathPreservesSurroundingInlineMarks() { + let intro = NativeEditorBlock(kind: .paragraph, text: AttributedString("Intro"), alignment: .left) + let viewModel = configuredViewModel(blocks: [intro]) + viewModel.focus(blockID: intro.id) + + viewModel.pasteMarkdown("Formula **important** $E = mc^2$ `today`") + + let inlineNodes = viewModel.document.proseMirrorDocument.content.last?.content ?? [] + #expect(inlineNodes.map(\.type) == ["text", "text", "text", "mathInline", "text", "text"]) + #expect(inlineNodes[1].text == "important") + #expect(inlineNodes[1].marks?.contains(ProseMirrorMark(type: "bold")) == true) + #expect(inlineNodes[3].attrs?["text"] == .string("E = mc^2")) + #expect(inlineNodes[5].text == "today") + #expect(inlineNodes[5].marks?.contains(ProseMirrorMark(type: "code")) == true) + } + @Test func indentAndOutdentActiveListBlock() { let block = NativeEditorBlock(kind: .bulletListItem, text: AttributedString("Nested"), alignment: .left) let viewModel = configuredViewModel(blocks: [block]) From 9c335d929c8d5a709b875c050417dd6d630948a4 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 11:54:09 +0100 Subject: [PATCH 011/201] fix: preserve inline marks around pasted atoms --- ...iveEditorMarkdownParser+DocmostLinks.swift | 18 +- ...tiveEditorMarkdownParser+InlineMarks.swift | 177 ++++++++++++++++++ .../Editor/NativeEditorMarkdownParser.swift | 4 +- 3 files changed, 188 insertions(+), 11 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift index 2812554..9af2b0e 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift @@ -16,7 +16,7 @@ extension NativeEditorMarkdownParser { static func appendMarkdownText( _ markdown: String, to result: inout AttributedString, - parsesInlineMarkdown: Bool = true + usesFoundationMarkdownParser: Bool = true ) { guard markdown.isEmpty == false else { return } @@ -26,7 +26,7 @@ extension NativeEditorMarkdownParser { appendMarkdownTextWithBareDocmostPageLinks( String(remaining[.. AttributedString { + var output = AttributedString("") + var remaining = markdown[...] + + while let match = nextInlineMarkdownMatch(in: remaining) { + output += AttributedString(String(remaining[.. String { var output = text let intent = run.inlinePresentationIntent ?? [] @@ -39,4 +53,167 @@ extension NativeEditorMarkdownParser { .replacing("[", with: "\\[") .replacing("]", with: "\\]") } + + private struct InlineMarkdownMatch { + var range: Range + var text: AttributedString + var priority: Int + } + + private static func nextInlineMarkdownMatch(in markdown: Substring) -> InlineMarkdownMatch? { + [ + codeInlineMarkdownMatch(in: markdown), + linkedInlineMarkdownMatch(in: markdown), + delimitedInlineMarkdownMatch( + in: markdown, + delimiter: "**", + intent: .stronglyEmphasized, + priority: 2 + ), + delimitedInlineMarkdownMatch( + in: markdown, + delimiter: "~~", + intent: .strikethrough, + priority: 3 + ), + delimitedInlineMarkdownMatch( + in: markdown, + delimiter: "*", + intent: .emphasized, + priority: 4 + ) + ] + .compactMap { $0 } + .min { lhs, rhs in + if lhs.range.lowerBound == rhs.range.lowerBound { + return lhs.priority < rhs.priority + } + + return lhs.range.lowerBound < rhs.range.lowerBound + } + } + + private static func codeInlineMarkdownMatch(in markdown: Substring) -> InlineMarkdownMatch? { + guard + let openIndex = markdown.firstIndex(of: "`"), + let closeIndex = markdown[markdown.index(after: openIndex)...].firstIndex(of: "`") + else { + return nil + } + + let contentStart = markdown.index(after: openIndex) + let content = String(markdown[contentStart.. InlineMarkdownMatch? { + var searchStart = markdown.startIndex + + while searchStart < markdown.endIndex, + let openLabelIndex = markdown[searchStart...].firstIndex(of: "[") { + if isImageMarker(before: openLabelIndex, in: markdown) { + searchStart = markdown.index(after: openLabelIndex) + continue + } + + guard + let closeLabelIndex = markdown[markdown.index(after: openLabelIndex)...].firstIndex(of: "]"), + markdown.index(after: closeLabelIndex) < markdown.endIndex, + markdown[markdown.index(after: closeLabelIndex)] == "(", + let closeDestinationIndex = markdown[ + markdown.index(after: markdown.index(after: closeLabelIndex))... + ].firstIndex(of: ")") + else { + return nil + } + + let labelStartIndex = markdown.index(after: openLabelIndex) + let destinationStartIndex = markdown.index(after: markdown.index(after: closeLabelIndex)) + let label = String(markdown[labelStartIndex.. InlineMarkdownMatch? { + guard + let openRange = markdown.range(of: delimiter), + let closeRange = markdown[openRange.upperBound...].range(of: delimiter) + else { + return nil + } + + if delimiter == "*", isPartOfStrongDelimiter(openRange, in: markdown) { + return nil + } + + let content = String(markdown[openRange.upperBound.. Bool { + guard index > markdown.startIndex else { return false } + return markdown[markdown.index(before: index)] == "!" + } + + private static func markdownLinkDestination(from destination: String) -> String { + let trimmedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) + + if trimmedDestination.hasPrefix("<"), + let closeIndex = trimmedDestination.firstIndex(of: ">") { + let sourceStartIndex = trimmedDestination.index(after: trimmedDestination.startIndex) + return String(trimmedDestination[sourceStartIndex.., + in markdown: Substring + ) -> Bool { + if range.lowerBound > markdown.startIndex, + markdown[markdown.index(before: range.lowerBound)] == "*" { + return true + } + + return range.upperBound < markdown.endIndex && markdown[range.upperBound] == "*" + } } diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift index 777bef7..7efd8bc 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift @@ -200,7 +200,7 @@ enum NativeEditorMarkdownParser { appendMarkdownText( String(remaining[.. Date: Sun, 28 Jun 2026 12:35:41 +0100 Subject: [PATCH 012/201] feat: improve editor fidelity --- .../Editor/NativeEditorCommand+Behavior.swift | 103 ++++++++++++++ .../NativeEditorDocument+Payloads.swift | 24 +++- .../Editor/NativeEditorInlineContent.swift | 21 +++ .../NativeEditorMarkdownParser+Tables.swift | 26 +++- .../Editor/NativeEditorMarkdownParser.swift | 4 +- .../NativeEditorRichBlockPayloads.swift | 24 ++++ .../NativeRichEditorViewModel+Tables.swift | 9 +- .../NativeEditorSlashCommandTests.swift | 32 +++++ .../NativeRichEditorMechanicsTests.swift | 35 ++++- .../Editor/NativeRichEditorTableTests.swift | 130 ++++++++++++++++++ 10 files changed, 399 insertions(+), 9 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift b/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift index 5e8c6fe..c5b2555 100644 --- a/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift +++ b/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift @@ -33,6 +33,109 @@ extension NativeEditorCommand { return 1 } + if searchTerms.contains(where: { $0.localizedStandardContains(query) }) { + return 1 + } + return nil } + + private var searchTerms: [String] { + switch self { + case .paragraph: + ["p", "text", "paragraph"] + case .heading1: + ["title", "big", "large", "h1"] + case .heading2: + ["subtitle", "medium", "h2"] + case .heading3: + ["subtitle", "small", "h3"] + case .bulletedList: + ["unordered", "point", "list"] + case .numberedList: + ["numbered", "ordered", "list", "ol"] + case .todoList: + ["todo", "task", "list", "check", "checkbox"] + case .quote: + ["blockquote", "quotes"] + case .codeBlock: + ["codeblock", "snippet"] + case .image: + ["photo", "picture", "media", "file", "attachment"] + case .video: + ["mp4", "media", "file", "attachment"] + case .audio: + ["music", "sound", "mp3", "media", "file", "attachment"] + case .pdf: + ["document", "embed"] + case .fileAttachment: + ["upload", "csv", "zip"] + case .table: + ["rows", "columns"] + case .baseInline: + ["base", "database", "grid", "spreadsheet"] + case .kanban: + ["board", "cards", "status", "task", "database"] + case .callout: + ["notice", "panel", "info", "warning", "success", "error", "danger"] + case .details: + ["collapsible", "block", "toggle", "details", "expand"] + case .mathInline: + ["math", "inline", "mathinline", "inlinemath", "inline math", "equation", "katex", "latex", "tex"] + case .pageBreak: + ["page", "break", "pagebreak", "print"] + case .divider: + ["horizontal rule", "hr"] + case .columns: + ["layout", "split", "side"] + case .columns3: + ["layout", "split", "triple"] + case .columns4, .columns5: + ["layout", "split"] + case .subpages: + ["child", "children", "nested", "hierarchy", "toc"] + case .syncedBlock: + ["sync", "excerpt", "transclusion", "reusable", "snippet"] + case .embed: + ["url", "external", "iframe"] + case .iframeEmbed: + ["iframe"] + case .airtableEmbed: + ["airtable"] + case .loomEmbed: + ["loom"] + case .figmaEmbed: + ["figma"] + case .typeformEmbed: + ["typeform"] + case .miroEmbed: + ["miro"] + case .youtubeEmbed: + ["youtube", "yt", "media", "video"] + case .vimeoEmbed: + ["vimeo"] + case .framerEmbed: + ["framer"] + case .googleDriveEmbed: + ["google drive", "gdrive"] + case .googleSheetsEmbed: + ["google sheets", "gsheets"] + case .mathBlock: + ["math", "block", "mathblock", "block math", "equation", "katex", "latex", "tex"] + case .mermaid: + ["diagrams", "chart", "uml"] + case .drawio: + ["diagrams", "charts", "uml", "whiteboard"] + case .excalidraw: + ["diagrams", "draw", "sketch", "whiteboard"] + case .date: + ["today"] + case .time: + ["now", "clock"] + case .status: + ["badge", "label", "lozenge"] + case .emoji: + ["icon", "smiley", "emoticon", "symbol", "reaction"] + } + } } diff --git a/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift b/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift index b72bb91..5437de2 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift @@ -162,8 +162,12 @@ nonisolated extension NativeEditorDocument { .prefix(NativeEditorTable.maximumColumnCount) .map { cell in let columnWidths = tableColumnWidths(from: cell.attrs) + let cellContent = cell.content ?? [] + let inlineContent = inlineContent(from: cellContent) return NativeEditorTableCell( - plainText: plainText(in: cell.content ?? []), + plainText: inlineContent.plainText, + inlineContent: inlineContent.preservedForTableCell, + preservedContent: preservedTableCellContent(from: cellContent, inlineContent: inlineContent), isHeader: cell.type == "tableHeader", backgroundColorName: cell.attrs?["backgroundColorName"]?.stringValue, columnWidth: columnWidths.first, @@ -174,6 +178,24 @@ nonisolated extension NativeEditorDocument { } } + private static func preservedTableCellContent( + from content: [ProseMirrorNode], + inlineContent: [NativeEditorInlineContent] + ) -> [ProseMirrorNode]? { + guard content.isEmpty == false else { return nil } + + let hasBlockContent = content.count != 1 || content.first?.type != "paragraph" + let hasUnsupportedInlineContent = inlineContent.contains { item in + if case .unsupported = item { + return true + } + + return false + } + + return hasBlockContent || hasUnsupportedInlineContent ? content : nil + } + private static func tableColumnWidths(from attrs: [String: ProseMirrorJSONValue]?) -> [Int] { guard let value = attrs?["colwidth"] ?? attrs?["colWidth"] else { return [] diff --git a/docmostly/Features/Editor/NativeEditorInlineContent.swift b/docmostly/Features/Editor/NativeEditorInlineContent.swift index 630da3c..7b41982 100644 --- a/docmostly/Features/Editor/NativeEditorInlineContent.swift +++ b/docmostly/Features/Editor/NativeEditorInlineContent.swift @@ -46,6 +46,27 @@ nonisolated enum NativeEditorInlineContent: Equatable, Hashable, Sendable, Codab true } } + + var requiresTableCellInlinePreservation: Bool { + switch self { + case .text(_, let marks): + marks.isEmpty == false + case .hardBreak: + false + case .mention, .status, .mathInline, .unsupported: + true + } + } +} + +nonisolated extension Array where Element == NativeEditorInlineContent { + var plainText: String { + map(\.plainText).joined() + } + + var preservedForTableCell: [NativeEditorInlineContent]? { + contains(where: \.requiresTableCellInlinePreservation) ? self : nil + } } nonisolated enum NativeEditorTextMark: Equatable, Hashable, Sendable, Codable { diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift index 9fa119a..b199789 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift @@ -64,11 +64,25 @@ extension NativeEditorMarkdownParser { ) -> NativeEditorTableRow { NativeEditorTableRow( cells: normalizedTableCells(cells, columnCount: columnCount).map { - NativeEditorTableCell(plainText: $0, isHeader: isHeader, backgroundColorName: nil) + tableCell(from: $0, isHeader: isHeader) } ) } + private static func tableCell(from markdown: String, isHeader: Bool) -> NativeEditorTableCell { + let attributedText = inlineText(from: markdown) + let inlineContent = NativeEditorDocument + .inlineContent(from: NativeEditorDocument.inlineNodes(from: attributedText)) + .preservedForTableCell + + return NativeEditorTableCell( + plainText: String(attributedText.characters), + inlineContent: inlineContent, + isHeader: isHeader, + backgroundColorName: nil + ) + } + private static func normalizedTableCells(_ cells: [String], columnCount: Int) -> [String] { var result = Array(cells.prefix(columnCount)) @@ -147,10 +161,18 @@ extension NativeEditorMarkdownParser { } private static func markdownTableRow(from row: NativeEditorTableRow, columnCount: Int) -> String { - let cells = normalizedTableCells(row.cells.map(\.plainText), columnCount: columnCount) + let cells = normalizedTableCells(row.cells.map(markdownTableCellContent), columnCount: columnCount) return "| \(cells.map(escapedMarkdownTableCell).joined(separator: " | ")) |" } + private static func markdownTableCellContent(from cell: NativeEditorTableCell) -> String { + guard cell.preservedContent == nil, let inlineContent = cell.inlineContent else { + return cell.plainText + } + + return inlineMarkdown(from: NativeEditorDocument.attributedText(from: inlineContent)) + } + private static func markdownTableSeparatorRow(columnCount: Int) -> String { "| \(Array(repeating: "---", count: columnCount).joined(separator: " | ")) |" } diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift index 7efd8bc..e25b877 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift @@ -191,7 +191,7 @@ enum NativeEditorMarkdownParser { ) } - private static func inlineText(from markdown: String) -> AttributedString { + static func inlineText(from markdown: String) -> AttributedString { var result = AttributedString("") var remaining = markdown[...] @@ -305,7 +305,7 @@ enum NativeEditorMarkdownParser { return min(columns / 2, 8) } - private static func inlineMarkdown(from text: AttributedString) -> String { + static func inlineMarkdown(from text: AttributedString) -> String { var output = "" for run in text.runs { diff --git a/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift b/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift index 6512c04..ac0fe11 100644 --- a/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift +++ b/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift @@ -27,12 +27,36 @@ nonisolated struct NativeEditorTableRow: Equatable, Hashable, Sendable { nonisolated struct NativeEditorTableCell: Equatable, Hashable, Sendable { var plainText: String + var inlineContent: [NativeEditorInlineContent]? + var preservedContent: [ProseMirrorNode]? var isHeader: Bool var backgroundColorName: String? var columnWidth: Int? var columnSpan: Int = 1 var rowSpan: Int = 1 var columnWidths: [Int] = [] + + init( + plainText: String, + inlineContent: [NativeEditorInlineContent]? = nil, + preservedContent: [ProseMirrorNode]? = nil, + isHeader: Bool, + backgroundColorName: String?, + columnWidth: Int? = nil, + columnSpan: Int = 1, + rowSpan: Int = 1, + columnWidths: [Int] = [] + ) { + self.plainText = plainText + self.inlineContent = inlineContent + self.preservedContent = preservedContent + self.isHeader = isHeader + self.backgroundColorName = backgroundColorName + self.columnWidth = columnWidth + self.columnSpan = columnSpan + self.rowSpan = rowSpan + self.columnWidths = columnWidths + } } nonisolated struct NativeEditorMediaBlock: Equatable, Hashable, Sendable { diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+Tables.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+Tables.swift index 38f5f3f..a57ee59 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+Tables.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+Tables.swift @@ -9,6 +9,8 @@ extension NativeRichEditorViewModel { } table.rows[rowIndex].cells[columnIndex].plainText = text + table.rows[rowIndex].cells[columnIndex].inlineContent = nil + table.rows[rowIndex].cells[columnIndex].preservedContent = nil } } @@ -128,7 +130,7 @@ nonisolated enum NativeEditorTableNodeFactory { ProseMirrorNode( type: cell.isHeader ? "tableHeader" : "tableCell", attrs: cellAttrs(from: cell), - content: [paragraphNode(cell.plainText)] + content: cell.preservedContent ?? [paragraphNode(from: cell)] ) } @@ -158,10 +160,11 @@ nonisolated enum NativeEditorTableNodeFactory { return attrs.isEmpty ? nil : attrs } - private static func paragraphNode(_ text: String) -> ProseMirrorNode { + private static func paragraphNode(from cell: NativeEditorTableCell) -> ProseMirrorNode { ProseMirrorNode( type: "paragraph", - content: NativeEditorDocument.inlineNodes(from: AttributedString(text)) + content: cell.inlineContent.map(NativeEditorDocument.inlineNodes(from:)) ?? + NativeEditorDocument.inlineNodes(from: AttributedString(cell.plainText)) ) } } diff --git a/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift b/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift index 5b73ea1..b240df3 100644 --- a/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift +++ b/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift @@ -26,6 +26,24 @@ struct NativeEditorSlashCommandTests { #expect(titles.contains("Google Sheets")) } + @Test func slashCommandFilteringUsesDocmostSearchTerms() { + let expectations = [ + SlashCommandFilterExpectation(query: "today", title: "Date"), + SlashCommandFilterExpectation(query: "now", title: "Time"), + SlashCommandFilterExpectation(query: "checkbox", title: "To-do List"), + SlashCommandFilterExpectation(query: "hr", title: "Divider"), + SlashCommandFilterExpectation(query: "pagebreak", title: "Page Break"), + SlashCommandFilterExpectation(query: "latex", title: "Math Inline"), + SlashCommandFilterExpectation(query: "lozenge", title: "Status"), + SlashCommandFilterExpectation(query: "reaction", title: "Emoji") + ] + + for expectation in expectations { + let titles = slashCommandTitles(for: expectation.query) + #expect(titles.contains(expectation.title)) + } + } + @Test func applyingColumnSlashCommandsCreatesDocmostColumnLayouts() { let expectations = [ ColumnCommandExpectation(command: .columns, layout: "two_equal", columnCount: 2), @@ -120,6 +138,15 @@ struct NativeEditorSlashCommandTests { return viewModel } + + private func slashCommandTitles(for query: String) -> [String] { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString("/\(query)"), alignment: .left) + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: [block]) + viewModel.focus(blockID: block.id) + + return viewModel.filteredSlashCommands.map(\.title) + } } private struct ColumnCommandExpectation { @@ -138,3 +165,8 @@ private struct BaseCommandExpectation { let command: NativeEditorCommand let previewText: String } + +private struct SlashCommandFilterExpectation { + let query: String + let title: String +} diff --git a/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift b/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift index ab8e00a..54a4576 100644 --- a/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift @@ -131,7 +131,8 @@ struct NativeRichEditorMechanicsTests { #expect(viewModel.document.blocks.count == 2) - guard case .table(let table) = viewModel.document.blocks[1].kind else { + let tableBlock = try #require(viewModel.document.blocks.dropFirst().first) + guard case .table(let table) = tableBlock.kind else { Issue.record("Expected pasted Markdown table to become a native table block.") return } @@ -159,6 +160,38 @@ struct NativeRichEditorMechanicsTests { """) } + @Test func pasteMarkdownTablePreservesInlineMarksInCells() throws { + let intro = NativeEditorBlock(kind: .paragraph, text: AttributedString("Intro"), alignment: .left) + let viewModel = configuredViewModel(blocks: [intro]) + viewModel.focus(blockID: intro.id) + + viewModel.pasteMarkdown(""" + | Feature | Source | + | --- | --- | + | **Tables** | [Spec](https://example.com/spec) | + """) + + guard case .table(let table) = viewModel.document.blocks[1].kind else { + Issue.record("Expected pasted Markdown table to become a native table block.") + return + } + + #expect(table.rows[1].cells.map(\.plainText) == ["Tables", "Spec"]) + + let bodyRow = try #require(viewModel.document.proseMirrorDocument.content.last?.content?.dropFirst().first) + let firstCellText = try #require(bodyRow.content?[0].content?.first?.content?.first) + let secondCellText = try #require(bodyRow.content?[1].content?.first?.content?.first) + + #expect(firstCellText.text == "Tables") + #expect(firstCellText.marks?.contains(ProseMirrorMark(type: "bold")) == true) + #expect(secondCellText.text == "Spec") + #expect( + secondCellText.marks?.contains( + ProseMirrorMark(type: "link", attrs: ["href": .string("https://example.com/spec")]) + ) == true + ) + } + @Test func pasteMarkdownRichBlocksCreatesNativeBlocks() throws { let intro = NativeEditorBlock(kind: .paragraph, text: AttributedString("Intro"), alignment: .left) let viewModel = configuredViewModel(blocks: [intro]) diff --git a/docmostlyTests/Editor/NativeRichEditorTableTests.swift b/docmostlyTests/Editor/NativeRichEditorTableTests.swift index 737bbe5..21e49bb 100644 --- a/docmostlyTests/Editor/NativeRichEditorTableTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorTableTests.swift @@ -106,6 +106,136 @@ struct NativeRichEditorTableTests { #expect(firstCell?.content?.first?.content?.first?.text == "Updated") } + @Test func editingTableCellPreservesInlineMarksInOtherCells() throws { + let data = Data(""" + { + "type": "doc", + "content": [ + { + "type": "table", + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableHeader", + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Feature" }] + } + ] + }, + { + "type": "tableHeader", + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Status" }] + } + ] + } + ] + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Tables", + "marks": [{ "type": "bold" }] + } + ] + } + ] + }, + { + "type": "tableCell", + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Draft" }] + } + ] + } + ] + } + ] + } + ] + } + """.utf8) + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = try NativeEditorDocument(proseMirrorJSONData: data) + let blockID = try #require(viewModel.document.blocks.first?.id) + + viewModel.updateTableCell(blockID: blockID, rowIndex: 1, columnIndex: 1, text: "Ready") + + let bodyRow = try #require(viewModel.document.proseMirrorDocument.content.first?.content?.dropFirst().first) + let preservedText = try #require(bodyRow.content?.first?.content?.first?.content?.first) + #expect(preservedText.text == "Tables") + #expect(preservedText.marks?.contains(ProseMirrorMark(type: "bold")) == true) + } + + @Test func editingTableCellPreservesUnsupportedRichContentInOtherCells() throws { + let data = Data(""" + { + "type": "doc", + "content": [ + { + "type": "table", + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "content": [ + { + "type": "image", + "attrs": { + "src": "/files/table-image.png", + "alt": "Architecture" + } + } + ] + }, + { + "type": "tableCell", + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Draft" }] + } + ] + } + ] + } + ] + } + ] + } + """.utf8) + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = try NativeEditorDocument(proseMirrorJSONData: data) + let blockID = try #require(viewModel.document.blocks.first?.id) + + viewModel.updateTableCell(blockID: blockID, rowIndex: 0, columnIndex: 1, text: "Ready") + + let firstRow = try #require(viewModel.document.proseMirrorDocument.content.first?.content?.first) + let firstCell = try #require(firstRow.content?.first) + let preservedImage = try #require(firstCell.content?.first) + #expect(preservedImage.type == "image") + #expect(preservedImage.attrs?["src"] == .string("/files/table-image.png")) + #expect(preservedImage.attrs?["alt"] == .string("Architecture")) + } + private func tableViewModel() -> NativeRichEditorViewModel { let block = NativeEditorBlock(kind: .paragraph, text: AttributedString("/table"), alignment: .left) let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") From a24342937fc017a9678772cc2960ecf9bac72319 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 12:41:37 +0100 Subject: [PATCH 013/201] fix: preserve markdown attachment ids --- ...ativeEditorMarkdownParser+RichBlocks.swift | 59 ++++++++++++++----- .../NativeEditorMarkdownImportTests.swift | 39 ++++++++++++ 2 files changed, 82 insertions(+), 16 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift index 6b79166..9591302 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift @@ -221,13 +221,14 @@ extension NativeEditorMarkdownParser { let destinationStartIndex = line.index(after: openDestinationIndex) let destination = String(line[destinationStartIndex.. NativeEditorBlockKind? { guard let fileExtension = markdownLinkFileExtension(from: source) else { return nil } let title = title.isEmpty ? nil : title + let attachmentID = docmostAttachmentID(from: source) if videoFileExtensions.contains(fileExtension) { return .video(NativeEditorMediaBlock( source: source, alternativeText: nil, title: title, - attachmentID: nil, + attachmentID: attachmentID, sizeInBytes: nil, width: nil, height: nil, @@ -293,7 +295,7 @@ extension NativeEditorMarkdownParser { source: source, alternativeText: nil, title: title, - attachmentID: nil, + attachmentID: attachmentID, sizeInBytes: nil, width: nil, height: nil, @@ -306,7 +308,7 @@ extension NativeEditorMarkdownParser { return .pdf(NativeEditorPDFBlock( source: source, name: title, - attachmentID: nil, + attachmentID: attachmentID, sizeInBytes: nil, width: nil, height: nil @@ -318,7 +320,7 @@ extension NativeEditorMarkdownParser { name: title, mimeType: nil, sizeInBytes: nil, - attachmentID: nil + attachmentID: attachmentID )) } @@ -473,27 +475,52 @@ extension NativeEditorMarkdownParser { } private static func markdownLinkFileExtension(from source: String) -> String? { + let path = markdownLinkPath(from: source) + guard + let fileExtension = path.split(separator: ".").last?.lowercased(), + fileExtension != path.lowercased() + else { + return nil + } + + return fileExtension + } + + private static func docmostAttachmentID(from source: String) -> String? { + let pathComponents = markdownLinkPath(from: source) + .split(separator: "/", omittingEmptySubsequences: true) + .map(String.init) + guard + let apiIndex = pathComponents.firstIndex(of: "api"), + pathComponents.indices.contains(pathComponents.index(after: apiIndex)) + else { + return nil + } + + let filesIndex = pathComponents.index(after: apiIndex) + guard pathComponents[filesIndex] == "files" else { return nil } + + let attachmentIndex = pathComponents.index(after: filesIndex) + guard pathComponents.indices.contains(attachmentIndex) else { return nil } + + let attachmentID = pathComponents[attachmentIndex] + guard attachmentID.isEmpty == false else { return nil } + return attachmentID.removingPercentEncoding ?? attachmentID + } + + private static func markdownLinkPath(from source: String) -> String { let pathSource: String if let components = URLComponents(string: source), components.scheme != nil { - guard let componentPath = components.path.nonEmpty else { return nil } - pathSource = componentPath + pathSource = components.path.nonEmpty ?? "" } else { pathSource = source } - let path = pathSource.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false) + return pathSource.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false) .first? .split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false) .first .map(String.init) ?? pathSource - guard - let fileExtension = path.split(separator: ".").last?.lowercased(), - fileExtension != path.lowercased() - else { - return nil - } - - return fileExtension } private static func escapedMarkdownLinkText(_ text: String) -> String { diff --git a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift index 6f68854..72afd79 100644 --- a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift +++ b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift @@ -47,6 +47,45 @@ struct NativeEditorMarkdownImportTests { #expect(blocks[3].rawNode?.type == "attachment") } + @Test func docmostAttachmentLinksImportWithAttachmentIDs() throws { + let blocks = NativeEditorMarkdownParser.blocks(from: """ + ![Hero](/api/files/image-1/Hero.png) + [Launch demo.mp4](/api/files/video-1/Launch%20demo.mp4) + [Spec.pdf](/api/files/pdf-1/Spec.pdf) + [Archive.zip](/api/files/file-1/Archive.zip) + """) + + #expect(blocks.count == 4) + + guard case .image(let image) = blocks[0].kind else { + Issue.record("Expected Docmost image link to import as an image block.") + return + } + #expect(image.attachmentID == "image-1") + #expect(blocks[0].rawNode?.attrs?["attachmentId"] == .string("image-1")) + + guard case .video(let video) = blocks[1].kind else { + Issue.record("Expected Docmost video link to import as a video block.") + return + } + #expect(video.attachmentID == "video-1") + #expect(blocks[1].rawNode?.attrs?["attachmentId"] == .string("video-1")) + + guard case .pdf(let pdf) = blocks[2].kind else { + Issue.record("Expected Docmost PDF link to import as a PDF block.") + return + } + #expect(pdf.attachmentID == "pdf-1") + #expect(blocks[2].rawNode?.attrs?["attachmentId"] == .string("pdf-1")) + + guard case .attachment(let attachment) = blocks[3].kind else { + Issue.record("Expected Docmost file link to import as an attachment block.") + return + } + #expect(attachment.attachmentID == "file-1") + #expect(blocks[3].rawNode?.attrs?["attachmentId"] == .string("file-1")) + } + @Test func genericMarkdownLinksRemainEditableParagraphText() throws { let block = try #require(NativeEditorMarkdownParser.blocks(from: "[Example](https://example.com)").first) From 0def049a76f69cebbdfd63d4a3ee4a085af55b8f Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 12:47:30 +0100 Subject: [PATCH 014/201] fix: support details markdown shortcut --- .../NativeEditorDocument+Encoding.swift | 4 +-- .../Editor/NativeEditorMarkdownParser.swift | 11 +++++++ .../NativeRichEditorMechanicsTests.swift | 30 +++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift b/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift index 73677dd..fb4ce98 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift @@ -219,8 +219,8 @@ nonisolated extension NativeEditorDocument { switch block.kind { case .callout: ProseMirrorNode(type: "callout", content: [textContainerNode(type: "paragraph", block: block)]) - case .details: - ProseMirrorNode(type: "details") + case .details(let details): + NativeEditorRichBlockNodeFactory.detailsNode(from: details) case .pageBreak: ProseMirrorNode(type: "pageBreak") case .divider: diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift index e25b877..e50712f 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift @@ -48,6 +48,10 @@ enum NativeEditorMarkdownParser { return codeRule } + if let detailsRule = detailsInputRule(from: text) { + return detailsRule + } + return lineInputRule(from: text) } @@ -122,6 +126,13 @@ enum NativeEditorMarkdownParser { return NativeEditorMarkdownInputRule(kind: .codeBlock(language: language.isEmpty ? nil : language), text: "") } + private static func detailsInputRule(from text: String) -> NativeEditorMarkdownInputRule? { + guard text == ":::details " else { return nil } + + let details = NativeEditorDetailsBlock(summary: "Details", previewText: "Details", isOpen: true) + return NativeEditorMarkdownInputRule(kind: .details(details), text: details.summary) + } + private static func lineInputRule(from text: String) -> NativeEditorMarkdownInputRule? { if let taskRule = taskInputRule(from: text) { return taskRule diff --git a/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift b/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift index 54a4576..acf9813 100644 --- a/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift @@ -68,6 +68,36 @@ struct NativeRichEditorMechanicsTests { #expect(viewModel.markdownForDocument() == "---") } + @Test func markdownInputRuleSupportsDocmostDetailsShortcut() throws { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString(""), alignment: .left) + let viewModel = configuredViewModel(blocks: [block]) + viewModel.focus(blockID: block.id) + + viewModel.document.blocks[0].text = AttributedString(":::details ") + viewModel.handleDocumentChanged() + + guard case .details(let details) = viewModel.document.blocks[0].kind else { + Issue.record("Expected Docmost details shortcut to create a native details block.") + return + } + #expect(details.summary == "Details") + #expect(details.previewText == "Details") + #expect(String(viewModel.document.blocks[0].text.characters) == "Details") + + let node = viewModel.document.proseMirrorDocument.content[0] + #expect(node.type == "details") + #expect(node.attrs?["open"] == .bool(true)) + #expect(node.content?.first?.type == "detailsSummary") + #expect(node.content?.first?.content?.first?.text == "Details") + #expect(node.content?[1].type == "detailsContent") + #expect(node.content?[1].content?.first?.content?.first?.text == "Details") + + viewModel.undo() + + #expect(viewModel.document.blocks[0].kind == .paragraph) + #expect(String(viewModel.document.blocks[0].text.characters).isEmpty) + } + @Test func pasteMarkdownInsertsNativeBlocksAfterActiveBlock() { let intro = NativeEditorBlock(kind: .paragraph, text: AttributedString("Intro"), alignment: .left) let viewModel = configuredViewModel(blocks: [intro]) From 231b5f918948f0aa19b00494a7755c464b0a59ab Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 12:54:39 +0100 Subject: [PATCH 015/201] fix: preserve user mention markdown --- ...iveEditorMarkdownParser+DocmostLinks.swift | 229 +++++++++++++++++- .../NativeEditorMentionMarkdownTests.swift | 47 ++++ 2 files changed, 274 insertions(+), 2 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift index 9af2b0e..64ac9c5 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift @@ -13,6 +13,11 @@ extension NativeEditorMarkdownParser { var pageLink: DocmostPageLink } + private struct DocmostMentionHTML { + var range: Range + var mention: NativeEditorMention + } + static func appendMarkdownText( _ markdown: String, to result: inout AttributedString, @@ -22,6 +27,17 @@ extension NativeEditorMarkdownParser { var remaining = markdown[...] var didAppendAtom = false + while let htmlMention = nextDocmostMentionHTML(in: remaining) { + appendMarkdownText( + String(remaining[.. String { - guard mention.entityType == "page", let slugID = mention.slugID, slugID.isEmpty == false else { - return fallbackText + guard mention.entityType == "page" else { + if mention.entityType == nil { + return fallbackText + } + return mentionHTMLMarkdown(from: mention, fallbackText: fallbackText) + } + + guard let slugID = mention.slugID, slugID.isEmpty == false else { + return mentionHTMLMarkdown(from: mention, fallbackText: fallbackText) } let label = escapedMarkdownLinkText(mention.label ?? fallbackText) @@ -50,6 +73,35 @@ extension NativeEditorMarkdownParser { return "[\(label)](/p/\(slugID)\(anchor))" } + private static func mentionHTMLMarkdown(from mention: NativeEditorMention, fallbackText: String) -> String { + let attrs: [(String, String?)] = [ + ("data-type", "mention"), + ("data-id", mention.identifier), + ("data-label", mention.label), + ("data-entity-type", mention.entityType), + ("data-entity-id", mention.entityID), + ("data-slug-id", mention.slugID), + ("data-creator-id", mention.creatorID), + ("data-anchor-id", mention.anchorID) + ] + let attrText = attrs.compactMap { name, value in + value.nonEmpty.map { "\(name)=\"\(escapedMentionHTMLAttribute($0))\"" } + }.joined(separator: " ") + + let displayText = mentionHTMLDisplayText(from: mention, fallbackText: fallbackText) + return "\(escapedMentionHTMLText(displayText))" + } + + private static func mentionHTMLDisplayText(from mention: NativeEditorMention, fallbackText: String) -> String { + if mention.entityType == "user" { + let label = mention.label ?? fallbackText.removingMentionTrigger.nonEmpty ?? mention.entityID + ?? mention.identifier ?? "Mention" + return "@\(label)" + } + + return mention.label ?? fallbackText.nonEmpty ?? mention.entityID ?? mention.identifier ?? "Mention" + } + private static func appendMarkdownTextWithBareDocmostPageLinks( _ markdown: String, to result: inout AttributedString, @@ -109,6 +161,12 @@ extension NativeEditorMarkdownParser { result += segment } + private static func appendMention(_ mention: NativeEditorMention, to result: inout AttributedString) { + var segment = AttributedString(mention.displayText) + segment[NativeEditorMentionAttribute.self] = mention + result += segment + } + private static func plainMarkdownText(from markdown: String) -> String { let attributedText = (try? AttributedString(markdown: markdown)) ?? AttributedString(markdown) return String(attributedText.characters) @@ -152,6 +210,129 @@ extension NativeEditorMarkdownParser { return nil } + private static func nextDocmostMentionHTML(in markdown: Substring) -> DocmostMentionHTML? { + var searchStart = markdown.startIndex + + while searchStart < markdown.endIndex, + let openRange = markdown[searchStart...].range(of: "") else { + return nil + } + + let openingTag = String(markdown[openRange.lowerBound...openTagEnd]) + let attrs = mentionHTMLAttributes(from: openingTag) + guard attrs["data-type"] == "mention" else { + searchStart = markdown.index(after: openRange.lowerBound) + continue + } + + let contentStart = markdown.index(after: openTagEnd) + guard let closeRange = markdown[contentStart...].range(of: "", options: .caseInsensitive) else { + return nil + } + + let body = String(markdown[contentStart.. NativeEditorMention { + let entityType = attrs["data-entity-type"]?.nonEmpty + let fallbackLabel = unescapedMentionHTMLText(fallbackHTMLText) + .trimmingCharacters(in: .whitespacesAndNewlines) + .removingMentionTrigger + + return NativeEditorMention( + identifier: attrs["data-id"]?.nonEmpty, + label: attrs["data-label"]?.nonEmpty ?? fallbackLabel.nonEmpty, + entityType: entityType, + entityID: attrs["data-entity-id"]?.nonEmpty, + slugID: attrs["data-slug-id"]?.nonEmpty, + creatorID: attrs["data-creator-id"]?.nonEmpty, + anchorID: attrs["data-anchor-id"]?.nonEmpty + ) + } + + private static func mentionHTMLAttributes(from openingTag: String) -> [String: String] { + var attrs = [String: String]() + var index = openingTag.startIndex + + while index < openingTag.endIndex { + guard let nameRange = nextMentionHTMLAttributeNameRange(in: openingTag, startingAt: index) else { + break + } + + index = nameRange.upperBound + skipMentionHTMLWhitespace(in: openingTag, index: &index) + guard index < openingTag.endIndex, openingTag[index] == "=" else { + continue + } + + index = openingTag.index(after: index) + skipMentionHTMLWhitespace(in: openingTag, index: &index) + let value = mentionHTMLAttributeValue(in: openingTag, startingAt: &index) + attrs[String(openingTag[nameRange]).lowercased()] = unescapedMentionHTMLText(value) + } + + return attrs + } + + private static func nextMentionHTMLAttributeNameRange( + in text: String, + startingAt index: String.Index + ) -> Range? { + var nameStart = index + while nameStart < text.endIndex, text[nameStart].isMentionHTMLAttributeNameCharacter == false { + nameStart = text.index(after: nameStart) + } + + guard nameStart < text.endIndex else { return nil } + + var nameEnd = nameStart + while nameEnd < text.endIndex, text[nameEnd].isMentionHTMLAttributeNameCharacter { + nameEnd = text.index(after: nameEnd) + } + + return nameStart.. String { + guard index < text.endIndex else { return "" } + + if text[index] == "\"" || text[index] == "'" { + let quote = text[index] + let valueStart = text.index(after: index) + guard let valueEnd = text[valueStart...].firstIndex(of: quote) else { + index = text.endIndex + return String(text[valueStart...]) + } + + index = text.index(after: valueEnd) + return String(text[valueStart.. DocmostMarkdownLink? { var searchStart = markdown.startIndex @@ -283,6 +464,25 @@ extension NativeEditorMarkdownParser { .replacing("[", with: "\\[") .replacing("]", with: "\\]") } + + private static func escapedMentionHTMLAttribute(_ text: String) -> String { + escapedMentionHTMLText(text).replacing("\"", with: """) + } + + private static func escapedMentionHTMLText(_ text: String) -> String { + text + .replacing("&", with: "&") + .replacing("<", with: "<") + .replacing(">", with: ">") + } + + private static func unescapedMentionHTMLText(_ text: String) -> String { + text + .replacing(""", with: "\"") + .replacing("<", with: "<") + .replacing(">", with: ">") + .replacing("&", with: "&") + } } private extension Character { @@ -294,4 +494,29 @@ private extension Character { false } } + + var isMentionHTMLAttributeNameCharacter: Bool { + isLetter || isNumber || self == "-" || self == "_" || self == ":" + } +} + +private extension Optional where Wrapped == String { + var nonEmpty: String? { + switch self { + case .some(let value): + value.nonEmpty + case .none: + nil + } + } +} + +private extension String { + var nonEmpty: String? { + isEmpty ? nil : self + } + + var removingMentionTrigger: String { + hasPrefix("@") ? String(dropFirst()) : self + } } diff --git a/docmostlyTests/Editor/NativeEditorMentionMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorMentionMarkdownTests.swift index 8b54ab5..44fc516 100644 --- a/docmostlyTests/Editor/NativeEditorMentionMarkdownTests.swift +++ b/docmostlyTests/Editor/NativeEditorMentionMarkdownTests.swift @@ -90,6 +90,53 @@ struct NativeEditorMentionMarkdownTests { #expect(viewModel.markdownForDocument() == "Discuss [Roadmap](/p/abc123#shipping) today") } + @Test func documentMarkdownConversionPreservesUserMentionAtomsAsDocmostHTML() { + var text = AttributedString("Discuss ") + var mentionText = AttributedString("@Taylor") + mentionText[NativeEditorMentionAttribute.self] = NativeEditorMention( + identifier: "mention-1", + label: "Taylor", + entityType: "user", + entityID: "user-1", + creatorID: "creator-1" + ) + text += mentionText + text += AttributedString(" today") + + let block = NativeEditorBlock(kind: .paragraph, text: text, alignment: .left) + let viewModel = configuredViewModel(blocks: [block]) + let mentionHTML = userMentionHTML() + + #expect(viewModel.markdownForDocument() == "Discuss \(mentionHTML) today") + } + + @Test func pasteMarkdownDocmostUserMentionHTMLCreatesMentionAtom() throws { + let intro = NativeEditorBlock(kind: .paragraph, text: AttributedString("Intro"), alignment: .left) + let viewModel = configuredViewModel(blocks: [intro]) + viewModel.focus(blockID: intro.id) + let mentionHTML = userMentionHTML() + + viewModel.pasteMarkdown("Discuss \(mentionHTML) today") + + let inlineNodes = viewModel.document.proseMirrorDocument.content.last?.content ?? [] + #expect(inlineNodes.map(\.type) == ["text", "mention", "text"]) + #expect(inlineNodes[0].text == "Discuss ") + #expect(inlineNodes[2].text == " today") + + let attrs = try #require(inlineNodes[1].attrs) + #expect(attrs["id"] == .string("mention-1")) + #expect(attrs["label"] == .string("Taylor")) + #expect(attrs["entityType"] == .string("user")) + #expect(attrs["entityId"] == .string("user-1")) + #expect(attrs["creatorId"] == .string("creator-1")) + } + + private func userMentionHTML() -> String { + #""# + + "@Taylor" + } + private func configuredViewModel(blocks: [NativeEditorBlock]) -> NativeRichEditorViewModel { let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") viewModel.document = NativeEditorDocument(blocks: blocks) From 97fe3aa7760ed3bbb361203e911d066e66c3db24 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 13:10:09 +0100 Subject: [PATCH 016/201] fix: preserve inline comment markdown --- ...itorMarkdownParser+DocmostInlineHTML.swift | 133 ++++++++++++ ...iveEditorMarkdownParser+DocmostLinks.swift | 205 +++++++++--------- .../Editor/NativeEditorMarkdownParser.swift | 9 +- .../NativeEditorInlineCommentMarkTests.swift | 37 ++++ 4 files changed, 280 insertions(+), 104 deletions(-) create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift new file mode 100644 index 0000000..8d113b5 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift @@ -0,0 +1,133 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func docmostInlineHTMLAttributes(from openingTag: String) -> [String: String] { + var attrs = [String: String]() + var index = openingTag.startIndex + + while index < openingTag.endIndex { + guard let nameRange = nextDocmostInlineHTMLAttributeNameRange(in: openingTag, startingAt: index) else { + break + } + + let name = String(openingTag[nameRange]).lowercased() + index = nameRange.upperBound + skipDocmostInlineHTMLWhitespace(in: openingTag, index: &index) + guard index < openingTag.endIndex, openingTag[index] == "=" else { + if name != "span" { + attrs[name] = "" + } + continue + } + + index = openingTag.index(after: index) + skipDocmostInlineHTMLWhitespace(in: openingTag, index: &index) + let value = docmostInlineHTMLAttributeValue(in: openingTag, startingAt: &index) + attrs[name] = unescapedInlineHTMLText(value) + } + + return attrs + } + + static func matchingCloseSpanRange( + in markdown: Substring, + bodyStart: String.Index + ) -> Range? { + var depth = 1 + var searchStart = bodyStart + + while searchStart < markdown.endIndex, + let closeRange = markdown[searchStart...].range(of: "", options: .caseInsensitive) { + if let nestedOpenRange = markdown[searchStart...].range(of: " String { + escapedInlineHTMLText(text).replacing("\"", with: """) + } + + static func escapedInlineHTMLText(_ text: String) -> String { + text + .replacing("&", with: "&") + .replacing("<", with: "<") + .replacing(">", with: ">") + } + + static func unescapedInlineHTMLText(_ text: String) -> String { + text + .replacing(""", with: "\"") + .replacing("<", with: "<") + .replacing(">", with: ">") + .replacing("&", with: "&") + } + + private static func nextDocmostInlineHTMLAttributeNameRange( + in text: String, + startingAt index: String.Index + ) -> Range? { + var nameStart = index + while nameStart < text.endIndex, text[nameStart].isDocmostHTMLAttrNameChar == false { + nameStart = text.index(after: nameStart) + } + + guard nameStart < text.endIndex else { return nil } + + var nameEnd = nameStart + while nameEnd < text.endIndex, text[nameEnd].isDocmostHTMLAttrNameChar { + nameEnd = text.index(after: nameEnd) + } + + return nameStart.. String { + guard index < text.endIndex else { return "" } + + if text[index] == "\"" || text[index] == "'" { + let quote = text[index] + let valueStart = text.index(after: index) + guard let valueEnd = text[valueStart...].firstIndex(of: quote) else { + index = text.endIndex + return String(text[valueStart...]) + } + + index = text.index(after: valueEnd) + return String(text[valueStart.. + var comment: NativeEditorInlineCommentMark + var bodyMarkdown: String + } + static func appendMarkdownText( _ markdown: String, to result: inout AttributedString, @@ -27,6 +34,17 @@ extension NativeEditorMarkdownParser { var remaining = markdown[...] var didAppendAtom = false + while let htmlComment = nextDocmostCommentHTML(in: remaining) { + appendMarkdownText( + String(remaining[.. String { + comments.normalizedNativeEditorInlineComments.reversed().reduce(body) { markdown, comment in + commentHTMLMarkdown(from: comment, body: markdown) + } + } + + private static func commentHTMLMarkdown(from comment: NativeEditorInlineCommentMark, body: String) -> String { + let className = comment.isResolved ? "comment-mark resolved" : "comment-mark" + let commentID = escapedInlineHTMLAttribute(comment.commentID) + let resolvedAttribute = comment.isResolved ? #" data-resolved="true""# : "" + return #""# + + body + + "" + } + private static func mentionHTMLMarkdown(from mention: NativeEditorMention, fallbackText: String) -> String { let attrs: [(String, String?)] = [ ("data-type", "mention"), @@ -85,11 +118,11 @@ extension NativeEditorMarkdownParser { ("data-anchor-id", mention.anchorID) ] let attrText = attrs.compactMap { name, value in - value.nonEmpty.map { "\(name)=\"\(escapedMentionHTMLAttribute($0))\"" } + value.nonEmpty.map { "\(name)=\"\(escapedInlineHTMLAttribute($0))\"" } }.joined(separator: " ") let displayText = mentionHTMLDisplayText(from: mention, fallbackText: fallbackText) - return "\(escapedMentionHTMLText(displayText))" + return "\(escapedInlineHTMLText(displayText))" } private static func mentionHTMLDisplayText(from mention: NativeEditorMention, fallbackText: String) -> String { @@ -167,6 +200,35 @@ extension NativeEditorMarkdownParser { result += segment } + private static func appendComment(_ htmlComment: DocmostCommentHTML, to result: inout AttributedString) { + var commentBody = AttributedString("") + appendMarkdownText( + htmlComment.bodyMarkdown, + to: &commentBody, + usesFoundationMarkdownParser: false + ) + applyComment(htmlComment.comment, to: &commentBody) + result += commentBody + } + + private static func applyComment(_ comment: NativeEditorInlineCommentMark, to text: inout AttributedString) { + let ranges = text.runs.map(\.range) + for range in ranges { + let comments = text[range].nativeEditorInlineComments.updatingNativeEditorInlineComment(comment) + text[range][NativeEditorCommentMarksAttribute.self] = comments.isEmpty ? nil : comments + text[range][NativeEditorCommentIDAttribute.self] = comments.first?.commentID + text[range][NativeEditorCommentResolvedAttribute.self] = comments.first?.isResolved + text[range].backgroundColor = commentBackgroundColor(for: comments) + } + } + + private static func commentBackgroundColor(for comments: [NativeEditorInlineCommentMark]) -> Color? { + guard comments.isEmpty == false else { return nil } + return comments.contains { $0.isResolved == false } + ? .yellow.opacity(0.28) + : .gray.opacity(0.16) + } + private static func plainMarkdownText(from markdown: String) -> String { let attributedText = (try? AttributedString(markdown: markdown)) ?? AttributedString(markdown) return String(attributedText.characters) @@ -220,14 +282,14 @@ extension NativeEditorMarkdownParser { } let openingTag = String(markdown[openRange.lowerBound...openTagEnd]) - let attrs = mentionHTMLAttributes(from: openingTag) + let attrs = docmostInlineHTMLAttributes(from: openingTag) guard attrs["data-type"] == "mention" else { searchStart = markdown.index(after: openRange.lowerBound) continue } let contentStart = markdown.index(after: openTagEnd) - guard let closeRange = markdown[contentStart...].range(of: "", options: .caseInsensitive) else { + guard let closeRange = matchingCloseSpanRange(in: markdown, bodyStart: contentStart) else { return nil } @@ -241,9 +303,46 @@ extension NativeEditorMarkdownParser { return nil } + private static func nextDocmostCommentHTML(in markdown: Substring) -> DocmostCommentHTML? { + var searchStart = markdown.startIndex + + while searchStart < markdown.endIndex, + let openRange = markdown[searchStart...].range(of: "") else { + return nil + } + + let openingTag = String(markdown[openRange.lowerBound...openTagEnd]) + let attrs = docmostInlineHTMLAttributes(from: openingTag) + guard let commentID = attrs["data-comment-id"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nonEmpty + else { + searchStart = markdown.index(after: openRange.lowerBound) + continue + } + + let contentStart = markdown.index(after: openTagEnd) + guard let closeRange = matchingCloseSpanRange(in: markdown, bodyStart: contentStart) else { + return nil + } + + return DocmostCommentHTML( + range: openRange.lowerBound.. NativeEditorMention { let entityType = attrs["data-entity-type"]?.nonEmpty - let fallbackLabel = unescapedMentionHTMLText(fallbackHTMLText) + let fallbackLabel = unescapedInlineHTMLText(fallbackHTMLText) .trimmingCharacters(in: .whitespacesAndNewlines) .removingMentionTrigger @@ -258,81 +357,6 @@ extension NativeEditorMarkdownParser { ) } - private static func mentionHTMLAttributes(from openingTag: String) -> [String: String] { - var attrs = [String: String]() - var index = openingTag.startIndex - - while index < openingTag.endIndex { - guard let nameRange = nextMentionHTMLAttributeNameRange(in: openingTag, startingAt: index) else { - break - } - - index = nameRange.upperBound - skipMentionHTMLWhitespace(in: openingTag, index: &index) - guard index < openingTag.endIndex, openingTag[index] == "=" else { - continue - } - - index = openingTag.index(after: index) - skipMentionHTMLWhitespace(in: openingTag, index: &index) - let value = mentionHTMLAttributeValue(in: openingTag, startingAt: &index) - attrs[String(openingTag[nameRange]).lowercased()] = unescapedMentionHTMLText(value) - } - - return attrs - } - - private static func nextMentionHTMLAttributeNameRange( - in text: String, - startingAt index: String.Index - ) -> Range? { - var nameStart = index - while nameStart < text.endIndex, text[nameStart].isMentionHTMLAttributeNameCharacter == false { - nameStart = text.index(after: nameStart) - } - - guard nameStart < text.endIndex else { return nil } - - var nameEnd = nameStart - while nameEnd < text.endIndex, text[nameEnd].isMentionHTMLAttributeNameCharacter { - nameEnd = text.index(after: nameEnd) - } - - return nameStart.. String { - guard index < text.endIndex else { return "" } - - if text[index] == "\"" || text[index] == "'" { - let quote = text[index] - let valueStart = text.index(after: index) - guard let valueEnd = text[valueStart...].firstIndex(of: quote) else { - index = text.endIndex - return String(text[valueStart...]) - } - - index = text.index(after: valueEnd) - return String(text[valueStart.. DocmostMarkdownLink? { var searchStart = markdown.startIndex @@ -465,24 +489,6 @@ extension NativeEditorMarkdownParser { .replacing("]", with: "\\]") } - private static func escapedMentionHTMLAttribute(_ text: String) -> String { - escapedMentionHTMLText(text).replacing("\"", with: """) - } - - private static func escapedMentionHTMLText(_ text: String) -> String { - text - .replacing("&", with: "&") - .replacing("<", with: "<") - .replacing(">", with: ">") - } - - private static func unescapedMentionHTMLText(_ text: String) -> String { - text - .replacing(""", with: "\"") - .replacing("<", with: "<") - .replacing(">", with: ">") - .replacing("&", with: "&") - } } private extension Character { @@ -495,9 +501,6 @@ private extension Character { } } - var isMentionHTMLAttributeNameCharacter: Bool { - isLetter || isNumber || self == "-" || self == "_" || self == ":" - } } private extension Optional where Wrapped == String { diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift index e50712f..b3fc797 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift @@ -321,13 +321,16 @@ enum NativeEditorMarkdownParser { for run in text.runs { let runText = String(text[run.range].characters) + let runMarkdown: String if let math = run[NativeEditorMathInlineAttribute.self] { - output += "$\(math.text.replacing("$", with: "\\$"))$" + runMarkdown = "$\(math.text.replacing("$", with: "\\$"))$" } else if let mention = run[NativeEditorMentionAttribute.self] { - output += mentionMarkdown(from: mention, fallbackText: runText) + runMarkdown = mentionMarkdown(from: mention, fallbackText: runText) } else { - output += inlineRunMarkdown(from: run, text: runText) + runMarkdown = inlineRunMarkdown(from: run, text: runText) } + + output += commentMarkdown(from: run.nativeEditorInlineComments, body: runMarkdown) } return output diff --git a/docmostlyTests/Editor/NativeEditorInlineCommentMarkTests.swift b/docmostlyTests/Editor/NativeEditorInlineCommentMarkTests.swift index aa1b339..6dc8d7d 100644 --- a/docmostlyTests/Editor/NativeEditorInlineCommentMarkTests.swift +++ b/docmostlyTests/Editor/NativeEditorInlineCommentMarkTests.swift @@ -3,6 +3,43 @@ import Testing @testable import docmostly struct NativeEditorInlineCommentMarkTests { + @Test func markdownExportPreservesDocmostInlineCommentSpan() { + var text = AttributedString("Needs review") + text.setNativeEditorInlineComments([ + NativeEditorInlineCommentMark(commentID: "comment-1", isResolved: false) + ]) + + let block = NativeEditorBlock(kind: .paragraph, text: text, alignment: .left) + + #expect( + NativeEditorMarkdownParser.markdown(from: [block]) == + #"Needs review"# + ) + } + + @Test func markdownImportPreservesDocmostInlineCommentSpan() throws { + let markdown = #"Review this **copy** today"# + let block = try #require( + NativeEditorMarkdownParser.blocks(from: markdown).first + ) + + let comment = NativeEditorInlineCommentMark(commentID: "comment-1", isResolved: true) + let markedRuns = block.text.runs.filter { $0.nativeEditorInlineComments == [comment] } + + #expect(markedRuns.count == 2) + let boldRun = try #require(markedRuns.last) + #expect(String(block.text[boldRun.range].characters) == "copy") + #expect(boldRun.inlinePresentationIntent?.contains(.stronglyEmphasized) == true) + + let inlineNodes = try #require(NativeEditorDocument(blocks: [block]).proseMirrorDocument.content.first?.content) + let commentMark = ProseMirrorMark( + type: "comment", + attrs: ["commentId": .string("comment-1"), "resolved": .bool(true)] + ) + #expect(inlineNodes.contains { $0.marks?.contains(commentMark) == true }) + } + @Test func preservesOverlappingInlineCommentMarks() throws { let data = Data(""" { From b244c72a6e699c96f0941045df90adc4c6a43430 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 13:13:22 +0100 Subject: [PATCH 017/201] fix: match slash commands fuzzily --- .../Editor/NativeEditorCommand+Behavior.swift | 21 ++++++++++++++++++- .../NativeEditorSlashCommandTests.swift | 13 ++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift b/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift index c5b2555..1034345 100644 --- a/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift +++ b/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift @@ -25,7 +25,9 @@ extension NativeEditorCommand { func matchPriority(query: String) -> Int? { guard query.isEmpty == false else { return 0 } - if title.localizedStandardContains(query) || rawValue.localizedStandardContains(query) { + if title.localizedStandardContains(query) + || rawValue.localizedStandardContains(query) + || title.fuzzyMatchesSlashCommandQuery(query) { return 0 } @@ -139,3 +141,20 @@ extension NativeEditorCommand { } } } + +private extension String { + func fuzzyMatchesSlashCommandQuery(_ query: String) -> Bool { + let normalizedQuery = query.lowercased() + guard normalizedQuery.isEmpty == false else { return true } + + var queryIndex = normalizedQuery.startIndex + for character in lowercased() where character == normalizedQuery[queryIndex] { + queryIndex = normalizedQuery.index(after: queryIndex) + if queryIndex == normalizedQuery.endIndex { + return true + } + } + + return false + } +} diff --git a/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift b/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift index b240df3..0379618 100644 --- a/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift +++ b/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift @@ -44,6 +44,19 @@ struct NativeEditorSlashCommandTests { } } + @Test func slashCommandFilteringUsesDocmostFuzzyTitleMatching() { + let expectations = [ + SlashCommandFilterExpectation(query: "tdl", title: "To-do List"), + SlashCommandFilterExpectation(query: "nb", title: "Numbered List"), + SlashCommandFilterExpectation(query: "pgb", title: "Page Break") + ] + + for expectation in expectations { + let titles = slashCommandTitles(for: expectation.query) + #expect(titles.contains(expectation.title)) + } + } + @Test func applyingColumnSlashCommandsCreatesDocmostColumnLayouts() { let expectations = [ ColumnCommandExpectation(command: .columns, layout: "two_equal", columnCount: 2), From 197817dc2ebd3c700f47658cd4ee38b6e344bb1b Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 13:18:56 +0100 Subject: [PATCH 018/201] fix: align table slash command defaults --- .../NativeEditorCommand+RichBlocks.swift | 33 +++++++++---------- .../NativeEditorInlineCommentMarkTests.swift | 1 + .../Editor/NativeRichEditorTableTests.swift | 32 +++++++++++++++--- .../NativeRichEditorViewModelTests.swift | 4 +-- 4 files changed, 46 insertions(+), 24 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorCommand+RichBlocks.swift b/docmostly/Features/Editor/NativeEditorCommand+RichBlocks.swift index f3ece32..b1ec4fa 100644 --- a/docmostly/Features/Editor/NativeEditorCommand+RichBlocks.swift +++ b/docmostly/Features/Editor/NativeEditorCommand+RichBlocks.swift @@ -108,18 +108,20 @@ extension NativeEditorCommand { } var defaultTableRows: [NativeEditorTableRow] { - [ - NativeEditorTableRow(cells: [ - NativeEditorTableCell(plainText: "Column 1", isHeader: true, backgroundColorName: nil), - NativeEditorTableCell(plainText: "Column 2", isHeader: true, backgroundColorName: nil) - ]), - NativeEditorTableRow(cells: [ - NativeEditorTableCell(plainText: "", isHeader: false, backgroundColorName: nil), - NativeEditorTableCell(plainText: "", isHeader: false, backgroundColorName: nil) - ]) + let columnCount = 3 + return [ + tableRow(columnCount: columnCount, isHeader: true), + tableRow(columnCount: columnCount, isHeader: false), + tableRow(columnCount: columnCount, isHeader: false) ] } + private func tableRow(columnCount: Int, isHeader: Bool) -> NativeEditorTableRow { + NativeEditorTableRow(cells: (0.. ProseMirrorNode { + private func tableRowNode(from row: NativeEditorTableRow) -> ProseMirrorNode { ProseMirrorNode( type: "tableRow", - content: texts.map { text in + content: row.cells.map { cell in ProseMirrorNode( - type: cellType, - content: [paragraphNode(text)] + type: cell.isHeader ? "tableHeader" : "tableCell", + content: [paragraphNode(cell.plainText)] ) } ) diff --git a/docmostlyTests/Editor/NativeEditorInlineCommentMarkTests.swift b/docmostlyTests/Editor/NativeEditorInlineCommentMarkTests.swift index 6dc8d7d..0ab875f 100644 --- a/docmostlyTests/Editor/NativeEditorInlineCommentMarkTests.swift +++ b/docmostlyTests/Editor/NativeEditorInlineCommentMarkTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import docmostly +@MainActor struct NativeEditorInlineCommentMarkTests { @Test func markdownExportPreservesDocmostInlineCommentSpan() { var text = AttributedString("Needs review") diff --git a/docmostlyTests/Editor/NativeRichEditorTableTests.swift b/docmostlyTests/Editor/NativeRichEditorTableTests.swift index 21e49bb..a664c92 100644 --- a/docmostlyTests/Editor/NativeRichEditorTableTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorTableTests.swift @@ -4,6 +4,28 @@ import Testing @MainActor struct NativeRichEditorTableTests { + @Test func tableSlashCommandCreatesDocmostDefaultTableShape() throws { + let viewModel = tableViewModel() + + guard case .table(let table) = viewModel.document.blocks[0].kind else { + Issue.record("Expected table block") + return + } + + #expect(table.rows.count == 3) + #expect(table.columnCount == 3) + #expect(table.rows.first?.cells.allSatisfy(\.isHeader) == true) + #expect(table.rows.dropFirst().flatMap(\.cells).allSatisfy { $0.isHeader == false }) + #expect(table.rows.flatMap(\.cells).allSatisfy { $0.plainText.isEmpty }) + + let tableNode = try #require(viewModel.document.proseMirrorDocument.content.first) + #expect(tableNode.content?.count == 3) + #expect(tableNode.content?.first?.content?.map(\.type) == ["tableHeader", "tableHeader", "tableHeader"]) + #expect(tableNode.content?.dropFirst().allSatisfy { row in + row.content?.allSatisfy { $0.type == "tableCell" } == true + } == true) + } + @Test func updatesTableCellAndReencodesRawTableNode() { let viewModel = tableViewModel() let blockID = viewModel.document.blocks[0].id @@ -38,8 +60,8 @@ struct NativeRichEditorTableTests { Issue.record("Expected table block") return } - #expect(expandedTable.rows.count == 3) - #expect(expandedTable.columnCount == 3) + #expect(expandedTable.rows.count == 4) + #expect(expandedTable.columnCount == 4) #expect(expandedTable.rows[0].cells[1].isHeader == true) #expect(expandedTable.rows[1].cells.allSatisfy { $0.isHeader == false }) @@ -50,9 +72,9 @@ struct NativeRichEditorTableTests { Issue.record("Expected table block") return } - #expect(reducedTable.rows.count == 2) - #expect(reducedTable.columnCount == 2) - #expect(viewModel.document.proseMirrorDocument.content.first?.content?.count == 2) + #expect(reducedTable.rows.count == 3) + #expect(reducedTable.columnCount == 3) + #expect(viewModel.document.proseMirrorDocument.content.first?.content?.count == 3) } @Test func updatesTableColumnWidthAndReencodesCellColwidth() { diff --git a/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift b/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift index 933d573..52e40f1 100644 --- a/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift @@ -92,10 +92,10 @@ struct NativeRichEditorViewModelTests { Issue.record("Expected table block") return } - #expect(table.rows.count == 2) + #expect(table.rows.count == 3) #expect(viewModel.document.blocks[0].isEditable == false) #expect(viewModel.document.proseMirrorDocument.content.first?.type == "table") - #expect(viewModel.document.proseMirrorDocument.content.first?.content?.count == 2) + #expect(viewModel.document.proseMirrorDocument.content.first?.content?.count == 3) } @Test func slashCommandInventoryIncludesMediaFileAndDiagramBlocks() { From 0f216c44db400e1ad50f7973c06184ebec32086d Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 13:23:22 +0100 Subject: [PATCH 019/201] fix: support callout markdown shortcut --- .../Editor/NativeEditorMarkdownParser.swift | 24 +++++++++++++ .../NativeRichEditorMechanicsTests.swift | 36 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift index b3fc797..d9644b2 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift @@ -52,6 +52,10 @@ enum NativeEditorMarkdownParser { return detailsRule } + if let calloutRule = calloutInputRule(from: text) { + return calloutRule + } + return lineInputRule(from: text) } @@ -133,6 +137,26 @@ enum NativeEditorMarkdownParser { return NativeEditorMarkdownInputRule(kind: .details(details), text: details.summary) } + private static func calloutInputRule(from text: String) -> NativeEditorMarkdownInputRule? { + guard text.hasPrefix(":::"), text.last?.isWhitespace == true else { return nil } + + let typeText = text + .dropFirst(3) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard typeText.allSatisfy(\.isLetter) else { return nil } + + let style = normalizedCalloutStyle(from: typeText) + let callout = NativeEditorCalloutBlock(style: style, icon: nil, previewText: "Callout") + return NativeEditorMarkdownInputRule(kind: .callout(callout), text: callout.previewText) + } + + private static func normalizedCalloutStyle(from typeText: String) -> String { + let validStyles: Set = ["default", "info", "note", "success", "warning", "danger"] + guard typeText.isEmpty == false else { return "info" } + let lowercasedType = typeText.lowercased() + return validStyles.contains(lowercasedType) ? lowercasedType : "info" + } + private static func lineInputRule(from text: String) -> NativeEditorMarkdownInputRule? { if let taskRule = taskInputRule(from: text) { return taskRule diff --git a/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift b/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift index acf9813..02fe68c 100644 --- a/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift @@ -98,6 +98,42 @@ struct NativeRichEditorMechanicsTests { #expect(String(viewModel.document.blocks[0].text.characters).isEmpty) } + @Test func markdownInputRuleSupportsDocmostDefaultCalloutShortcut() { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString(""), alignment: .left) + let viewModel = configuredViewModel(blocks: [block]) + viewModel.focus(blockID: block.id) + + viewModel.document.blocks[0].text = AttributedString("::: ") + viewModel.handleDocumentChanged() + + guard case .callout(let callout) = viewModel.document.blocks[0].kind else { + Issue.record("Expected Docmost callout shortcut to create a native callout block.") + return + } + #expect(callout.style == "info") + #expect(callout.previewText == "Callout") + #expect(String(viewModel.document.blocks[0].text.characters) == "Callout") + #expect(viewModel.document.proseMirrorDocument.content[0].type == "callout") + #expect(viewModel.document.proseMirrorDocument.content[0].attrs?["type"] == .string("info")) + } + + @Test func markdownInputRuleSupportsDocmostTypedCalloutShortcut() { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString(""), alignment: .left) + let viewModel = configuredViewModel(blocks: [block]) + viewModel.focus(blockID: block.id) + + viewModel.document.blocks[0].text = AttributedString(":::warning ") + viewModel.handleDocumentChanged() + + guard case .callout(let callout) = viewModel.document.blocks[0].kind else { + Issue.record("Expected Docmost typed callout shortcut to create a native callout block.") + return + } + #expect(callout.style == "warning") + #expect(String(viewModel.document.blocks[0].text.characters) == "Callout") + #expect(viewModel.document.proseMirrorDocument.content[0].attrs?["type"] == .string("warning")) + } + @Test func pasteMarkdownInsertsNativeBlocksAfterActiveBlock() { let intro = NativeEditorBlock(kind: .paragraph, text: AttributedString("Intro"), alignment: .left) let viewModel = configuredViewModel(blocks: [intro]) From 8cb18c427d6f55ae246ca2cba3a9d11631c08532 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 13:25:30 +0100 Subject: [PATCH 020/201] fix: support math block markdown shortcut --- .../Editor/NativeEditorMarkdownParser.swift | 17 +++++++++++++++++ .../NativeRichEditorMechanicsTests.swift | 18 ++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift index d9644b2..efac3d9 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift @@ -48,6 +48,10 @@ enum NativeEditorMarkdownParser { return codeRule } + if let mathBlockRule = mathBlockInputRule(from: text) { + return mathBlockRule + } + if let detailsRule = detailsInputRule(from: text) { return detailsRule } @@ -130,6 +134,19 @@ enum NativeEditorMarkdownParser { return NativeEditorMarkdownInputRule(kind: .codeBlock(language: language.isEmpty ? nil : language), text: "") } + private static func mathBlockInputRule(from text: String) -> NativeEditorMarkdownInputRule? { + guard text.hasPrefix("$$$"), text.hasSuffix("$$$") else { return nil } + + let mathText = text + .dropFirst(3) + .dropLast(3) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard mathText.isEmpty == false else { return nil } + + let math = NativeEditorMathBlock(text: mathText) + return NativeEditorMarkdownInputRule(kind: .mathBlock(math), text: math.text) + } + private static func detailsInputRule(from text: String) -> NativeEditorMarkdownInputRule? { guard text == ":::details " else { return nil } diff --git a/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift b/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift index 02fe68c..d44d619 100644 --- a/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift @@ -134,6 +134,24 @@ struct NativeRichEditorMechanicsTests { #expect(viewModel.document.proseMirrorDocument.content[0].attrs?["type"] == .string("warning")) } + @Test func markdownInputRuleSupportsDocmostMathBlockShortcut() { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString(""), alignment: .left) + let viewModel = configuredViewModel(blocks: [block]) + viewModel.focus(blockID: block.id) + + viewModel.document.blocks[0].text = AttributedString("$$$E = mc^2$$$") + viewModel.handleDocumentChanged() + + guard case .mathBlock(let math) = viewModel.document.blocks[0].kind else { + Issue.record("Expected Docmost math shortcut to create a native math block.") + return + } + #expect(math.text == "E = mc^2") + #expect(String(viewModel.document.blocks[0].text.characters) == "E = mc^2") + #expect(viewModel.document.proseMirrorDocument.content[0].type == "mathBlock") + #expect(viewModel.document.proseMirrorDocument.content[0].attrs?["text"] == .string("E = mc^2")) + } + @Test func pasteMarkdownInsertsNativeBlocksAfterActiveBlock() { let intro = NativeEditorBlock(kind: .paragraph, text: AttributedString("Intro"), alignment: .left) let viewModel = configuredViewModel(blocks: [intro]) From fd026a134dcaac569780e32af2f788d57cd3796a Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 13:35:04 +0100 Subject: [PATCH 021/201] fix: stabilize editor fidelity PR checks --- .../Editor/NativeEditorCommand+Behavior.swift | 12 +++++++----- .../Editor/NativeEditorDocument+Encoding.swift | 4 ++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift b/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift index 1034345..fa616ce 100644 --- a/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift +++ b/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift @@ -25,18 +25,20 @@ extension NativeEditorCommand { func matchPriority(query: String) -> Int? { guard query.isEmpty == false else { return 0 } - if title.localizedStandardContains(query) - || rawValue.localizedStandardContains(query) - || title.fuzzyMatchesSlashCommandQuery(query) { + if title.localizedStandardContains(query) || rawValue.localizedStandardContains(query) { return 0 } - if subtitle.localizedStandardContains(query) { + if title.fuzzyMatchesSlashCommandQuery(query) { return 1 } + if subtitle.localizedStandardContains(query) { + return 2 + } + if searchTerms.contains(where: { $0.localizedStandardContains(query) }) { - return 1 + return 2 } return nil diff --git a/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift b/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift index fb4ce98..abbbd6b 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift @@ -217,8 +217,8 @@ nonisolated extension NativeEditorDocument { private static func structuralFallbackNode(from block: NativeEditorBlock) -> ProseMirrorNode? { switch block.kind { - case .callout: - ProseMirrorNode(type: "callout", content: [textContainerNode(type: "paragraph", block: block)]) + case .callout(let callout): + NativeEditorRichBlockNodeFactory.calloutNode(from: callout) case .details(let details): NativeEditorRichBlockNodeFactory.detailsNode(from: details) case .pageBreak: From a162c263e7ffce14fe66bb8841c792911f9db970 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 13:42:57 +0100 Subject: [PATCH 022/201] fix: rank slash command raw aliases after fuzzy titles --- .../Features/Editor/NativeEditorCommand+Behavior.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift b/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift index fa616ce..661ed2a 100644 --- a/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift +++ b/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift @@ -25,7 +25,7 @@ extension NativeEditorCommand { func matchPriority(query: String) -> Int? { guard query.isEmpty == false else { return 0 } - if title.localizedStandardContains(query) || rawValue.localizedStandardContains(query) { + if title.localizedStandardContains(query) { return 0 } @@ -33,6 +33,10 @@ extension NativeEditorCommand { return 1 } + if rawValue.localizedStandardContains(query) { + return 2 + } + if subtitle.localizedStandardContains(query) { return 2 } From 2afbd5f632c247a4228fe1fbb266c96fbad03075 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 13:49:36 +0100 Subject: [PATCH 023/201] fix: keep slash fuzzy matches visible --- .../Editor/NativeEditorCommand+Behavior.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift b/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift index 661ed2a..8ca2ff8 100644 --- a/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift +++ b/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift @@ -25,11 +25,11 @@ extension NativeEditorCommand { func matchPriority(query: String) -> Int? { guard query.isEmpty == false else { return 0 } - if title.localizedStandardContains(query) { + if title.localizedStandardContainsAtWordStart(query) { return 0 } - if title.fuzzyMatchesSlashCommandQuery(query) { + if title.localizedStandardContains(query) || title.fuzzyMatchesSlashCommandQuery(query) { return 1 } @@ -149,6 +149,15 @@ extension NativeEditorCommand { } private extension String { + func localizedStandardContainsAtWordStart(_ query: String) -> Bool { + guard let range = localizedStandardRange(of: query) else { return false } + guard range.lowerBound != startIndex else { return true } + + let previousIndex = index(before: range.lowerBound) + let previousCharacter = self[previousIndex] + return previousCharacter.isWhitespace || previousCharacter.isPunctuation + } + func fuzzyMatchesSlashCommandQuery(_ query: String) -> Bool { let normalizedQuery = query.lowercased() guard normalizedQuery.isEmpty == false else { return true } From 38c17ce94a404d2e2c965e4394126c7cb3109704 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 13:56:24 +0100 Subject: [PATCH 024/201] fix: serialize CRDT attachment tests --- .../Editor/NativeEditorCRDTCoordinatorReuseTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/docmostlyTests/Editor/NativeEditorCRDTCoordinatorReuseTests.swift b/docmostlyTests/Editor/NativeEditorCRDTCoordinatorReuseTests.swift index 19720bc..b3c0321 100644 --- a/docmostlyTests/Editor/NativeEditorCRDTCoordinatorReuseTests.swift +++ b/docmostlyTests/Editor/NativeEditorCRDTCoordinatorReuseTests.swift @@ -24,6 +24,7 @@ struct NativeEditorCRDTCoordinatorReuseTests { } @MainActor +@Suite(.serialized) struct CRDTEngineAttachmentTests { @Test func crdtAttachmentDoesNothingWithoutFactory() async { let appState = AppState() From 629b48cb50c9d7948241d9570b427b18ddb154a1 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 14:01:55 +0100 Subject: [PATCH 025/201] fix: narrow no-factory CRDT test --- .../NativeEditorCRDTCoordinatorReuseTests.swift | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/docmostlyTests/Editor/NativeEditorCRDTCoordinatorReuseTests.swift b/docmostlyTests/Editor/NativeEditorCRDTCoordinatorReuseTests.swift index b3c0321..eab477c 100644 --- a/docmostlyTests/Editor/NativeEditorCRDTCoordinatorReuseTests.swift +++ b/docmostlyTests/Editor/NativeEditorCRDTCoordinatorReuseTests.swift @@ -26,18 +26,15 @@ struct NativeEditorCRDTCoordinatorReuseTests { @MainActor @Suite(.serialized) struct CRDTEngineAttachmentTests { - @Test func crdtAttachmentDoesNothingWithoutFactory() async { - let appState = AppState() - let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") - - await NativeEditorCRDTDocumentEngineAttachment.attachIfAvailable( - to: viewModel, - appState: appState + @Test func appStateDoesNotCreateCRDTEngineWithoutFactory() async throws { + let appState = AppState(crdtDocumentEngineFactory: nil) + let engine = try await appState.makeCRDTDocumentEngine( + pageID: "page-1", + title: "Page", + document: NativeEditorDocument() ) - #expect(viewModel.usesCRDTDocumentEngine == false) - #expect(viewModel.collaborationSession().syncDriver == nil) - #expect(viewModel.realtimeStatus == .disconnected) + #expect(engine == nil) } @Test func crdtAttachmentConfiguresFactoryEngineBeforeCollaborationSession() async throws { From 05e748af2173bf3e002b649b0d446a0dfd21b25c Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 14:14:02 +0100 Subject: [PATCH 026/201] fix: support inline math input rule --- .../Editor/NativeEditorMarkdownParser.swift | 35 +++++++++++++++++++ .../NativeRichEditorViewModel+History.swift | 1 + .../NativeRichEditorViewModel+Mechanics.swift | 24 +++++++++++++ .../NativeRichEditorMechanicsTests.swift | 22 ++++++++++++ 4 files changed, 82 insertions(+) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift index efac3d9..33b0a89 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift @@ -276,6 +276,14 @@ enum NativeEditorMarkdownParser { return result } + static func inlineMathInputRuleText(from text: String) -> AttributedString? { + guard let shortcut = trailingInlineMathShortcut(in: text) else { return nil } + + var result = AttributedString(String(text[.. (openingRange: Range, text: String)? { + guard text.hasSuffix("$$") else { return nil } + + let closingStart = text.index(text.endIndex, offsetBy: -2) + guard + let openingRange = text.range( + of: "$$", + options: .backwards, + range: text.startIndex.. text.startIndex { + let previousIndex = text.index(before: openingRange.lowerBound) + guard text[previousIndex].isWhitespace else { return nil } + } + + return (openingRange, mathText) + } + private static func nextInlineMathDelimiter( in markdown: Substring ) -> (range: Range, value: String)? { diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+History.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+History.swift index 8d16b01..a33dc11 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+History.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+History.swift @@ -86,6 +86,7 @@ extension NativeRichEditorViewModel { if applyingInputRules { applyMarkdownInputRuleIfNeeded() applySmartTypographyIfNeeded() + applyInlineMarkdownInputRuleIfNeeded() } let after = makeHistorySnapshot() diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+Mechanics.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+Mechanics.swift index bea21db..3ca6928 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+Mechanics.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+Mechanics.swift @@ -76,6 +76,21 @@ extension NativeRichEditorViewModel { document.blocks[index].selection = AttributedTextSelection() } + func applyInlineMarkdownInputRuleIfNeeded() { + guard + let index = activeBlockIndex, + document.blocks[index].kind.allowsInlineMarkdownInputRules, + let attributedText = NativeEditorMarkdownParser.inlineMathInputRuleText( + from: String(document.blocks[index].text.characters) + ) + else { + return + } + + document.blocks[index].text = attributedText + document.blocks[index].selection = AttributedTextSelection() + } + func applySmartTypographyIfNeeded() { guard let index = activeBlockIndex, document.blocks[index].kind.allowsSmartTypography else { return } @@ -125,4 +140,13 @@ private extension NativeEditorBlockKind { true } } + + var allowsInlineMarkdownInputRules: Bool { + switch self { + case .codeBlock: + false + default: + isEditable + } + } } diff --git a/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift b/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift index d44d619..c907cbd 100644 --- a/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift @@ -152,6 +152,28 @@ struct NativeRichEditorMechanicsTests { #expect(viewModel.document.proseMirrorDocument.content[0].attrs?["text"] == .string("E = mc^2")) } + @Test func markdownInputRuleSupportsDocmostInlineMathShortcut() throws { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString(""), alignment: .left) + let viewModel = configuredViewModel(blocks: [block]) + viewModel.focus(blockID: block.id) + + viewModel.document.blocks[0].text = AttributedString("Area $$A = pi r^2$$") + viewModel.handleDocumentChanged() + + #expect(viewModel.document.blocks[0].kind == .paragraph) + #expect(String(viewModel.document.blocks[0].text.characters) == "Area A = pi r^2") + + let inlineNodes = try #require(viewModel.document.proseMirrorDocument.content.first?.content) + #expect(inlineNodes.map(\.type) == ["text", "mathInline"]) + #expect(inlineNodes[0].text == "Area ") + #expect(inlineNodes[1].attrs?["text"] == .string("A = pi r^2")) + + viewModel.undo() + + #expect(viewModel.document.blocks[0].kind == .paragraph) + #expect(String(viewModel.document.blocks[0].text.characters).isEmpty) + } + @Test func pasteMarkdownInsertsNativeBlocksAfterActiveBlock() { let intro = NativeEditorBlock(kind: .paragraph, text: AttributedString("Intro"), alignment: .left) let viewModel = configuredViewModel(blocks: [intro]) From 15c62a0b25893c91830e8a1d2317c7eccb6d562c Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 14:24:38 +0100 Subject: [PATCH 027/201] test: cover slash menu code block gating --- .../Editor/NativeEditorSlashCommandTests.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift b/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift index 0379618..0e01dad 100644 --- a/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift +++ b/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift @@ -57,6 +57,20 @@ struct NativeEditorSlashCommandTests { } } + @Test func slashCommandMenuIsDisabledInsideCodeBlocks() { + let block = NativeEditorBlock( + kind: .codeBlock(language: nil), + text: AttributedString("/table"), + alignment: .left + ) + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: [block]) + viewModel.focus(blockID: block.id) + + #expect(viewModel.isShowingSlashCommands == false) + #expect(viewModel.filteredSlashCommands.isEmpty) + } + @Test func applyingColumnSlashCommandsCreatesDocmostColumnLayouts() { let expectations = [ ColumnCommandExpectation(command: .columns, layout: "two_equal", columnCount: 2), From 42bc426310f939846e7fc78aa4a99001bbaac4c5 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 14:29:44 +0100 Subject: [PATCH 028/201] fix: disable slash menu in code blocks --- .../NativeRichEditorViewModel+BlockEditing.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift index a5b8559..1c9ee50 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift @@ -241,6 +241,7 @@ extension NativeRichEditorViewModel { var activeSlashCommandQuery: String? { guard let index = activeBlockIndex else { return nil } + guard document.blocks[index].kind.allowsSlashCommands else { return nil } let text = String(document.blocks[index].text.characters) guard text.first == "/", text.contains("\n") == false else { @@ -250,3 +251,14 @@ extension NativeRichEditorViewModel { return String(text.dropFirst()).trimmingCharacters(in: .whitespacesAndNewlines) } } + +private extension NativeEditorBlockKind { + var allowsSlashCommands: Bool { + switch self { + case .codeBlock: + false + default: + isEditable + } + } +} From f7cdcc91ffea4f3e6390587258c346bc26101a51 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 14:36:27 +0100 Subject: [PATCH 029/201] fix: require active slash query for command list --- docmostly/Features/Editor/NativeRichEditorViewModel.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel.swift b/docmostly/Features/Editor/NativeRichEditorViewModel.swift index 2da1f61..ce508ff 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel.swift @@ -113,6 +113,8 @@ final class NativeRichEditorViewModel { } var filteredSlashCommands: [NativeEditorCommand] { + guard let slashCommandQuery = activeSlashCommandQuery else { return [] } + let matches = NativeEditorCommand.allCases.compactMap { command in command.matchPriority(query: slashCommandQuery).map { priority in (command: command, priority: priority) From 72c655d4993450be8e89485f581df4c48227b90d Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 14:51:12 +0100 Subject: [PATCH 030/201] test: cover image title markdown fidelity --- .../NativeEditorMarkdownImportTests.swift | 16 ++++++++++++++ .../NativeEditorRichMarkdownExportTests.swift | 22 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift index 72afd79..a2ae82a 100644 --- a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift +++ b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift @@ -86,6 +86,22 @@ struct NativeEditorMarkdownImportTests { #expect(blocks[3].rawNode?.attrs?["attachmentId"] == .string("file-1")) } + @Test func markdownImageTitleImportsAsNativeMediaTitle() throws { + let block = try #require(NativeEditorMarkdownParser.blocks( + from: #"![Architecture](/files/image.png "System diagram")"# + ).first) + + guard case .image(let image) = block.kind else { + Issue.record("Expected Markdown image to import as a native image block.") + return + } + + #expect(image.source == "/files/image.png") + #expect(image.alternativeText == "Architecture") + #expect(image.title == "System diagram") + #expect(block.rawNode?.attrs?["title"] == .string("System diagram")) + } + @Test func genericMarkdownLinksRemainEditableParagraphText() throws { let block = try #require(NativeEditorMarkdownParser.blocks(from: "[Example](https://example.com)").first) diff --git a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift index 829ef90..a62a949 100644 --- a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift +++ b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift @@ -4,6 +4,28 @@ import Testing @MainActor struct NativeEditorRichMarkdownExportTests { + @Test func documentMarkdownConversionPreservesImageTitle() { + let viewModel = configuredViewModel(blocks: [ + NativeEditorBlock( + kind: .image(NativeEditorMediaBlock( + source: "/files/image.png", + alternativeText: "Architecture", + title: "System diagram", + attachmentID: nil, + sizeInBytes: nil, + width: nil, + height: nil, + aspectRatio: nil, + alignment: nil + )), + text: AttributedString("Architecture"), + alignment: .left + ) + ]) + + #expect(viewModel.markdownForDocument() == #"![Architecture](/files/image.png "System diagram")"#) + } + @Test func documentMarkdownConversionPreservesRichBlockMeaning() { let viewModel = configuredViewModel(blocks: richMarkdownFixtureBlocks()) From 500f18c46856b5a254c2633ba9f46c802598845f Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 15:00:00 +0100 Subject: [PATCH 031/201] fix: preserve image markdown titles --- ...torMarkdownParser+MarkdownLinkTitles.swift | 52 +++++++++++++++++++ ...ativeEditorMarkdownParser+RichBlocks.swift | 6 ++- 2 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+MarkdownLinkTitles.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+MarkdownLinkTitles.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+MarkdownLinkTitles.swift new file mode 100644 index 0000000..3957999 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+MarkdownLinkTitles.swift @@ -0,0 +1,52 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func markdownLinkTitle(from destination: String) -> String? { + let destination = destination.trimmingCharacters(in: .whitespacesAndNewlines) + guard + let titleRange = destination.range(of: " \""), + destination.hasSuffix("\"") + else { + return nil + } + + let titleStart = titleRange.upperBound + let titleEnd = destination.index(before: destination.endIndex) + let title = unescapedMarkdownLinkTitle(String(destination[titleStart.. String { + guard let title, title.isEmpty == false else { return "" } + return " \"\(escapedMarkdownLinkTitle(title))\"" + } + + private static func escapedMarkdownLinkTitle(_ text: String) -> String { + text.replacing("\\", with: "\\\\") + .replacing("\"", with: "\\\"") + .replacing("\r", with: " ") + .replacing("\n", with: " ") + } + + private static func unescapedMarkdownLinkTitle(_ text: String) -> String { + var result = "" + var isEscaped = false + + for character in text { + if isEscaped { + result.append(character) + isEscaped = false + } else if character == "\\" { + isEscaped = true + } else { + result.append(character) + } + } + + if isEscaped { + result.append("\\") + } + + return result + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift index 9591302..2722ea2 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift @@ -221,13 +221,14 @@ extension NativeEditorMarkdownParser { let destinationStartIndex = line.index(after: openDestinationIndex) let destination = String(line[destinationStartIndex.. String { From a55cc4949408987556ef6b68e50bee4687d6122f Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 15:15:04 +0100 Subject: [PATCH 032/201] test: cover iframe markdown embed import --- .../Editor/NativeEditorMarkdownImportTests.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift index a2ae82a..479ba64 100644 --- a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift +++ b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift @@ -102,6 +102,22 @@ struct NativeEditorMarkdownImportTests { #expect(block.rawNode?.attrs?["title"] == .string("System diagram")) } + @Test func docmostIframeMarkdownLinksImportAsEmbedBlocks() throws { + let source = "https://player.example.com/embed/demo" + let block = try #require(NativeEditorMarkdownParser.blocks(from: "[\(source)](\(source))").first) + + guard case .embed(let embed) = block.kind else { + Issue.record("Expected Docmost iframe Markdown link to import as a native embed block.") + return + } + + #expect(embed.source == source) + #expect(embed.provider == "iframe") + #expect(block.rawNode?.type == "embed") + #expect(block.rawNode?.attrs?["src"] == .string(source)) + #expect(block.rawNode?.attrs?["provider"] == .string("iframe")) + } + @Test func genericMarkdownLinksRemainEditableParagraphText() throws { let block = try #require(NativeEditorMarkdownParser.blocks(from: "[Example](https://example.com)").first) From d7b34dadd66c199b2d2798954cc4bb3f224fae63 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 15:22:58 +0100 Subject: [PATCH 033/201] test: cover iframe embed markdown export --- .../NativeEditorRichMarkdownExportTests.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift index a62a949..55dacb6 100644 --- a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift +++ b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift @@ -4,6 +4,25 @@ import Testing @MainActor struct NativeEditorRichMarkdownExportTests { + @Test func documentMarkdownConversionPreservesIframeEmbedMarkdownShape() { + let source = "https://player.example.com/embed/demo" + let viewModel = configuredViewModel(blocks: [ + NativeEditorBlock( + kind: .embed(NativeEditorEmbedBlock( + source: source, + provider: "iframe", + alignment: nil, + width: nil, + height: nil + )), + text: AttributedString(source), + alignment: .left + ) + ]) + + #expect(viewModel.markdownForDocument() == "[\(source)](\(source))") + } + @Test func documentMarkdownConversionPreservesImageTitle() { let viewModel = configuredViewModel(blocks: [ NativeEditorBlock( From fe855894769fb651401f9f7407aa45f7997b0ff7 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 15:29:57 +0100 Subject: [PATCH 034/201] fix: preserve iframe embed markdown --- .../NativeEditorMarkdownParser+Embeds.swift | 86 +++++++++++++++++++ ...ativeEditorMarkdownParser+RichBlocks.swift | 12 ++- 2 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift new file mode 100644 index 0000000..5e0f5d6 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift @@ -0,0 +1,86 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func iframeEmbedMarkdownBlock(from line: String) -> NativeEditorBlock? { + guard let link = iframeMarkdownLink(from: line), isWebEmbedSource(link.source) else { + return nil + } + + let embed = NativeEditorEmbedBlock( + source: link.source, + provider: "iframe", + alignment: nil, + width: nil, + height: nil + ) + return NativeEditorBlock( + kind: .embed(embed), + text: AttributedString(link.source), + alignment: .left, + rawNode: NativeEditorRichBlockNodeFactory.embedNode(from: embed) + ) + } + + private static func iframeMarkdownLink(from line: String) -> (label: String, source: String)? { + guard line.hasPrefix("["), let closeLabelIndex = line.firstIndex(of: "]") else { + return nil + } + + let openDestinationIndex = line.index(after: closeLabelIndex) + guard + openDestinationIndex < line.endIndex, + line[openDestinationIndex] == "(", + let closeDestinationIndex = line.lastIndex(of: ")"), + closeDestinationIndex == line.index(before: line.endIndex) + else { + return nil + } + + let labelStartIndex = line.index(after: line.startIndex) + let label = unescapedMarkdownLinkLabel(String(line[labelStartIndex.. String { + var source = destination.trimmingCharacters(in: .whitespacesAndNewlines) + if source.hasPrefix("<"), source.hasSuffix(">") { + source.removeFirst() + source.removeLast() + } + return source.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func unescapedMarkdownLinkLabel(_ text: String) -> String { + var result = "" + var isEscaped = false + + for character in text { + if isEscaped { + result.append(character) + isEscaped = false + } else if character == "\\" { + isEscaped = true + } else { + result.append(character) + } + } + + return isEscaped ? result + "\\" : result + } + + private static func isWebEmbedSource(_ source: String) -> Bool { + guard + let components = URLComponents(string: source), + let scheme = components.scheme?.lowercased(), + scheme == "https" || scheme == "http", + components.host?.isEmpty == false + else { + return false + } + + return true + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift index 2722ea2..d055e63 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift @@ -17,7 +17,7 @@ extension NativeEditorMarkdownParser { } static func singleLineRichBlock(from line: String) -> NativeEditorBlock? { - imageMarkdownBlock(from: line) ?? linkedFileMarkdownBlock(from: line) + imageMarkdownBlock(from: line) ?? linkedFileMarkdownBlock(from: line) ?? iframeEmbedMarkdownBlock(from: line) } static func richMarkdownLine(from block: NativeEditorBlock) -> String? { @@ -73,7 +73,7 @@ extension NativeEditorMarkdownParser { private static func embeddedMarkdownLine(from block: NativeEditorBlock) -> String? { switch block.kind { case .embed(let embed): - linkMarkdown(title: embed.provider ?? embed.source ?? "Embed", url: embed.source) + embedMarkdown(from: embed) case .drawio(let diagram): diagramMarkdown(from: diagram, fallbackTitle: "Draw.io diagram") case .excalidraw(let diagram): @@ -410,6 +410,14 @@ extension NativeEditorMarkdownParser { ) } + private static func embedMarkdown(from embed: NativeEditorEmbedBlock) -> String { + if embed.provider == "iframe", let source = embed.source, source.isEmpty == false { + return linkMarkdown(title: source, url: source) + } + + return linkMarkdown(title: embed.provider ?? embed.source ?? "Embed", url: embed.source) + } + private static func mathMarkdown(from math: NativeEditorMathBlock) -> String { """ $$ From 5ee65732577eb669ce5447483dd46a0b7b46c3f2 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 15:44:23 +0100 Subject: [PATCH 035/201] test: cover markdown hard break import --- .../NativeEditorMarkdownImportTests.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift index 479ba64..0d35f15 100644 --- a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift +++ b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift @@ -4,6 +4,23 @@ import Testing @MainActor struct NativeEditorMarkdownImportTests { + @Test func markdownSingleNewlinesImportAsHardBreaksInsideParagraph() throws { + let blocks = NativeEditorMarkdownParser.blocks(from: """ + Line one + Line two + """) + + #expect(blocks.count == 1) + let block = try #require(blocks.first) + #expect(block.kind == .paragraph) + #expect(String(block.text.characters) == "Line one\nLine two") + + let node = NativeEditorDocument.node(from: block) + #expect(node.content?.map(\.type) == ["text", "hardBreak", "text"]) + #expect(node.content?.first?.text == "Line one") + #expect(node.content?.last?.text == "Line two") + } + @Test func markdownLinksImportAsNativeMediaAndAttachmentBlocks() throws { let blocks = NativeEditorMarkdownParser.blocks(from: """ [Launch demo.mp4](/files/demo.mp4) From 2ab3a58f1928b50893f786d388b0133cf0894c43 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 15:50:24 +0100 Subject: [PATCH 036/201] fix: preserve markdown hard breaks on import --- .../Editor/NativeEditorMarkdownParser.swift | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift index 33b0a89..71f410d 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift @@ -30,6 +30,12 @@ enum NativeEditorMarkdownParser { continue } + if let paragraph = paragraphBlock(in: lines, startingAt: index) { + blocks.append(paragraph.block) + index = paragraph.endIndex + continue + } + if let block = block(from: lines[index]) { blocks.append(block) } @@ -39,6 +45,48 @@ enum NativeEditorMarkdownParser { return blocks.isEmpty ? [NativeEditorDocument.emptyBlock()] : blocks } + private static func paragraphBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + var paragraphLines: [String] = [] + var currentIndex = index + + while currentIndex < lines.endIndex, + let text = paragraphLineText(in: lines, startingAt: currentIndex) { + paragraphLines.append(text) + currentIndex = lines.index(after: currentIndex) + } + + guard paragraphLines.count > 1 else { return nil } + + return ( + NativeEditorBlock( + kind: .paragraph, + text: inlineText(from: paragraphLines.joined(separator: "\n")), + alignment: .left + ), + currentIndex + ) + } + + private static func paragraphLineText( + in lines: [String], + startingAt index: Array.Index + ) -> String? { + guard + fencedCodeBlock(in: lines, startingAt: index) == nil, + richBlock(in: lines, startingAt: index) == nil, + tableBlock(in: lines, startingAt: index) == nil, + let block = block(from: lines[index]), + block.kind == .paragraph + else { + return nil + } + + return String(block.text.characters) + } + static func inputRule(from text: String) -> NativeEditorMarkdownInputRule? { if isDivider(text.trimmingCharacters(in: .whitespaces)) { return NativeEditorMarkdownInputRule(kind: .divider, text: "Divider") From 5b1b0e7f48358974ec62b6b90c5b4c55e2fe679e Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 15:56:31 +0100 Subject: [PATCH 037/201] fix: retain newlines in grouped markdown paragraphs --- .../Features/Editor/NativeEditorMarkdownParser.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift index 71f410d..5c9a487 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift @@ -63,13 +63,23 @@ enum NativeEditorMarkdownParser { return ( NativeEditorBlock( kind: .paragraph, - text: inlineText(from: paragraphLines.joined(separator: "\n")), + text: multilineParagraphText(from: paragraphLines), alignment: .left ), currentIndex ) } + private static func multilineParagraphText(from lines: [String]) -> AttributedString { + lines.enumerated().reduce(into: AttributedString("")) { result, item in + if item.offset > 0 { + result += AttributedString("\n") + } + + result += inlineText(from: item.element) + } + } + private static func paragraphLineText( in lines: [String], startingAt index: Array.Index From 652fa94c2ff1684f911d8479d77e57c0896c6562 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 16:08:45 +0100 Subject: [PATCH 038/201] test: cover status markdown fidelity --- .../NativeEditorStatusMarkdownTests.swift | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 docmostlyTests/Editor/NativeEditorStatusMarkdownTests.swift diff --git a/docmostlyTests/Editor/NativeEditorStatusMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorStatusMarkdownTests.swift new file mode 100644 index 0000000..48c038a --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorStatusMarkdownTests.swift @@ -0,0 +1,39 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorStatusMarkdownTests { + @Test func markdownExportPreservesStatusAtomAsDocmostHTML() { + var text = AttributedString("Stage ") + var statusText = AttributedString("Blocked") + statusText[NativeEditorStatusAttribute.self] = NativeEditorStatusBadge(text: "Blocked", color: "red") + text += statusText + text += AttributedString(" now") + + let block = NativeEditorBlock(kind: .paragraph, text: text, alignment: .left) + + #expect( + NativeEditorMarkdownParser.markdown(from: [block]) == + #"Stage Blocked now"# + ) + } + + @Test func markdownImportPreservesDocmostStatusHTMLAsAtom() throws { + let block = try #require(NativeEditorMarkdownParser.blocks( + from: #"Stage Ship now"# + ).first) + + let statusRun = try #require(block.text.runs.first { run in + run[NativeEditorStatusAttribute.self]?.text == "Ship" + }) + #expect(statusRun[NativeEditorStatusAttribute.self]?.color == "green") + + let inlineNodes = try #require(NativeEditorDocument.node(from: block).content) + #expect(inlineNodes.map(\.type) == ["text", "status", "text"]) + #expect(inlineNodes[0].text == "Stage ") + #expect(inlineNodes[2].text == " now") + #expect(inlineNodes[1].attrs?["text"] == .string("Ship")) + #expect(inlineNodes[1].attrs?["color"] == .string("green")) + } +} From 990a544f69ca54ba00a4c2366798ea7d70400af7 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 16:14:56 +0100 Subject: [PATCH 039/201] fix: preserve status atoms in markdown --- ...iveEditorMarkdownParser+DocmostLinks.swift | 11 ++++ .../NativeEditorMarkdownParser+Status.swift | 57 +++++++++++++++++++ .../Editor/NativeEditorMarkdownParser.swift | 4 +- 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+Status.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift index 1344db2..4089921 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift @@ -45,6 +45,17 @@ extension NativeEditorMarkdownParser { remaining = remaining[htmlComment.range.upperBound...] } + while let htmlStatus = nextDocmostStatusHTML(in: remaining) { + appendMarkdownText( + String(remaining[.. String { + let color = escapedInlineHTMLAttribute(status.color) + let text = escapedInlineHTMLText(status.text) + return #"\#(text)"# + } + + static func nextDocmostStatusHTML( + in markdown: Substring + ) -> (range: Range, status: NativeEditorStatusBadge)? { + var searchStart = markdown.startIndex + + while searchStart < markdown.endIndex, + let openRange = markdown[searchStart...].range(of: "") else { + return nil + } + + let openingTag = String(markdown[openRange.lowerBound...openTagEnd]) + let attrs = docmostInlineHTMLAttributes(from: openingTag) + guard attrs["data-type"]?.localizedCaseInsensitiveCompare("status") == .orderedSame else { + searchStart = markdown.index(after: openRange.lowerBound) + continue + } + + let contentStart = markdown.index(after: openTagEnd) + guard let closeRange = matchingCloseSpanRange(in: markdown, bodyStart: contentStart) else { + return nil + } + + return ( + openRange.lowerBound.. Date: Sun, 28 Jun 2026 16:28:21 +0100 Subject: [PATCH 040/201] test: cover page break markdown fidelity --- .../Editor/NativeEditorMarkdownImportTests.swift | 9 +++++++++ .../Editor/NativeEditorRichMarkdownExportTests.swift | 12 ++++++++++++ 2 files changed, 21 insertions(+) diff --git a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift index 0d35f15..7f8b868 100644 --- a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift +++ b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift @@ -119,6 +119,15 @@ struct NativeEditorMarkdownImportTests { #expect(block.rawNode?.attrs?["title"] == .string("System diagram")) } + @Test func docmostPageBreakHTMLImportsAsNativePageBreakBlock() throws { + let block = try #require(NativeEditorMarkdownParser.blocks( + from: #"
"# + ).first) + + #expect(block.kind == .pageBreak) + #expect(block.rawNode?.type == "pageBreak") + } + @Test func docmostIframeMarkdownLinksImportAsEmbedBlocks() throws { let source = "https://player.example.com/embed/demo" let block = try #require(NativeEditorMarkdownParser.blocks(from: "[\(source)](\(source))").first) diff --git a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift index 55dacb6..7ee917c 100644 --- a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift +++ b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift @@ -45,6 +45,18 @@ struct NativeEditorRichMarkdownExportTests { #expect(viewModel.markdownForDocument() == #"![Architecture](/files/image.png "System diagram")"#) } + @Test func documentMarkdownConversionPreservesDocmostPageBreakHTMLShape() { + let viewModel = configuredViewModel(blocks: [ + NativeEditorBlock( + kind: .pageBreak, + text: AttributedString("Page break"), + alignment: .left + ) + ]) + + #expect(viewModel.markdownForDocument() == #"
"#) + } + @Test func documentMarkdownConversionPreservesRichBlockMeaning() { let viewModel = configuredViewModel(blocks: richMarkdownFixtureBlocks()) From 8f306374ee0833978e9bcb4c61dfa79dfdf0d759 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 16:36:28 +0100 Subject: [PATCH 041/201] fix: preserve page break markdown fidelity --- ...ativeEditorMarkdownParser+PageBreaks.swift | 34 +++++++++++++++++++ ...ativeEditorMarkdownParser+RichBlocks.swift | 5 +-- .../NativeEditorRichMarkdownExportTests.swift | 2 +- 3 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+PageBreaks.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+PageBreaks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+PageBreaks.swift new file mode 100644 index 0000000..5e45869 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+PageBreaks.swift @@ -0,0 +1,34 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func pageBreakHTMLBlock(from line: String) -> NativeEditorBlock? { + let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) + let lowercasedLine = trimmedLine.lowercased() + guard + lowercasedLine.hasPrefix(""), + tagEnd > trimmedLine.startIndex + else { + return nil + } + + let openingTag = String(trimmedLine[trimmedLine.startIndex.. NativeEditorBlock? { - imageMarkdownBlock(from: line) ?? linkedFileMarkdownBlock(from: line) ?? iframeEmbedMarkdownBlock(from: line) + pageBreakHTMLBlock(from: line) ?? imageMarkdownBlock(from: line) ?? linkedFileMarkdownBlock(from: line) ?? + iframeEmbedMarkdownBlock(from: line) } static func richMarkdownLine(from block: NativeEditorBlock) -> String? { @@ -56,7 +57,7 @@ extension NativeEditorMarkdownParser { case .details(let details): detailsMarkdown(from: details) case .pageBreak: - #"
"# + #"
"# case .columns(let columns): columnsMarkdown(from: columns) case .subpages: diff --git a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift index 7ee917c..91e9cb7 100644 --- a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift +++ b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift @@ -75,7 +75,7 @@ struct NativeEditorRichMarkdownExportTests { Ship native editor -
+
[Example](https://example.com) $$ E = mc^2 From 90d5254fb34fd56ac73920d189a5641283277df9 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 16:45:33 +0100 Subject: [PATCH 042/201] test: cover columns markdown fidelity --- .../NativeEditorMarkdownImportTests.swift | 31 +++++++++++++++++++ .../NativeEditorRichMarkdownExportTests.swift | 30 ++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift index 7f8b868..36f0f88 100644 --- a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift +++ b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift @@ -128,6 +128,37 @@ struct NativeEditorMarkdownImportTests { #expect(block.rawNode?.type == "pageBreak") } + @Test func docmostColumnsHTMLImportsAsNativeColumnsBlock() throws { + let markdown = """ +
+
+ Navigation +
+
+ Main content +
+
+ """ + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + + guard case .columns(let columns) = block.kind else { + Issue.record("Expected Docmost columns HTML to import as a native columns block.") + return + } + + #expect(columns.layout == "two_left_sidebar") + #expect(columns.widthMode == "wide") + #expect(columns.columnCount == 2) + #expect(columns.columnTexts == ["Navigation", "Main content"]) + #expect(block.rawNode?.type == "columns") + #expect(block.rawNode?.attrs?["layout"] == .string("two_left_sidebar")) + #expect(block.rawNode?.attrs?["widthMode"] == .string("wide")) + #expect(block.rawNode?.content?.map(\.type) == ["column", "column"]) + #expect(block.rawNode?.content?[0].attrs?["width"] == .double(0.6)) + #expect(block.rawNode?.content?[1].attrs?["width"] == .double(1.4)) + #expect(NativeEditorMarkdownParser.markdown(from: [block]) == markdown) + } + @Test func docmostIframeMarkdownLinksImportAsEmbedBlocks() throws { let source = "https://player.example.com/embed/demo" let block = try #require(NativeEditorMarkdownParser.blocks(from: "[\(source)](\(source))").first) diff --git a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift index 91e9cb7..e4ff64f 100644 --- a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift +++ b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift @@ -57,6 +57,36 @@ struct NativeEditorRichMarkdownExportTests { #expect(viewModel.markdownForDocument() == #"
"#) } + @Test func documentMarkdownConversionPreservesDocmostColumnsHTMLShape() { + let viewModel = configuredViewModel(blocks: [ + NativeEditorBlock( + kind: .columns(NativeEditorColumnsBlock( + layout: "three_equal", + widthMode: "wide", + columnCount: 3, + previewText: "Plan Build Ship", + columnTexts: ["Plan", "Build", "Ship"] + )), + text: AttributedString("Plan Build Ship"), + alignment: .left + ) + ]) + + #expect(viewModel.markdownForDocument() == """ +
+
+ Plan +
+
+ Build +
+
+ Ship +
+
+ """) + } + @Test func documentMarkdownConversionPreservesRichBlockMeaning() { let viewModel = configuredViewModel(blocks: richMarkdownFixtureBlocks()) From 4b231393434d3593ec9bb6f3f0ddad9e1d1ff263 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 16:55:59 +0100 Subject: [PATCH 043/201] fix: preserve columns markdown fidelity --- .../NativeEditorDocument+Payloads.swift | 19 +- .../NativeEditorMarkdownParser+Columns.swift | 186 ++++++++++++++++++ ...ativeEditorMarkdownParser+RichBlocks.swift | 11 +- .../NativeEditorRichBlockPayloads.swift | 1 + ...NativeRichEditorViewModel+RichBlocks.swift | 39 +++- 5 files changed, 244 insertions(+), 12 deletions(-) create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+Columns.swift diff --git a/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift b/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift index 5437de2..8b7304c 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift @@ -70,13 +70,15 @@ nonisolated extension NativeEditorDocument { static func columnsBlock(from node: ProseMirrorNode) -> NativeEditorColumnsBlock { let columns = (node.content ?? []).filter { $0.type == "column" } let columnTexts = columns.map { plainText(in: $0.content ?? []) } + let columnWidths = columns.map { columnWidth(from: $0.attrs) } return NativeEditorColumnsBlock( layout: node.attrs?["layout"]?.stringValue ?? "two_equal", widthMode: node.attrs?["widthMode"]?.stringValue ?? "normal", columnCount: columns.count, previewText: columnTexts.joined(separator: " "), - columnTexts: columnTexts + columnTexts: columnTexts, + columnWidths: columnWidths ) } @@ -209,6 +211,21 @@ nonisolated extension NativeEditorDocument { } } + private static func columnWidth(from attrs: [String: ProseMirrorJSONValue]?) -> Double? { + guard let value = attrs?["width"] else { return nil } + + switch value { + case .int(let width): + Double(width) + case .double(let width): + width + case .string(let width): + Double(width) + case .bool, .object, .array, .null: + nil + } + } + private static func normalizedTableSpan(_ value: Int?) -> Int { max(value ?? 1, 1) } diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Columns.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Columns.swift new file mode 100644 index 0000000..fa1bb41 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Columns.swift @@ -0,0 +1,186 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func columnsHTMLBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + guard let columnsAttributes = divAttributes(from: lines[index], dataType: "columns") else { + return nil + } + + var columns: [ParsedColumnHTML] = [] + var currentIndex = lines.index(after: index) + + while currentIndex < lines.endIndex { + let line = lines[currentIndex].trimmingCharacters(in: .whitespacesAndNewlines) + if line.localizedCaseInsensitiveCompare("") == .orderedSame { + guard columns.isEmpty == false else { return nil } + let columnsBlock = nativeColumnsBlock(from: columnsAttributes, columns: columns) + return ( + NativeEditorBlock( + kind: .columns(columnsBlock), + text: AttributedString(columnsBlock.previewText), + alignment: .left, + rawNode: NativeEditorRichBlockNodeFactory.columnsNode(from: columnsBlock) + ), + lines.index(after: currentIndex) + ) + } + + guard let column = parsedColumnHTML(in: lines, startingAt: currentIndex) else { + return nil + } + columns.append(column.column) + currentIndex = column.endIndex + } + + return nil + } + + static func columnsMarkdown(from columns: NativeEditorColumnsBlock) -> String { + let columnTexts = normalizedColumnTexts(from: columns) + let columnWidths = normalizedColumnWidths(from: columns, columnCount: columnTexts.count) + let widthMode = columns.widthMode.isEmpty ? "normal" : columns.widthMode + let widthModeAttribute = widthMode == "normal" + ? "" + : #" data-width-mode="\#(escapedInlineHTMLAttribute(widthMode))""# + let layout = escapedInlineHTMLAttribute(columns.layout) + let openingTag = #"
+ """ + } + + private struct ParsedColumnHTML { + var text: String + var width: Double? + } + + private static func parsedColumnHTML( + in lines: [String], + startingAt index: Array.Index + ) -> (column: ParsedColumnHTML, endIndex: Array.Index)? { + guard let columnAttributes = divAttributes(from: lines[index], dataType: "column") else { + return nil + } + + var bodyLines: [String] = [] + var currentIndex = lines.index(after: index) + + while currentIndex < lines.endIndex { + let line = lines[currentIndex].trimmingCharacters(in: .whitespacesAndNewlines) + if line.localizedCaseInsensitiveCompare("
") == .orderedSame { + return ( + ParsedColumnHTML( + text: columnText(from: bodyLines), + width: columnAttributes["data-width"].flatMap(Double.init) + ), + lines.index(after: currentIndex) + ) + } + + bodyLines.append(lines[currentIndex]) + currentIndex = lines.index(after: currentIndex) + } + + return nil + } + + private static func nativeColumnsBlock( + from attributes: [String: String], + columns: [ParsedColumnHTML] + ) -> NativeEditorColumnsBlock { + let columnTexts = columns.map(\.text) + let columnWidths = columns.map(\.width) + return NativeEditorColumnsBlock( + layout: attributes["data-layout"].nonEmpty ?? "two_equal", + widthMode: attributes["data-width-mode"].nonEmpty ?? "normal", + columnCount: columnTexts.count, + previewText: columnTexts.joined(separator: " "), + columnTexts: columnTexts, + columnWidths: columnWidths + ) + } + + private static func divAttributes(from line: String, dataType: String) -> [String: String]? { + let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedLine.lowercased().hasPrefix(""), tagEnd > trimmedLine.startIndex else { + return nil + } + + let openingTag = String(trimmedLine[trimmedLine.startIndex.. String { + lines.compactMap(columnTextLine(from:)) + .joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func columnTextLine(from line: String) -> String? { + let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedLine.isEmpty == false else { return nil } + + let lowercasedLine = trimmedLine.lowercased() + if lowercasedLine.hasPrefix(""), + let openingEnd = trimmedLine.firstIndex(of: ">"), + let closingStart = lowercasedLine.range(of: "

", options: .backwards)?.lowerBound { + return unescapedInlineHTMLText(String(trimmedLine[trimmedLine.index(after: openingEnd).. [String] { + if columns.columnTexts.isEmpty == false { + return Array(columns.columnTexts.prefix(max(columns.columnCount, 1))) + } + + let columnCount = max(columns.columnCount, 1) + let firstColumnText = columns.previewText.trimmingCharacters(in: .whitespacesAndNewlines) + return (0.. [Double?] { + (0.. String { + let widthText = htmlNumber(width) + return """ +
+ \(escapedInlineHTMLText(text.trimmingCharacters(in: .whitespacesAndNewlines))) +
+ """ + } + + private static func htmlNumber(_ value: Double) -> String { + let text = String(value) + return text.hasSuffix(".0") ? String(text.dropLast(2)) : text + } +} + +private extension String { + var nonEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift index 206bb46..a292c46 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift @@ -13,6 +13,10 @@ extension NativeEditorMarkdownParser { return calloutBlock } + if let columnsBlock = columnsHTMLBlock(in: lines, startingAt: index) { + return columnsBlock + } + return detailsHTMLBlock(in: lines, startingAt: index) } @@ -382,13 +386,6 @@ extension NativeEditorMarkdownParser { """ } - private static func columnsMarkdown(from columns: NativeEditorColumnsBlock) -> String { - let columnTexts = columns.columnTexts.isEmpty ? [columns.previewText] : columns.columnTexts - return columnTexts.enumerated().map { index, text in - "### Column \(index + 1)\n\(text.trimmedMarkdownBlockText)" - }.joined(separator: "\n\n") - } - private static func transclusionSourceMarkdown(from source: NativeEditorTransclusionSourceBlock) -> String { if let identifier = source.identifier, identifier.isEmpty == false { return "\n\(source.previewText.trimmedMarkdownBlockText)" diff --git a/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift b/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift index ac0fe11..48fb5b3 100644 --- a/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift +++ b/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift @@ -149,6 +149,7 @@ nonisolated struct NativeEditorColumnsBlock: Equatable, Hashable, Sendable { var columnCount: Int var previewText: String var columnTexts: [String] = [] + var columnWidths: [Double?] = [] } nonisolated struct NativeEditorTransclusionSourceBlock: Equatable, Hashable, Sendable { diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+RichBlocks.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+RichBlocks.swift index 675f3a7..6f8c4f1 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+RichBlocks.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+RichBlocks.swift @@ -27,12 +27,19 @@ extension NativeRichEditorViewModel { func updateColumns(blockID: UUID, layout: String, widthMode: String, columnTexts: [String]) { updateRichBlock(blockID: blockID) { block in let normalizedColumnTexts = Self.normalizedColumnTexts(columnTexts) + let existingColumnWidths: [Double?] + if case .columns(let existingColumns) = block.kind { + existingColumnWidths = existingColumns.columnWidths + } else { + existingColumnWidths = [] + } let columns = NativeEditorColumnsBlock( layout: layout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "two_equal" : layout, widthMode: widthMode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "normal" : widthMode, columnCount: normalizedColumnTexts.count, previewText: normalizedColumnTexts.joined(separator: " "), - columnTexts: normalizedColumnTexts + columnTexts: normalizedColumnTexts, + columnWidths: Self.normalizedColumnWidths(existingColumnWidths, count: normalizedColumnTexts.count) ) block.kind = .columns(columns) block.text = AttributedString(columns.previewText) @@ -149,6 +156,12 @@ extension NativeRichEditorViewModel { let limitedTexts = Array(columnTexts.prefix(4)) return limitedTexts.isEmpty ? [""] : limitedTexts } + + private static func normalizedColumnWidths(_ columnWidths: [Double?], count: Int) -> [Double?] { + (0.. ProseMirrorNode { let columnTexts = normalizedColumnTexts(from: columns) + let columnWidths = normalizedColumnWidths(from: columns, columnCount: columnTexts.count) return ProseMirrorNode( type: "columns", attrs: [ "layout": .string(columns.layout), "widthMode": .string(columns.widthMode) ], - content: columnTexts.map(columnNode(text:)) + content: zip(columnTexts, columnWidths).map { columnNode(text: $0.0, width: $0.1) } ) } @@ -315,10 +329,10 @@ nonisolated enum NativeEditorRichBlockNodeFactory { ) } - private static func columnNode(text: String) -> ProseMirrorNode { + private static func columnNode(text: String, width: Double?) -> ProseMirrorNode { ProseMirrorNode( type: "column", - attrs: ["width": .int(1)], + attrs: ["width": proseMirrorNumber(from: width ?? 1)], content: [paragraphNode(text)] ) } @@ -332,4 +346,21 @@ nonisolated enum NativeEditorRichBlockNodeFactory { let firstColumnText = columns.previewText.trimmingCharacters(in: .whitespacesAndNewlines) return (0.. [Double?] { + (0.. ProseMirrorJSONValue { + if value.rounded() == value, let intValue = Int(exactly: value) { + return .int(intValue) + } + + return .double(value) + } } From 761cabc3a494d1ab81ab4dd9a8be63ee53584bad Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 16:59:59 +0100 Subject: [PATCH 044/201] fix: avoid columns helper visibility collision --- .../Editor/NativeEditorMarkdownParser+Columns.swift | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Columns.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Columns.swift index fa1bb41..31f6beb 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Columns.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Columns.swift @@ -100,8 +100,8 @@ extension NativeEditorMarkdownParser { let columnTexts = columns.map(\.text) let columnWidths = columns.map(\.width) return NativeEditorColumnsBlock( - layout: attributes["data-layout"].nonEmpty ?? "two_equal", - widthMode: attributes["data-width-mode"].nonEmpty ?? "normal", + layout: nonEmptyAttribute(attributes["data-layout"]) ?? "two_equal", + widthMode: nonEmptyAttribute(attributes["data-width-mode"]) ?? "normal", columnCount: columnTexts.count, previewText: columnTexts.joined(separator: " "), columnTexts: columnTexts, @@ -177,10 +177,11 @@ extension NativeEditorMarkdownParser { let text = String(value) return text.hasSuffix(".0") ? String(text.dropLast(2)) : text } -} -private extension String { - var nonEmpty: String? { - isEmpty ? nil : self + private static func nonEmptyAttribute(_ value: String?) -> String? { + guard let value, value.isEmpty == false else { + return nil + } + return value } } From f0742fadf15faee61c1c690e55d8424e2ce3d5a0 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 17:02:30 +0100 Subject: [PATCH 045/201] fix: type optional column width nil --- docmostly/Features/Editor/NativeEditorDocument+Payloads.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift b/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift index 8b7304c..4749c5e 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift @@ -222,7 +222,7 @@ nonisolated extension NativeEditorDocument { case .string(let width): Double(width) case .bool, .object, .array, .null: - nil + Optional.none } } From eb667b65772a1c12549d2a29b79abe295928f413 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 17:04:25 +0100 Subject: [PATCH 046/201] fix: return parsed column widths --- .../Features/Editor/NativeEditorDocument+Payloads.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift b/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift index 4749c5e..176b2b2 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift @@ -216,13 +216,13 @@ nonisolated extension NativeEditorDocument { switch value { case .int(let width): - Double(width) + return Double(width) case .double(let width): - width + return width case .string(let width): - Double(width) + return Double(width) case .bool, .object, .array, .null: - Optional.none + return nil } } From e083043a6f483fa9bfbd43ab6c69ff5adbe687e3 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 17:12:00 +0100 Subject: [PATCH 047/201] fix: close columns layout attribute --- .../Editor/NativeEditorMarkdownParser+Columns.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Columns.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Columns.swift index 31f6beb..0cb50f3 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Columns.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Columns.swift @@ -46,7 +46,7 @@ extension NativeEditorMarkdownParser { ? "" : #" data-width-mode="\#(escapedInlineHTMLAttribute(widthMode))""# let layout = escapedInlineHTMLAttribute(columns.layout) - let openingTag = #"
"# let columnMarkup = zip(columnTexts, columnWidths).map { text, width in columnMarkdown(text: text, width: width ?? 1) }.joined(separator: "\n") @@ -138,8 +138,9 @@ extension NativeEditorMarkdownParser { let lowercasedLine = trimmedLine.lowercased() if lowercasedLine.hasPrefix(""), let openingEnd = trimmedLine.firstIndex(of: ">"), - let closingStart = lowercasedLine.range(of: "

", options: .backwards)?.lowerBound { - return unescapedInlineHTMLText(String(trimmedLine[trimmedLine.index(after: openingEnd)..", options: [.caseInsensitive, .backwards]) { + let contentStart = trimmedLine.index(after: openingEnd) + return unescapedInlineHTMLText(String(trimmedLine[contentStart.. Date: Sun, 28 Jun 2026 17:22:31 +0100 Subject: [PATCH 048/201] fix: import legacy page breaks --- .../NativeEditorMarkdownParser+PageBreaks.swift | 16 +++++++++++----- .../Editor/NativeEditorMarkdownImportTests.swift | 9 +++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+PageBreaks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+PageBreaks.swift index 5e45869..794501f 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+PageBreaks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+PageBreaks.swift @@ -4,10 +4,7 @@ extension NativeEditorMarkdownParser { static func pageBreakHTMLBlock(from line: String) -> NativeEditorBlock? { let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) let lowercasedLine = trimmedLine.lowercased() - guard - lowercasedLine.hasPrefix(" Bool { + if attributes["data-type"]?.localizedCaseInsensitiveCompare("pageBreak") == .orderedSame { + return true + } + + let style = attributes["style"]?.lowercased() ?? "" + return style.contains("page-break-after") && style.contains("always") + } } diff --git a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift index 36f0f88..a62818f 100644 --- a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift +++ b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift @@ -128,6 +128,15 @@ struct NativeEditorMarkdownImportTests { #expect(block.rawNode?.type == "pageBreak") } + @Test func legacyPageBreakHTMLImportsAsNativePageBreakBlock() throws { + let block = try #require(NativeEditorMarkdownParser.blocks( + from: #"
"# + ).first) + + #expect(block.kind == .pageBreak) + #expect(block.rawNode?.type == "pageBreak") + } + @Test func docmostColumnsHTMLImportsAsNativeColumnsBlock() throws { let markdown = """
From 003e7c626e26594b3189ccf38b25b854f6aaad61 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 17:33:27 +0100 Subject: [PATCH 049/201] test: cover diagram markdown fidelity --- .../NativeEditorMarkdownImportTests.swift | 102 +++++++++++++++++ .../NativeEditorRichMarkdownExportTests.swift | 107 ++++++++++++++++++ 2 files changed, 209 insertions(+) diff --git a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift index a62818f..fbd395e 100644 --- a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift +++ b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift @@ -168,6 +168,64 @@ struct NativeEditorMarkdownImportTests { #expect(NativeEditorMarkdownParser.markdown(from: [block]) == markdown) } + @Test func docmostDiagramHTMLImportsAsNativeDiagramBlocks() throws { + let drawioSource = "/api/files/drawio-1/diagram.drawio.svg" + let excalidrawSource = "/api/files/excalidraw-1/sketch.png" + let markdown = [ + docmostDiagramHTML( + type: "drawio", + source: drawioSource, + title: "System map", + alternativeText: "System diagram", + attachmentID: "drawio-1", + size: "2048", + width: "640", + height: "360", + aspectRatio: "1.7777778", + alignment: "center" + ), + docmostDiagramHTML( + type: "excalidraw", + source: excalidrawSource, + title: "Sketch", + alternativeText: "Whiteboard sketch", + attachmentID: "excalidraw-1", + width: "75%", + alignment: "right" + ) + ].joined(separator: "\n") + let blocks = NativeEditorMarkdownParser.blocks(from: markdown) + + try #require(blocks.count == 2) + guard case .drawio(let drawio) = blocks[0].kind else { + Issue.record("Expected Docmost draw.io HTML to import as a native draw.io block.") + return + } + guard case .excalidraw(let excalidraw) = blocks[1].kind else { + Issue.record("Expected Docmost Excalidraw HTML to import as a native Excalidraw block.") + return + } + + #expect(drawio.source == drawioSource) + #expect(drawio.title == "System map") + #expect(drawio.alternativeText == "System diagram") + #expect(drawio.attachmentID == "drawio-1") + #expect(drawio.sizeInBytes == 2_048) + #expect(drawio.width == "640") + #expect(drawio.height == "360") + #expect(drawio.aspectRatio == "1.7777778") + #expect(drawio.alignment == "center") + #expect(blocks[0].rawNode?.type == "drawio") + #expect(blocks[0].rawNode?.attrs?["src"] == .string(drawioSource)) + #expect(blocks[0].rawNode?.attrs?["width"] == .int(640)) + #expect(blocks[0].rawNode?.attrs?["attachmentId"] == .string("drawio-1")) + #expect(excalidraw.source == excalidrawSource) + #expect(excalidraw.width == "75%") + #expect(blocks[1].rawNode?.type == "excalidraw") + #expect(blocks[1].rawNode?.attrs?["width"] == .string("75%")) + #expect(NativeEditorMarkdownParser.markdown(from: blocks) == markdown) + } + @Test func docmostIframeMarkdownLinksImportAsEmbedBlocks() throws { let source = "https://player.example.com/embed/demo" let block = try #require(NativeEditorMarkdownParser.blocks(from: "[\(source)](\(source))").first) @@ -190,4 +248,48 @@ struct NativeEditorMarkdownImportTests { #expect(block.kind == .paragraph) #expect(String(block.text.characters) == "Example") } + + private func docmostDiagramHTML( + type: String, + source: String, + title: String, + alternativeText: String, + attachmentID: String, + size: String? = nil, + width: String? = nil, + height: String? = nil, + aspectRatio: String? = nil, + alignment: String? = nil + ) -> String { + let openingTag = htmlTag("div", attributes: [ + ("data-type", type), + ("data-src", source), + ("data-title", title), + ("data-alt", alternativeText), + ("data-width", width), + ("data-height", height), + ("data-size", size), + ("data-aspect-ratio", aspectRatio), + ("data-align", alignment), + ("data-attachment-id", attachmentID) + ]) + let imageTag = htmlTag("img", attributes: [ + ("src", source), + ("alt", alternativeText), + ("width", width) + ]) + + return """ + \(openingTag) + \(imageTag) +
+ """ + } + + private func htmlTag(_ name: String, attributes: [(String, String?)]) -> String { + let attributeText = attributes.compactMap { key, value -> String? in + value.map { #"\#(key)="\#($0)""# } + }.joined(separator: " ") + return "<\(name) \(attributeText)>" + } } diff --git a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift index e4ff64f..f96fee0 100644 --- a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift +++ b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift @@ -87,6 +87,69 @@ struct NativeEditorRichMarkdownExportTests { """) } + @Test func documentMarkdownConversionPreservesDocmostDiagramHTMLShape() { + let drawioSource = "/api/files/drawio-1/diagram.drawio.svg" + let excalidrawSource = "/api/files/excalidraw-1/sketch.png" + let viewModel = configuredViewModel(blocks: [ + NativeEditorBlock( + kind: .drawio(NativeEditorDiagramBlock( + source: drawioSource, + title: "System map", + alternativeText: "System diagram", + attachmentID: "drawio-1", + sizeInBytes: 2_048, + width: "640", + height: "360", + aspectRatio: "1.7777778", + alignment: "center" + )), + text: AttributedString("System map"), + alignment: .left + ), + NativeEditorBlock( + kind: .excalidraw(NativeEditorDiagramBlock( + source: excalidrawSource, + title: "Sketch", + alternativeText: "Whiteboard sketch", + attachmentID: "excalidraw-1", + sizeInBytes: nil, + width: "75%", + height: nil, + aspectRatio: nil, + alignment: "right" + )), + text: AttributedString("Sketch"), + alignment: .left + ) + ]) + + let expected = [ + docmostDiagramHTML( + type: "drawio", + source: drawioSource, + title: "System map", + alternativeText: "System diagram", + attachmentID: "drawio-1", + size: "2048", + width: "640", + height: "360", + aspectRatio: "1.7777778", + alignment: "center" + ), + docmostDiagramHTML( + type: "excalidraw", + source: excalidrawSource, + title: "Sketch", + alternativeText: "Whiteboard sketch", + attachmentID: "excalidraw-1", + width: "75%", + alignment: "right" + ) + ].joined(separator: "\n") + + #expect(viewModel.markdownForDocument() == expected) + } + @Test func documentMarkdownConversionPreservesRichBlockMeaning() { let viewModel = configuredViewModel(blocks: richMarkdownFixtureBlocks()) @@ -120,6 +183,50 @@ struct NativeEditorRichMarkdownExportTests { return viewModel } + private func docmostDiagramHTML( + type: String, + source: String, + title: String, + alternativeText: String, + attachmentID: String, + size: String? = nil, + width: String? = nil, + height: String? = nil, + aspectRatio: String? = nil, + alignment: String? = nil + ) -> String { + let openingTag = htmlTag("div", attributes: [ + ("data-type", type), + ("data-src", source), + ("data-title", title), + ("data-alt", alternativeText), + ("data-width", width), + ("data-height", height), + ("data-size", size), + ("data-aspect-ratio", aspectRatio), + ("data-align", alignment), + ("data-attachment-id", attachmentID) + ]) + let imageTag = htmlTag("img", attributes: [ + ("src", source), + ("alt", alternativeText), + ("width", width) + ]) + + return """ + \(openingTag) + \(imageTag) +
+ """ + } + + private func htmlTag(_ name: String, attributes: [(String, String?)]) -> String { + let attributeText = attributes.compactMap { key, value -> String? in + value.map { #"\#(key)="\#($0)""# } + }.joined(separator: " ") + return "<\(name) \(attributeText)>" + } + private func richMarkdownFixtureBlocks() -> [NativeEditorBlock] { [ imageMarkdownFixtureBlock(), From 98adb75a9cf1945e40e62ef9f0c1cbf70b7b2b90 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 17:42:20 +0100 Subject: [PATCH 050/201] fix: preserve diagram markdown fidelity --- .../NativeEditorMarkdownParser+Embeds.swift | 128 ++++++++++++++++++ ...ativeEditorMarkdownParser+RichBlocks.swift | 17 +-- ...NativeRichEditorViewModel+RichBlocks.swift | 17 ++- 3 files changed, 148 insertions(+), 14 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift index 5e0f5d6..3f07827 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift @@ -1,6 +1,44 @@ import Foundation extension NativeEditorMarkdownParser { + static func diagramHTMLBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + guard + let attributes = htmlTagAttributes(from: lines[index], tagName: "div"), + let type = diagramType(from: attributes["data-type"]) + else { + return nil + } + + var imageAttributes: [String: String] = [:] + var currentIndex = lines.index(after: index) + + while currentIndex < lines.endIndex { + let line = lines[currentIndex].trimmingCharacters(in: .whitespacesAndNewlines) + if let attrs = htmlTagAttributes(from: line, tagName: "img") { + imageAttributes = attrs + } + if line.localizedCaseInsensitiveCompare("") == .orderedSame { + let diagram = diagramBlock(from: attributes, imageAttributes: imageAttributes) + let kind = diagramKind(type: type, diagram: diagram) + return ( + NativeEditorBlock( + kind: kind, + text: AttributedString(NativeEditorDocument.previewText(for: kind)), + alignment: .left, + rawNode: NativeEditorRichBlockNodeFactory.diagramNode(from: diagram, type: type) + ), + lines.index(after: currentIndex) + ) + } + currentIndex = lines.index(after: currentIndex) + } + + return nil + } + static func iframeEmbedMarkdownBlock(from line: String) -> NativeEditorBlock? { guard let link = iframeMarkdownLink(from: line), isWebEmbedSource(link.source) else { return nil @@ -21,6 +59,32 @@ extension NativeEditorMarkdownParser { ) } + static func diagramMarkdown(from diagram: NativeEditorDiagramBlock, type: String) -> String { + let openingTag = htmlTag("div", attributes: [ + ("data-type", type), + ("data-src", diagram.source), + ("data-title", diagram.title), + ("data-alt", diagram.alternativeText), + ("data-width", diagram.width), + ("data-height", diagram.height), + ("data-size", diagram.sizeInBytes.map(String.init)), + ("data-aspect-ratio", diagram.aspectRatio), + ("data-align", diagram.alignment), + ("data-attachment-id", diagram.attachmentID) + ]) + let imageTag = htmlTag("img", attributes: [ + ("src", diagram.source), + ("alt", diagram.alternativeText ?? diagram.title), + ("width", diagram.width) + ]) + + return """ + \(openingTag) + \(imageTag) + + """ + } + private static func iframeMarkdownLink(from line: String) -> (label: String, source: String)? { guard line.hasPrefix("["), let closeLabelIndex = line.firstIndex(of: "]") else { return nil @@ -83,4 +147,68 @@ extension NativeEditorMarkdownParser { return true } + + private static func htmlTag(_ name: String, attributes: [(String, String?)]) -> String { + let attributeText = attributes.compactMap { key, value -> String? in + guard let value = nonEmptyHTMLAttribute(value) else { return nil } + return #"\#(key)="\#(escapedInlineHTMLAttribute(value))""# + }.joined(separator: " ") + + return attributeText.isEmpty ? "<\(name)>" : "<\(name) \(attributeText)>" + } + + private static func htmlTagAttributes(from line: String, tagName: String) -> [String: String]? { + let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedLine.lowercased().hasPrefix("<\(tagName.lowercased())") else { return nil } + guard let tagEnd = trimmedLine.firstIndex(of: ">"), tagEnd > trimmedLine.startIndex else { + return nil + } + + let openingTag = String(trimmedLine[trimmedLine.startIndex.. String? { + guard let dataType else { return nil } + if dataType.localizedCaseInsensitiveCompare("drawio") == .orderedSame { + return "drawio" + } + if dataType.localizedCaseInsensitiveCompare("excalidraw") == .orderedSame { + return "excalidraw" + } + return nil + } + + private static func diagramBlock( + from attributes: [String: String], + imageAttributes: [String: String] + ) -> NativeEditorDiagramBlock { + let source = nonEmptyHTMLAttribute(attributes["data-src"]) ?? nonEmptyHTMLAttribute(imageAttributes["src"]) + return NativeEditorDiagramBlock( + source: source, + title: nonEmptyHTMLAttribute(attributes["data-title"]), + alternativeText: nonEmptyHTMLAttribute(attributes["data-alt"]), + attachmentID: diagramAttachmentID(from: attributes, source: source), + sizeInBytes: nonEmptyHTMLAttribute(attributes["data-size"]).flatMap(Int.init), + width: nonEmptyHTMLAttribute(attributes["data-width"]) ?? nonEmptyHTMLAttribute(imageAttributes["width"]), + height: nonEmptyHTMLAttribute(attributes["data-height"]), + aspectRatio: nonEmptyHTMLAttribute(attributes["data-aspect-ratio"]), + alignment: nonEmptyHTMLAttribute(attributes["data-align"]) + ) + } + + private static func diagramKind(type: String, diagram: NativeEditorDiagramBlock) -> NativeEditorBlockKind { + type == "drawio" ? .drawio(diagram) : .excalidraw(diagram) + } + + private static func diagramAttachmentID(from attributes: [String: String], source: String?) -> String? { + nonEmptyHTMLAttribute(attributes["data-attachment-id"]) ?? source.flatMap(docmostAttachmentID) + } + + private static func nonEmptyHTMLAttribute(_ value: String?) -> String? { + guard let value, value.isEmpty == false else { + return nil + } + return value + } } diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift index a292c46..aafda92 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift @@ -13,6 +13,10 @@ extension NativeEditorMarkdownParser { return calloutBlock } + if let diagramBlock = diagramHTMLBlock(in: lines, startingAt: index) { + return diagramBlock + } + if let columnsBlock = columnsHTMLBlock(in: lines, startingAt: index) { return columnsBlock } @@ -80,9 +84,9 @@ extension NativeEditorMarkdownParser { case .embed(let embed): embedMarkdown(from: embed) case .drawio(let diagram): - diagramMarkdown(from: diagram, fallbackTitle: "Draw.io diagram") + diagramMarkdown(from: diagram, type: "drawio") case .excalidraw(let diagram): - diagramMarkdown(from: diagram, fallbackTitle: "Excalidraw diagram") + diagramMarkdown(from: diagram, type: "excalidraw") case .mathBlock(let math): mathMarkdown(from: math) case .unsupported: @@ -401,13 +405,6 @@ extension NativeEditorMarkdownParser { return "" } - private static func diagramMarkdown(from diagram: NativeEditorDiagramBlock, fallbackTitle: String) -> String { - linkMarkdown( - title: diagram.title ?? diagram.alternativeText ?? diagram.source ?? fallbackTitle, - url: diagram.source - ) - } - private static func embedMarkdown(from embed: NativeEditorEmbedBlock) -> String { if embed.provider == "iframe", let source = embed.source, source.isEmpty == false { return linkMarkdown(title: source, url: source) @@ -494,7 +491,7 @@ extension NativeEditorMarkdownParser { return fileExtension } - private static func docmostAttachmentID(from source: String) -> String? { + static func docmostAttachmentID(from source: String) -> String? { let pathComponents = markdownLinkPath(from: source) .split(separator: "/", omittingEmptySubsequences: true) .map(String.init) diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+RichBlocks.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+RichBlocks.swift index 6f8c4f1..640deac 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+RichBlocks.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+RichBlocks.swift @@ -306,11 +306,11 @@ nonisolated enum NativeEditorRichBlockNodeFactory { if let sizeInBytes = diagram.sizeInBytes { attrs["size"] = .int(sizeInBytes) } - if let width = diagram.width.flatMap(Int.init) { - attrs["width"] = .int(width) + if let width = diagram.width.flatMap(proseMirrorDiagramDimension(from:)) { + attrs["width"] = width } - if let height = diagram.height.flatMap(Int.init) { - attrs["height"] = .int(height) + if let height = diagram.height.flatMap(proseMirrorDiagramDimension(from:)) { + attrs["height"] = height } if let aspectRatio = diagram.aspectRatio.flatMap(Double.init) { attrs["aspectRatio"] = .double(aspectRatio) @@ -363,4 +363,13 @@ nonisolated enum NativeEditorRichBlockNodeFactory { return .double(value) } + + private static func proseMirrorDiagramDimension(from value: String) -> ProseMirrorJSONValue? { + let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedValue.isEmpty == false else { return nil } + if let number = Double(trimmedValue) { + return proseMirrorNumber(from: number) + } + return .string(trimmedValue) + } } From 38e4593a144cf044d39e974a0896a57497e5003a Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 17:49:28 +0100 Subject: [PATCH 051/201] test: cover columns normalization fidelity --- .../NativeEditorRichMarkdownExportTests.swift | 31 ++++++++++++++ ...NativeRichEditorStructuralBlockTests.swift | 41 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift index f96fee0..b48123b 100644 --- a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift +++ b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift @@ -87,6 +87,37 @@ struct NativeEditorRichMarkdownExportTests { """) } + @Test func documentMarkdownConversionPadsColumnsToColumnCount() { + let viewModel = configuredViewModel(blocks: [ + NativeEditorBlock( + kind: .columns(NativeEditorColumnsBlock( + layout: "three_equal", + widthMode: "normal", + columnCount: 3, + previewText: "Plan", + columnTexts: ["Plan"], + columnWidths: [2] + )), + text: AttributedString("Plan"), + alignment: .left + ) + ]) + + #expect(viewModel.markdownForDocument() == """ +
+
+ Plan +
+
+ +
+
+ +
+
+ """) + } + @Test func documentMarkdownConversionPreservesDocmostDiagramHTMLShape() { let drawioSource = "/api/files/drawio-1/diagram.drawio.svg" let excalidrawSource = "/api/files/excalidraw-1/sketch.png" diff --git a/docmostlyTests/Editor/NativeRichEditorStructuralBlockTests.swift b/docmostlyTests/Editor/NativeRichEditorStructuralBlockTests.swift index 0ba284d..5fe558a 100644 --- a/docmostlyTests/Editor/NativeRichEditorStructuralBlockTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorStructuralBlockTests.swift @@ -25,6 +25,47 @@ struct NativeRichEditorStructuralBlockTests { #expect(node.content?[2].content?.first?.content?.first?.text == "Ship") } + @Test func columnsNodePadsTextsAndWidthsToColumnCount() { + let columns = NativeEditorColumnsBlock( + layout: "three_equal", + widthMode: "normal", + columnCount: 3, + previewText: "Plan", + columnTexts: ["Plan"], + columnWidths: [2] + ) + let node = NativeEditorRichBlockNodeFactory.columnsNode(from: columns) + + #expect(node.content?.count == 3) + #expect(node.content?[0].attrs?["width"] == .int(2)) + #expect(node.content?[1].attrs?["width"] == .int(1)) + #expect(node.content?[2].attrs?["width"] == .int(1)) + #expect(node.content?[0].content?.first?.content?.first?.text == "Plan") + #expect(node.content?[1].content?.first?.content?.first?.text == nil) + #expect(node.content?[2].content?.first?.content?.first?.text == nil) + } + + @Test func columnsBlockEqualityNormalizesMissingWidths() { + let columnsWithoutWidths = NativeEditorColumnsBlock( + layout: "three_equal", + widthMode: "normal", + columnCount: 3, + previewText: "Plan", + columnTexts: ["Plan"] + ) + let columnsWithNilWidths = NativeEditorColumnsBlock( + layout: "three_equal", + widthMode: "normal", + columnCount: 3, + previewText: "Plan", + columnTexts: ["Plan"], + columnWidths: [nil, nil, nil] + ) + + #expect(columnsWithoutWidths == columnsWithNilWidths) + #expect(Set([columnsWithoutWidths, columnsWithNilWidths]).count == 1) + } + @Test func updatesSyncedBlockIdentifiers() { let viewModel = structuralBlockViewModel() let sourceID = viewModel.document.blocks[1].id From 0ab7c29643a1d08f7777334a522d9e6cb9c5e1b8 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 17:57:17 +0100 Subject: [PATCH 052/201] fix: normalize column block widths --- .../NativeEditorMarkdownParser+Columns.swift | 23 +---------- .../NativeEditorRichBlockPayloads.swift | 41 ++++++++++++++++++- ...NativeRichEditorViewModel+RichBlocks.swift | 23 +---------- 3 files changed, 44 insertions(+), 43 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Columns.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Columns.swift index 0cb50f3..9332f26 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Columns.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Columns.swift @@ -39,8 +39,8 @@ extension NativeEditorMarkdownParser { } static func columnsMarkdown(from columns: NativeEditorColumnsBlock) -> String { - let columnTexts = normalizedColumnTexts(from: columns) - let columnWidths = normalizedColumnWidths(from: columns, columnCount: columnTexts.count) + let columnTexts = columns.normalizedColumnTexts + let columnWidths = columns.normalizedColumnWidths let widthMode = columns.widthMode.isEmpty ? "normal" : columns.widthMode let widthModeAttribute = widthMode == "normal" ? "" @@ -146,25 +146,6 @@ extension NativeEditorMarkdownParser { return unescapedInlineHTMLText(trimmedLine) } - private static func normalizedColumnTexts(from columns: NativeEditorColumnsBlock) -> [String] { - if columns.columnTexts.isEmpty == false { - return Array(columns.columnTexts.prefix(max(columns.columnCount, 1))) - } - - let columnCount = max(columns.columnCount, 1) - let firstColumnText = columns.previewText.trimmingCharacters(in: .whitespacesAndNewlines) - return (0.. [Double?] { - (0.. String { let widthText = htmlNumber(width) return """ diff --git a/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift b/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift index 48fb5b3..ad72abc 100644 --- a/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift +++ b/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift @@ -143,13 +143,52 @@ nonisolated struct NativeEditorDetailsBlock: Equatable, Hashable, Sendable { var isOpen: Bool } -nonisolated struct NativeEditorColumnsBlock: Equatable, Hashable, Sendable { +nonisolated struct NativeEditorColumnsBlock: Hashable, Sendable { var layout: String var widthMode: String var columnCount: Int var previewText: String var columnTexts: [String] = [] var columnWidths: [Double?] = [] + + var normalizedColumnTexts: [String] { + if columnTexts.isEmpty == false { + return normalizedValues(columnTexts, fallback: "") + } + + let firstColumnText = previewText.trimmingCharacters(in: .whitespacesAndNewlines) + return (0...none) + } + + static func == (lhs: NativeEditorColumnsBlock, rhs: NativeEditorColumnsBlock) -> Bool { + lhs.layout == rhs.layout && + lhs.widthMode == rhs.widthMode && + lhs.previewText == rhs.previewText && + lhs.normalizedColumnTexts == rhs.normalizedColumnTexts && + lhs.normalizedColumnWidths == rhs.normalizedColumnWidths + } + + func hash(into hasher: inout Hasher) { + hasher.combine(layout) + hasher.combine(widthMode) + hasher.combine(previewText) + hasher.combine(normalizedColumnTexts) + hasher.combine(normalizedColumnWidths) + } + + private var normalizedColumnCount: Int { + max(columnCount, 1) + } + + private func normalizedValues(_ values: [Value], fallback: Value) -> [Value] { + (0.. ProseMirrorNode { - let columnTexts = normalizedColumnTexts(from: columns) - let columnWidths = normalizedColumnWidths(from: columns, columnCount: columnTexts.count) + let columnTexts = columns.normalizedColumnTexts + let columnWidths = columns.normalizedColumnWidths return ProseMirrorNode( type: "columns", attrs: [ @@ -337,25 +337,6 @@ nonisolated enum NativeEditorRichBlockNodeFactory { ) } - private static func normalizedColumnTexts(from columns: NativeEditorColumnsBlock) -> [String] { - if columns.columnTexts.isEmpty == false { - return Array(columns.columnTexts.prefix(max(columns.columnCount, 1))) - } - - let columnCount = max(columns.columnCount, 1) - let firstColumnText = columns.previewText.trimmingCharacters(in: .whitespacesAndNewlines) - return (0.. [Double?] { - (0.. ProseMirrorJSONValue { if value.rounded() == value, let intValue = Int(exactly: value) { return .int(intValue) From 2cbe1ed09735142eb96b5e29ae7f009fa7b25dbb Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 18:10:14 +0100 Subject: [PATCH 053/201] test: cover media html fidelity --- .../NativeEditorMediaHTMLFidelityTests.swift | 336 ++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 docmostlyTests/Editor/NativeEditorMediaHTMLFidelityTests.swift diff --git a/docmostlyTests/Editor/NativeEditorMediaHTMLFidelityTests.swift b/docmostlyTests/Editor/NativeEditorMediaHTMLFidelityTests.swift new file mode 100644 index 0000000..20b2fbf --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorMediaHTMLFidelityTests.swift @@ -0,0 +1,336 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorMediaHTMLFidelityTests { + @Test func exportsDocmostMediaAndEmbedHTMLShapes() { + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: nativeBlocks()) + viewModel.resetEditingHistory() + + #expect(viewModel.markdownForDocument() == docmostHTMLMarkdown()) + } + + @Test func importsDocmostMediaAndEmbedHTMLAsTypedNativeBlocks() throws { + let markdown = docmostHTMLMarkdown() + let blocks = NativeEditorMarkdownParser.blocks(from: markdown) + + try #require(blocks.count == 6) + verifyImage(blocks[0]) + verifyVideo(blocks[1]) + verifyAudio(blocks[2]) + verifyPDF(blocks[3]) + verifyAttachment(blocks[4]) + verifyEmbed(blocks[5]) + #expect(NativeEditorMarkdownParser.markdown(from: blocks) == markdown) + } + + private func nativeBlocks() -> [NativeEditorBlock] { + [ + NativeEditorBlock(kind: .image(imageBlock()), text: AttributedString("Hero"), alignment: .left), + NativeEditorBlock(kind: .video(videoBlock()), text: AttributedString("Launch demo"), alignment: .left), + NativeEditorBlock(kind: .audio(audioBlock()), text: AttributedString("Audio"), alignment: .left), + NativeEditorBlock(kind: .pdf(pdfBlock()), text: AttributedString("Spec.pdf"), alignment: .left), + NativeEditorBlock( + kind: .attachment(attachmentBlock()), + text: AttributedString("Archive.zip"), + alignment: .left + ), + NativeEditorBlock(kind: .embed(embedBlock()), text: AttributedString("Figma"), alignment: .left) + ] + } + + private func imageBlock() -> NativeEditorMediaBlock { + NativeEditorMediaBlock( + source: "/api/files/image-1/Hero.png", + alternativeText: "Hero", + title: nil, + attachmentID: "image-1", + sizeInBytes: 2_048, + width: "640", + height: "360", + aspectRatio: "1.7777778", + alignment: "center" + ) + } + + private func videoBlock() -> NativeEditorMediaBlock { + NativeEditorMediaBlock( + source: "/api/files/video-1/Demo.mp4", + alternativeText: "Launch demo", + title: nil, + attachmentID: "video-1", + sizeInBytes: 8_192, + width: "75%", + height: "360", + aspectRatio: "1.7777778", + alignment: "right" + ) + } + + private func audioBlock() -> NativeEditorMediaBlock { + NativeEditorMediaBlock( + source: "/api/files/audio-1/Briefing.m4a", + alternativeText: nil, + title: nil, + attachmentID: "audio-1", + sizeInBytes: 4_096, + width: nil, + height: nil, + aspectRatio: nil, + alignment: nil + ) + } + + private func pdfBlock() -> NativeEditorPDFBlock { + NativeEditorPDFBlock( + source: "/api/files/pdf-1/Spec.pdf", + name: "Spec.pdf", + attachmentID: "pdf-1", + sizeInBytes: 16_384, + width: "800", + height: "600" + ) + } + + private func attachmentBlock() -> NativeEditorAttachmentBlock { + NativeEditorAttachmentBlock( + url: "/api/files/file-1/Archive.zip", + name: "Archive.zip", + mimeType: "application/zip", + sizeInBytes: 1_024, + attachmentID: "file-1" + ) + } + + private func embedBlock() -> NativeEditorEmbedBlock { + NativeEditorEmbedBlock( + source: "https://www.figma.com/file/demo", + provider: "Figma", + alignment: "center", + width: "800", + height: "600" + ) + } + + private func docmostHTMLMarkdown() -> String { + [ + imageHTML(), + videoHTML(), + audioHTML(), + pdfHTML(), + attachmentHTML(), + embedHTML() + ].joined(separator: "\n") + } + + private func imageHTML() -> String { + htmlTag("img", attributes: [ + ("src", "/api/files/image-1/Hero.png"), + ("alt", "Hero"), + ("width", "640"), + ("height", "360"), + ("data-align", "center"), + ("data-attachment-id", "image-1"), + ("data-size", "2048"), + ("data-aspect-ratio", "1.7777778") + ]) + } + + private func videoHTML() -> String { + """ + \(htmlTag("video", attributes: videoAttributes())) + + + """ + } + + private func videoAttributes() -> [(String, String?)] { + [ + ("controls", "true"), + ("src", "/api/files/video-1/Demo.mp4"), + ("aria-label", "Launch demo"), + ("data-attachment-id", "video-1"), + ("width", "75%"), + ("height", "360"), + ("data-size", "8192"), + ("data-align", "right"), + ("data-aspect-ratio", "1.7777778") + ] + } + + private func audioHTML() -> String { + """ + \(htmlTag("audio", attributes: audioAttributes())) + + + """ + } + + private func audioAttributes() -> [(String, String?)] { + [ + ("controls", "true"), + ("preload", "metadata"), + ("src", "/api/files/audio-1/Briefing.m4a"), + ("data-attachment-id", "audio-1"), + ("data-size", "4096") + ] + } + + private func pdfHTML() -> String { + """ + \(htmlTag("div", attributes: pdfContainerAttributes())) + + + """ + } + + private func pdfContainerAttributes() -> [(String, String?)] { + [ + ("data-type", "pdf"), + ("src", "/api/files/pdf-1/Spec.pdf"), + ("data-name", "Spec.pdf"), + ("data-attachment-id", "pdf-1"), + ("data-size", "16384"), + ("width", "800"), + ("height", "600") + ] + } + + private func attachmentHTML() -> String { + """ + \(htmlTag("div", attributes: attachmentContainerAttributes())) + Archive.zip + + """ + } + + private func attachmentContainerAttributes() -> [(String, String?)] { + [ + ("data-type", "attachment"), + ("data-attachment-url", "/api/files/file-1/Archive.zip"), + ("data-attachment-name", "Archive.zip"), + ("data-attachment-mime", "application/zip"), + ("data-attachment-size", "1024"), + ("data-attachment-id", "file-1") + ] + } + + private func embedHTML() -> String { + """ + \(htmlTag("div", attributes: embedContainerAttributes())) + https://www.figma.com/file/demo + + """ + } + + private func embedContainerAttributes() -> [(String, String?)] { + [ + ("data-type", "embed"), + ("data-src", "https://www.figma.com/file/demo"), + ("data-provider", "Figma"), + ("data-align", "center"), + ("data-width", "800"), + ("data-height", "600") + ] + } + + private func verifyImage(_ block: NativeEditorBlock) { + guard case .image(let image) = block.kind else { + Issue.record("Expected Docmost image HTML to import as a native image block.") + return + } + + #expect(image.source == "/api/files/image-1/Hero.png") + #expect(image.alternativeText == "Hero") + #expect(image.attachmentID == "image-1") + #expect(image.sizeInBytes == 2_048) + #expect(image.width == "640") + #expect(image.height == "360") + #expect(image.aspectRatio == "1.7777778") + #expect(image.alignment == "center") + #expect(block.rawNode?.type == "image") + #expect(block.rawNode?.attrs?["width"] == .int(640)) + } + + private func verifyVideo(_ block: NativeEditorBlock) { + guard case .video(let video) = block.kind else { + Issue.record("Expected Docmost video HTML to import as a native video block.") + return + } + + #expect(video.source == "/api/files/video-1/Demo.mp4") + #expect(video.alternativeText == "Launch demo") + #expect(video.attachmentID == "video-1") + #expect(video.sizeInBytes == 8_192) + #expect(video.width == "75%") + #expect(video.height == "360") + #expect(video.aspectRatio == "1.7777778") + #expect(video.alignment == "right") + #expect(block.rawNode?.type == "video") + #expect(block.rawNode?.attrs?["width"] == .string("75%")) + } + + private func verifyAudio(_ block: NativeEditorBlock) { + guard case .audio(let audio) = block.kind else { + Issue.record("Expected Docmost audio HTML to import as a native audio block.") + return + } + + #expect(audio.source == "/api/files/audio-1/Briefing.m4a") + #expect(audio.attachmentID == "audio-1") + #expect(audio.sizeInBytes == 4_096) + #expect(block.rawNode?.type == "audio") + } + + private func verifyPDF(_ block: NativeEditorBlock) { + guard case .pdf(let pdf) = block.kind else { + Issue.record("Expected Docmost PDF HTML to import as a native PDF block.") + return + } + + #expect(pdf.source == "/api/files/pdf-1/Spec.pdf") + #expect(pdf.name == "Spec.pdf") + #expect(pdf.attachmentID == "pdf-1") + #expect(pdf.sizeInBytes == 16_384) + #expect(pdf.width == "800") + #expect(pdf.height == "600") + #expect(block.rawNode?.type == "pdf") + } + + private func verifyAttachment(_ block: NativeEditorBlock) { + guard case .attachment(let attachment) = block.kind else { + Issue.record("Expected Docmost attachment HTML to import as a native attachment block.") + return + } + + #expect(attachment.url == "/api/files/file-1/Archive.zip") + #expect(attachment.name == "Archive.zip") + #expect(attachment.mimeType == "application/zip") + #expect(attachment.sizeInBytes == 1_024) + #expect(attachment.attachmentID == "file-1") + #expect(block.rawNode?.type == "attachment") + } + + private func verifyEmbed(_ block: NativeEditorBlock) { + guard case .embed(let embed) = block.kind else { + Issue.record("Expected Docmost embed HTML to import as a native embed block.") + return + } + + #expect(embed.source == "https://www.figma.com/file/demo") + #expect(embed.provider == "Figma") + #expect(embed.alignment == "center") + #expect(embed.width == "800") + #expect(embed.height == "600") + #expect(block.rawNode?.type == "embed") + } + + private func htmlTag(_ name: String, attributes: [(String, String?)]) -> String { + let attributeText = attributes.compactMap { key, value -> String? in + value.map { #"\#(key)="\#($0)""# } + }.joined(separator: " ") + return "<\(name) \(attributeText)>" + } +} From 063c25ab1c7a5cd3bd04957fdc6c1952ca38425a Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 18:30:13 +0100 Subject: [PATCH 054/201] fix: preserve media html fidelity --- .../NativeEditorMarkdownParser+Embeds.swift | 385 ++++++++++++++++++ ...ativeEditorMarkdownParser+RichBlocks.swift | 17 +- ...ativeRichEditorViewModel+MediaBlocks.swift | 20 +- 3 files changed, 412 insertions(+), 10 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift index 3f07827..9bbc9a1 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift @@ -1,6 +1,27 @@ import Foundation extension NativeEditorMarkdownParser { + static func mediaHTMLBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + let line = lines[index] + + if let image = imageHTMLBlock(from: line) { + return (image, lines.index(after: index)) + } + + if let video = mediaElementHTMLBlock(in: lines, startingAt: index, type: "video") { + return video + } + + if let audio = mediaElementHTMLBlock(in: lines, startingAt: index, type: "audio") { + return audio + } + + return typedMediaDivHTMLBlock(in: lines, startingAt: index) + } + static func diagramHTMLBlock( in lines: [String], startingAt index: Array.Index @@ -59,6 +80,95 @@ extension NativeEditorMarkdownParser { ) } + static func mediaHTMLMarkdown(from media: NativeEditorMediaBlock, type: String) -> String? { + guard mediaRequiresDocmostHTML(media, type: type) else { return nil } + + switch type { + case "image": + return imageHTMLMarkdown(from: media) + case "video": + return videoHTMLMarkdown(from: media) + case "audio": + return audioHTMLMarkdown(from: media) + default: + return nil + } + } + + static func pdfHTMLMarkdown(from pdf: NativeEditorPDFBlock) -> String? { + guard pdfRequiresDocmostHTML(pdf) else { return nil } + + let openingTag = htmlTag("div", attributes: [ + ("data-type", "pdf"), + ("src", pdf.source), + ("data-name", pdf.name), + ("data-attachment-id", pdf.attachmentID), + ("data-size", pdf.sizeInBytes.map(String.init)), + ("width", pdf.width), + ("height", pdf.height) + ]) + let frameTag = htmlTag("iframe", attributes: [ + ("src", pdf.source), + ("width", pdf.width), + ("height", pdf.height) + ]) + + return """ + \(openingTag) + \(frameTag) + + """ + } + + static func attachmentHTMLMarkdown(from attachment: NativeEditorAttachmentBlock) -> String? { + guard attachmentRequiresDocmostHTML(attachment) else { return nil } + + let openingTag = htmlTag("div", attributes: [ + ("data-type", "attachment"), + ("data-attachment-url", attachment.url), + ("data-attachment-name", attachment.name), + ("data-attachment-mime", attachment.mimeType), + ("data-attachment-size", attachment.sizeInBytes.map(String.init)), + ("data-attachment-id", attachment.attachmentID) + ]) + let linkTag = htmlTag("a", attributes: [ + ("href", attachment.url), + ("class", "attachment"), + ("target", "blank") + ]) + let title = escapedInlineHTMLText(attachment.name ?? attachment.url ?? "Attachment") + + return """ + \(openingTag) + \(linkTag)\(title) + + """ + } + + static func embedHTMLMarkdown(from embed: NativeEditorEmbedBlock) -> String? { + guard embedRequiresDocmostHTML(embed) else { return nil } + + let openingTag = htmlTag("div", attributes: [ + ("data-type", "embed"), + ("data-src", embed.source), + ("data-provider", embed.provider), + ("data-align", embed.alignment), + ("data-width", embed.width), + ("data-height", embed.height) + ]) + let linkTag = htmlTag("a", attributes: [ + ("href", embed.source), + ("target", "blank") + ]) + let source = escapedInlineHTMLText(embed.source ?? "Embed") + + return """ + \(openingTag) + \(linkTag)\(source) + + """ + } + static func diagramMarkdown(from diagram: NativeEditorDiagramBlock, type: String) -> String { let openingTag = htmlTag("div", attributes: [ ("data-type", type), @@ -85,6 +195,275 @@ extension NativeEditorMarkdownParser { """ } + private static func imageHTMLBlock(from line: String) -> NativeEditorBlock? { + guard let attributes = htmlTagAttributes(from: line, tagName: "img") else { + return nil + } + + let media = mediaBlock(from: attributes, sourceAttributes: [:], type: "image") + return htmlRichBlock( + kind: .image(media), + rawNode: NativeEditorRichBlockNodeFactory.mediaNode(from: media, type: "image") + ) + } + + private static func mediaElementHTMLBlock( + in lines: [String], + startingAt index: Array.Index, + type: String + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + guard let attributes = htmlTagAttributes(from: lines[index], tagName: type) else { + return nil + } + + var sourceAttributes: [String: String] = [:] + var currentIndex = index + + while currentIndex < lines.endIndex { + let line = lines[currentIndex].trimmingCharacters(in: .whitespacesAndNewlines) + if let attributes = htmlTagAttributes(from: line, tagName: "source") { + sourceAttributes = attributes + } + + if line.localizedCaseInsensitiveContains("") { + let media = mediaBlock(from: attributes, sourceAttributes: sourceAttributes, type: type) + return ( + htmlRichBlock( + kind: type == "video" ? .video(media) : .audio(media), + rawNode: NativeEditorRichBlockNodeFactory.mediaNode(from: media, type: type) + ), + lines.index(after: currentIndex) + ) + } + + currentIndex = lines.index(after: currentIndex) + } + + return nil + } + + private static func typedMediaDivHTMLBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + guard + let attributes = htmlTagAttributes(from: lines[index], tagName: "div"), + let type = mediaDivType(from: attributes["data-type"]) + else { + return nil + } + + var childAttributes: [String: String] = [:] + var currentIndex = lines.index(after: index) + + while currentIndex < lines.endIndex { + let line = lines[currentIndex].trimmingCharacters(in: .whitespacesAndNewlines) + if let attributes = htmlTagAttributes(from: line, tagName: type == "pdf" ? "iframe" : "a") { + childAttributes = attributes + } + + if line.localizedCaseInsensitiveCompare("") == .orderedSame { + let block = typedMediaDivBlock(type: type, attributes: attributes, childAttributes: childAttributes) + return (block, lines.index(after: currentIndex)) + } + + currentIndex = lines.index(after: currentIndex) + } + + return nil + } + + private static func typedMediaDivBlock( + type: String, + attributes: [String: String], + childAttributes: [String: String] + ) -> NativeEditorBlock { + switch type { + case "pdf": + let pdf = pdfBlock(from: attributes, iframeAttributes: childAttributes) + return htmlRichBlock(kind: .pdf(pdf), rawNode: NativeEditorRichBlockNodeFactory.pdfNode(from: pdf)) + case "attachment": + let attachment = attachmentBlock(from: attributes, linkAttributes: childAttributes) + return htmlRichBlock( + kind: .attachment(attachment), + rawNode: NativeEditorRichBlockNodeFactory.attachmentNode(from: attachment) + ) + default: + let embed = embedBlock(from: attributes, linkAttributes: childAttributes) + return htmlRichBlock(kind: .embed(embed), rawNode: NativeEditorRichBlockNodeFactory.embedNode(from: embed)) + } + } + + private static func mediaBlock( + from attributes: [String: String], + sourceAttributes: [String: String], + type: String + ) -> NativeEditorMediaBlock { + let source = nonEmptyHTMLAttribute(attributes["src"]) ?? nonEmptyHTMLAttribute(sourceAttributes["src"]) + return NativeEditorMediaBlock( + source: source, + alternativeText: mediaAlternativeText(from: attributes, type: type), + title: nonEmptyHTMLAttribute(attributes["title"]), + attachmentID: mediaAttachmentID(from: attributes, source: source), + sizeInBytes: nonEmptyHTMLAttribute(attributes["data-size"]).flatMap(Int.init), + width: nonEmptyHTMLAttribute(attributes["width"]), + height: nonEmptyHTMLAttribute(attributes["height"]), + aspectRatio: nonEmptyHTMLAttribute(attributes["data-aspect-ratio"]), + alignment: nonEmptyHTMLAttribute(attributes["data-align"]) + ) + } + + private static func pdfBlock( + from attributes: [String: String], + iframeAttributes: [String: String] + ) -> NativeEditorPDFBlock { + let source = nonEmptyHTMLAttribute(attributes["src"]) ?? nonEmptyHTMLAttribute(iframeAttributes["src"]) + return NativeEditorPDFBlock( + source: source, + name: nonEmptyHTMLAttribute(attributes["data-name"]), + attachmentID: mediaAttachmentID(from: attributes, source: source), + sizeInBytes: nonEmptyHTMLAttribute(attributes["data-size"]).flatMap(Int.init), + width: nonEmptyHTMLAttribute(attributes["width"]) ?? nonEmptyHTMLAttribute(iframeAttributes["width"]), + height: nonEmptyHTMLAttribute(attributes["height"]) ?? nonEmptyHTMLAttribute(iframeAttributes["height"]) + ) + } + + private static func attachmentBlock( + from attributes: [String: String], + linkAttributes: [String: String] + ) -> NativeEditorAttachmentBlock { + let source = nonEmptyHTMLAttribute(attributes["data-attachment-url"]) ?? + nonEmptyHTMLAttribute(linkAttributes["href"]) + return NativeEditorAttachmentBlock( + url: source, + name: nonEmptyHTMLAttribute(attributes["data-attachment-name"]), + mimeType: nonEmptyHTMLAttribute(attributes["data-attachment-mime"]), + sizeInBytes: nonEmptyHTMLAttribute(attributes["data-attachment-size"]).flatMap(Int.init), + attachmentID: mediaAttachmentID(from: attributes, source: source) + ) + } + + private static func embedBlock( + from attributes: [String: String], + linkAttributes: [String: String] + ) -> NativeEditorEmbedBlock { + NativeEditorEmbedBlock( + source: nonEmptyHTMLAttribute(attributes["data-src"]) ?? nonEmptyHTMLAttribute(linkAttributes["href"]), + provider: nonEmptyHTMLAttribute(attributes["data-provider"]), + alignment: nonEmptyHTMLAttribute(attributes["data-align"]), + width: nonEmptyHTMLAttribute(attributes["data-width"]), + height: nonEmptyHTMLAttribute(attributes["data-height"]) + ) + } + + private static func htmlRichBlock(kind: NativeEditorBlockKind, rawNode: ProseMirrorNode) -> NativeEditorBlock { + NativeEditorBlock( + kind: kind, + text: AttributedString(NativeEditorDocument.previewText(for: kind)), + alignment: .left, + rawNode: rawNode + ) + } + + private static func imageHTMLMarkdown(from media: NativeEditorMediaBlock) -> String { + htmlTag("img", attributes: [ + ("src", media.source), + ("alt", media.alternativeText), + ("width", media.width), + ("height", media.height), + ("data-align", media.alignment), + ("data-attachment-id", media.attachmentID), + ("data-size", media.sizeInBytes.map(String.init)), + ("data-aspect-ratio", media.aspectRatio) + ]) + } + + private static func videoHTMLMarkdown(from media: NativeEditorMediaBlock) -> String { + let openingTag = htmlTag("video", attributes: [ + ("controls", "true"), + ("src", media.source), + ("aria-label", media.alternativeText), + ("data-attachment-id", media.attachmentID), + ("width", media.width), + ("height", media.height), + ("data-size", media.sizeInBytes.map(String.init)), + ("data-align", media.alignment), + ("data-aspect-ratio", media.aspectRatio) + ]) + let sourceTag = htmlTag("source", attributes: [("src", media.source)]) + + return """ + \(openingTag) + \(sourceTag) + + """ + } + + private static func audioHTMLMarkdown(from media: NativeEditorMediaBlock) -> String { + let openingTag = htmlTag("audio", attributes: [ + ("controls", "true"), + ("preload", "metadata"), + ("src", media.source), + ("data-attachment-id", media.attachmentID), + ("data-size", media.sizeInBytes.map(String.init)) + ]) + let sourceTag = htmlTag("source", attributes: [("src", media.source)]) + + return """ + \(openingTag) + \(sourceTag) + + """ + } + + private static func mediaRequiresDocmostHTML(_ media: NativeEditorMediaBlock, type: String) -> Bool { + media.attachmentID != nil || + media.sizeInBytes != nil || + media.width != nil || + media.height != nil || + media.aspectRatio != nil || + media.alignment != nil || + (type == "video" && media.alternativeText != nil) + } + + private static func pdfRequiresDocmostHTML(_ pdf: NativeEditorPDFBlock) -> Bool { + pdf.attachmentID != nil || + pdf.sizeInBytes != nil || + pdf.width != nil || + pdf.height != nil + } + + private static func attachmentRequiresDocmostHTML(_ attachment: NativeEditorAttachmentBlock) -> Bool { + attachment.attachmentID != nil || + attachment.sizeInBytes != nil + } + + private static func embedRequiresDocmostHTML(_ embed: NativeEditorEmbedBlock) -> Bool { + embed.provider != nil && embed.provider != "iframe" || + embed.alignment != nil || + embed.width != nil || + embed.height != nil + } + + private static func mediaDivType(from dataType: String?) -> String? { + guard let dataType else { return nil } + return ["pdf", "attachment", "embed"].first { + dataType.localizedCaseInsensitiveCompare($0) == .orderedSame + } + } + + private static func mediaAlternativeText(from attributes: [String: String], type: String) -> String? { + if type == "video" { + return nonEmptyHTMLAttribute(attributes["aria-label"]) ?? nonEmptyHTMLAttribute(attributes["alt"]) + } + + return nonEmptyHTMLAttribute(attributes["alt"]) + } + + private static func mediaAttachmentID(from attributes: [String: String], source: String?) -> String? { + nonEmptyHTMLAttribute(attributes["data-attachment-id"]) ?? source.flatMap(docmostAttachmentID) + } + private static func iframeMarkdownLink(from line: String) -> (label: String, source: String)? { guard line.hasPrefix("["), let closeLabelIndex = line.firstIndex(of: "]") else { return nil @@ -157,6 +536,12 @@ extension NativeEditorMarkdownParser { return attributeText.isEmpty ? "<\(name)>" : "<\(name) \(attributeText)>" } + private static func escapedInlineHTMLText(_ text: String) -> String { + text.replacing("&", with: "&") + .replacing("<", with: "<") + .replacing(">", with: ">") + } + private static func htmlTagAttributes(from line: String, tagName: String) -> [String: String]? { let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmedLine.lowercased().hasPrefix("<\(tagName.lowercased())") else { return nil } diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift index aafda92..fe323d3 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift @@ -13,6 +13,10 @@ extension NativeEditorMarkdownParser { return calloutBlock } + if let mediaBlock = mediaHTMLBlock(in: lines, startingAt: index) { + return mediaBlock + } + if let diagramBlock = diagramHTMLBlock(in: lines, startingAt: index) { return diagramBlock } @@ -44,15 +48,16 @@ extension NativeEditorMarkdownParser { private static func mediaMarkdownLine(from block: NativeEditorBlock) -> String? { switch block.kind { case .image(let media): - imageMarkdown(from: media) + mediaHTMLMarkdown(from: media, type: "image") ?? imageMarkdown(from: media) case .video(let media): - mediaLinkMarkdown(from: media, fallbackTitle: "Video") + mediaHTMLMarkdown(from: media, type: "video") ?? mediaLinkMarkdown(from: media, fallbackTitle: "Video") case .audio(let media): - mediaLinkMarkdown(from: media, fallbackTitle: "Audio") + mediaHTMLMarkdown(from: media, type: "audio") ?? mediaLinkMarkdown(from: media, fallbackTitle: "Audio") case .pdf(let pdf): - linkMarkdown(title: pdf.name ?? pdf.source ?? "PDF", url: pdf.source) + pdfHTMLMarkdown(from: pdf) ?? linkMarkdown(title: pdf.name ?? pdf.source ?? "PDF", url: pdf.source) case .attachment(let attachment): - linkMarkdown(title: attachment.name ?? attachment.url ?? "Attachment", url: attachment.url) + attachmentHTMLMarkdown(from: attachment) ?? + linkMarkdown(title: attachment.name ?? attachment.url ?? "Attachment", url: attachment.url) default: nil } @@ -82,7 +87,7 @@ extension NativeEditorMarkdownParser { private static func embeddedMarkdownLine(from block: NativeEditorBlock) -> String? { switch block.kind { case .embed(let embed): - embedMarkdown(from: embed) + embedHTMLMarkdown(from: embed) ?? embedMarkdown(from: embed) case .drawio(let diagram): diagramMarkdown(from: diagram, type: "drawio") case .excalidraw(let diagram): diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+MediaBlocks.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+MediaBlocks.swift index 78fc183..57cbe96 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+MediaBlocks.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+MediaBlocks.swift @@ -197,14 +197,26 @@ nonisolated extension NativeEditorRichBlockNodeFactory { aspectRatio: String?, to attrs: inout [String: ProseMirrorJSONValue] ) { - if let width = width.flatMap(Int.init) { - attrs["width"] = .int(width) + if let width = width.flatMap(proseMirrorDimension(from:)) { + attrs["width"] = width } - if let height = height.flatMap(Int.init) { - attrs["height"] = .int(height) + if let height = height.flatMap(proseMirrorDimension(from:)) { + attrs["height"] = height } if let aspectRatio = aspectRatio.flatMap(Double.init) { attrs["aspectRatio"] = .double(aspectRatio) } } + + private static func proseMirrorDimension(from value: String) -> ProseMirrorJSONValue? { + let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedValue.isEmpty == false else { return nil } + if let intValue = Int(trimmedValue) { + return .int(intValue) + } + if let doubleValue = Double(trimmedValue) { + return .double(doubleValue) + } + return .string(trimmedValue) + } } From 500eece94538eb4f547a9fbc1e432f1cebed7fae Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 18:33:54 +0100 Subject: [PATCH 055/201] fix: remove duplicate media html escaping helper --- .../Features/Editor/NativeEditorMarkdownParser+Embeds.swift | 6 ------ 1 file changed, 6 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift index 9bbc9a1..114003f 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift @@ -536,12 +536,6 @@ extension NativeEditorMarkdownParser { return attributeText.isEmpty ? "<\(name)>" : "<\(name) \(attributeText)>" } - private static func escapedInlineHTMLText(_ text: String) -> String { - text.replacing("&", with: "&") - .replacing("<", with: "<") - .replacing(">", with: ">") - } - private static func htmlTagAttributes(from line: String, tagName: String) -> [String: String]? { let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmedLine.lowercased().hasPrefix("<\(tagName.lowercased())") else { return nil } From 20635c066893e1a2c0ee12beb4205d2306f0cc77 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 18:40:59 +0100 Subject: [PATCH 056/201] test: expect provider embed html export --- .../Editor/NativeEditorRichMarkdownExportTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift index b48123b..a45995f 100644 --- a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift +++ b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift @@ -200,7 +200,9 @@ struct NativeEditorRichMarkdownExportTests {
- [Example](https://example.com) + $$ E = mc^2 $$ From a983fee7e0a81be43f8ac4703c10481cdee8b529 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 18:57:26 +0100 Subject: [PATCH 057/201] test: cover diagram html import edge cases --- .../NativeEditorMarkdownImportTests.swift | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift index fbd395e..f57c2b8 100644 --- a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift +++ b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift @@ -226,6 +226,48 @@ struct NativeEditorMarkdownImportTests { #expect(NativeEditorMarkdownParser.markdown(from: blocks) == markdown) } + @Test func singleLineDocmostDiagramHTMLImportsAsNativeDiagramBlock() throws { + let source = "/api/files/drawio-1/diagram.drawio.svg" + let markdown = singleLineDocmostDiagramHTML( + type: "drawio", + source: source, + title: "System map", + alternativeText: "System diagram", + attachmentID: "drawio-1", + width: "640" + ) + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + + guard case .drawio(let drawio) = block.kind else { + Issue.record("Expected compact Docmost draw.io HTML to import as a native draw.io block.") + return + } + + #expect(drawio.source == source) + #expect(drawio.title == "System map") + #expect(drawio.alternativeText == "System diagram") + #expect(drawio.attachmentID == "drawio-1") + #expect(drawio.width == "640") + #expect(block.rawNode?.type == "drawio") + #expect(block.rawNode?.attrs?["src"] == .string(source)) + } + + @Test func longerHTMLTagNamesDoNotImportAsDocmostMediaBlocks() throws { + let imageLikeBlock = try #require( + NativeEditorMarkdownParser.blocks(from: #""#).first + ) + let diagramLikeBlock = try #require( + NativeEditorMarkdownParser.blocks( + from: #""# + ).first + ) + + #expect(imageLikeBlock.kind == .paragraph) + #expect(String(imageLikeBlock.text.characters) == #""#) + #expect(diagramLikeBlock.kind == .paragraph) + #expect(String(diagramLikeBlock.text.characters).contains(" String { + let openingTag = htmlTag("div", attributes: [ + ("data-type", type), + ("data-src", source), + ("data-title", title), + ("data-alt", alternativeText), + ("data-width", width), + ("data-attachment-id", attachmentID) + ]) + let imageTag = htmlTag("img", attributes: [ + ("src", source), + ("alt", alternativeText), + ("width", width) + ]) + + return "\(openingTag)\(imageTag)" + } + private func htmlTag(_ name: String, attributes: [(String, String?)]) -> String { let attributeText = attributes.compactMap { key, value -> String? in value.map { #"\#(key)="\#($0)""# } From dfff8716c9336c6e43ae3dc9d6688d3b390ae663 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 19:06:27 +0100 Subject: [PATCH 058/201] fix: parse compact diagram html safely --- ...itorMarkdownParser+DocmostInlineHTML.swift | 86 +++++++++++++++++++ .../NativeEditorMarkdownParser+Embeds.swift | 17 +--- 2 files changed, 89 insertions(+), 14 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift index 8d113b5..7b02013 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift @@ -29,6 +29,50 @@ extension NativeEditorMarkdownParser { return attrs } + static func htmlTagAttributes(from line: String, tagName: String) -> [String: String]? { + let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard let range = openingHTMLTagRange(in: trimmedLine, tagName: tagName), + range.lowerBound == trimmedLine.startIndex else { + return nil + } + + return docmostInlineHTMLAttributes(from: String(trimmedLine[range])) + } + + static func firstHTMLTagAttributes(in line: String, tagName: String) -> [String: String]? { + let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard let range = openingHTMLTagRange(in: trimmedLine, tagName: tagName) else { + return nil + } + + return docmostInlineHTMLAttributes(from: String(trimmedLine[range])) + } + + static func containsHTMLClosingTag(in line: String, tagName: String) -> Bool { + let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) + var searchStart = trimmedLine.startIndex + + while searchStart < trimmedLine.endIndex, + let openIndex = trimmedLine[searchStart...].firstIndex(of: "<") { + var nameStart = trimmedLine.index(after: openIndex) + guard nameStart < trimmedLine.endIndex, trimmedLine[nameStart] == "/" else { + searchStart = nameStart + continue + } + + nameStart = trimmedLine.index(after: nameStart) + let nameEnd = htmlTagNameEnd(in: trimmedLine, startingAt: nameStart) + let name = String(trimmedLine[nameStart.. Range? { + var searchStart = text.startIndex + + while searchStart < text.endIndex, + let openIndex = text[searchStart...].firstIndex(of: "<") { + let nameStart = text.index(after: openIndex) + guard nameStart < text.endIndex, text[nameStart] != "/" else { + searchStart = nameStart + continue + } + + let nameEnd = htmlTagNameEnd(in: text, startingAt: nameStart) + let name = String(text[nameStart..") else { + searchStart = nameEnd + continue + } + + return openIndex.. String.Index { + var currentIndex = index + while currentIndex < text.endIndex, text[currentIndex].isDocmostHTMLTagNameChar { + currentIndex = text.index(after: currentIndex) + } + return currentIndex + } + + private static func isHTMLTagBoundary(at index: String.Index, in text: String) -> Bool { + index == text.endIndex || text[index].isWhitespace || text[index] == ">" || text[index] == "/" + } + static func escapedInlineHTMLAttribute(_ text: String) -> String { escapedInlineHTMLText(text).replacing("\"", with: """) } @@ -130,4 +212,8 @@ private extension Character { var isDocmostHTMLAttrNameChar: Bool { isLetter || isNumber || self == "-" || self == "_" || self == ":" } + + var isDocmostHTMLTagNameChar: Bool { + isLetter || isNumber || self == "-" + } } diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift index 114003f..3e9db26 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift @@ -34,14 +34,14 @@ extension NativeEditorMarkdownParser { } var imageAttributes: [String: String] = [:] - var currentIndex = lines.index(after: index) + var currentIndex = index while currentIndex < lines.endIndex { let line = lines[currentIndex].trimmingCharacters(in: .whitespacesAndNewlines) - if let attrs = htmlTagAttributes(from: line, tagName: "img") { + if let attrs = firstHTMLTagAttributes(in: line, tagName: "img") { imageAttributes = attrs } - if line.localizedCaseInsensitiveCompare("") == .orderedSame { + if containsHTMLClosingTag(in: line, tagName: "div") { let diagram = diagramBlock(from: attributes, imageAttributes: imageAttributes) let kind = diagramKind(type: type, diagram: diagram) return ( @@ -536,17 +536,6 @@ extension NativeEditorMarkdownParser { return attributeText.isEmpty ? "<\(name)>" : "<\(name) \(attributeText)>" } - private static func htmlTagAttributes(from line: String, tagName: String) -> [String: String]? { - let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmedLine.lowercased().hasPrefix("<\(tagName.lowercased())") else { return nil } - guard let tagEnd = trimmedLine.firstIndex(of: ">"), tagEnd > trimmedLine.startIndex else { - return nil - } - - let openingTag = String(trimmedLine[trimmedLine.startIndex.. String? { guard let dataType else { return nil } if dataType.localizedCaseInsensitiveCompare("drawio") == .orderedSame { From 3fdbe10275e88aefcc873d170497e95cab6909e7 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 19:17:22 +0100 Subject: [PATCH 059/201] fix: preserve unsupported html as paragraph text --- .../Editor/NativeEditorMarkdownParser.swift | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift index ba4574c..2505181 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift @@ -330,10 +330,22 @@ enum NativeEditorMarkdownParser { remaining = remaining[closeRange.upperBound...] } - appendMarkdownText(String(remaining), to: &result, usesFoundationMarkdownParser: result.characters.isEmpty) + appendMarkdownText( + String(remaining), + to: &result, + usesFoundationMarkdownParser: shouldUseFoundationMarkdownParser(for: markdown, after: result) + ) return result } + private static func shouldUseFoundationMarkdownParser( + for markdown: String, + after result: AttributedString + ) -> Bool { + let trimmedMarkdown = markdown.trimmingCharacters(in: .whitespacesAndNewlines) + return result.characters.isEmpty && trimmedMarkdown.hasPrefix("<") == false + } + static func inlineMathInputRuleText(from text: String) -> AttributedString? { guard let shortcut = trailingInlineMathShortcut(in: text) else { return nil } From d6ddc4dfe45910d0443633a75be8032b381df627 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 19:27:25 +0100 Subject: [PATCH 060/201] test: cover structural html block fidelity --- .../NativeEditorMarkdownImportTests.swift | 45 +++++++++++++++++ ...iveEditorStructuralHTMLFidelityTests.swift | 48 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 docmostlyTests/Editor/NativeEditorStructuralHTMLFidelityTests.swift diff --git a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift index f57c2b8..94a5f46 100644 --- a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift +++ b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift @@ -226,6 +226,51 @@ struct NativeEditorMarkdownImportTests { #expect(NativeEditorMarkdownParser.markdown(from: blocks) == markdown) } + @Test func docmostStructuralHTMLImportsAsNativeStructuralBlocks() throws { + let markdown = """ +
+
+ Reusable launch checklist +
+
+
+ """ + let blocks = NativeEditorMarkdownParser.blocks(from: markdown) + + try #require(blocks.count == 4) + #expect(blocks[0].kind == .subpages) + #expect(blocks[0].rawNode?.type == "subpages") + + guard case .transclusionSource(let source) = blocks[1].kind else { + Issue.record("Expected Docmost transclusion source HTML to import as a native synced block.") + return + } + #expect(source.identifier == "sync-1") + #expect(source.previewText == "Reusable launch checklist") + #expect(blocks[1].rawNode?.type == "transclusionSource") + #expect(blocks[1].rawNode?.attrs?["id"] == .string("sync-1")) + + guard case .transclusionReference(let reference) = blocks[2].kind else { + Issue.record("Expected Docmost transclusion reference HTML to import as a native synced block reference.") + return + } + #expect(reference.sourcePageID == "page-1") + #expect(reference.transclusionID == "sync-1") + #expect(blocks[2].rawNode?.type == "transclusionReference") + #expect(blocks[2].rawNode?.attrs?["sourcePageId"] == .string("page-1")) + #expect(blocks[2].rawNode?.attrs?["transclusionId"] == .string("sync-1")) + + guard case .base(let base) = blocks[3].kind else { + Issue.record("Expected Docmost base embed HTML to import as a native base block.") + return + } + #expect(base.pageID == "base-page-1") + #expect(base.pendingKey == nil) + #expect(blocks[3].rawNode?.type == "base") + #expect(blocks[3].rawNode?.attrs?["pageId"] == .string("base-page-1")) + #expect(NativeEditorMarkdownParser.markdown(from: blocks) == markdown) + } + @Test func singleLineDocmostDiagramHTMLImportsAsNativeDiagramBlock() throws { let source = "/api/files/drawio-1/diagram.drawio.svg" let markdown = singleLineDocmostDiagramHTML( diff --git a/docmostlyTests/Editor/NativeEditorStructuralHTMLFidelityTests.swift b/docmostlyTests/Editor/NativeEditorStructuralHTMLFidelityTests.swift new file mode 100644 index 0000000..3244b28 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorStructuralHTMLFidelityTests.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorStructuralHTMLFidelityTests { + @Test func documentMarkdownConversionPreservesDocmostStructuralHTMLBlocks() { + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: [ + NativeEditorBlock(kind: .subpages, text: AttributedString("Subpages"), alignment: .left), + NativeEditorBlock( + kind: .transclusionSource(NativeEditorTransclusionSourceBlock( + identifier: "sync-1", + previewText: "Reusable launch checklist" + )), + text: AttributedString("Reusable launch checklist"), + alignment: .left + ), + NativeEditorBlock( + kind: .transclusionReference(NativeEditorTransclusionReferenceBlock( + sourcePageID: "page-1", + transclusionID: "sync-1" + )), + text: AttributedString("Reusable launch checklist"), + alignment: .left + ), + NativeEditorBlock( + kind: .base(NativeEditorBaseBlock( + pageID: "base-page-1", + pendingKey: nil, + previewText: "Roadmap base" + )), + text: AttributedString("Roadmap base"), + alignment: .left + ) + ]) + viewModel.resetEditingHistory() + + #expect(viewModel.markdownForDocument() == """ +
+
+ Reusable launch checklist +
+
+
+ """) + } +} From e39546aabaf5e3dfacdae332e2c0238c212d17ac Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 19:40:08 +0100 Subject: [PATCH 061/201] fix: preserve structural html blocks --- ...ativeEditorMarkdownParser+RichBlocks.swift | 29 +-- ...eEditorMarkdownParser+StructuralHTML.swift | 179 ++++++++++++++++++ 2 files changed, 187 insertions(+), 21 deletions(-) create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+StructuralHTML.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift index fe323d3..d48c41d 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift @@ -21,6 +21,10 @@ extension NativeEditorMarkdownParser { return diagramBlock } + if let structuralBlock = docmostStructuralHTMLBlock(in: lines, startingAt: index) { + return structuralBlock + } + if let columnsBlock = columnsHTMLBlock(in: lines, startingAt: index) { return columnsBlock } @@ -64,6 +68,10 @@ extension NativeEditorMarkdownParser { } private static func structuralMarkdownLine(from block: NativeEditorBlock) -> String? { + if let structuralHTML = docmostStructuralHTMLMarkdown(from: block) { + return structuralHTML + } + switch block.kind { case .callout(let callout): calloutMarkdown(from: callout) @@ -73,12 +81,6 @@ extension NativeEditorMarkdownParser { #"
"# case .columns(let columns): columnsMarkdown(from: columns) - case .subpages: - "" - case .transclusionSource(let source): - transclusionSourceMarkdown(from: source) - case .transclusionReference(let reference): - transclusionReferenceMarkdown(from: reference) default: nil } @@ -395,21 +397,6 @@ extension NativeEditorMarkdownParser { """ } - private static func transclusionSourceMarkdown(from source: NativeEditorTransclusionSourceBlock) -> String { - if let identifier = source.identifier, identifier.isEmpty == false { - return "\n\(source.previewText.trimmedMarkdownBlockText)" - } - - return source.previewText.trimmedMarkdownBlockText - } - - private static func transclusionReferenceMarkdown( - from reference: NativeEditorTransclusionReferenceBlock - ) -> String { - let identifier = reference.transclusionID ?? reference.sourcePageID ?? "unknown" - return "" - } - private static func embedMarkdown(from embed: NativeEditorEmbedBlock) -> String { if embed.provider == "iframe", let source = embed.source, source.isEmpty == false { return linkMarkdown(title: source, url: source) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+StructuralHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+StructuralHTML.swift new file mode 100644 index 0000000..9e05449 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+StructuralHTML.swift @@ -0,0 +1,179 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func docmostStructuralHTMLBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + guard let attributes = docmostStructuralHTMLAttributes(from: lines[index]) else { + return nil + } + + switch attributes["data-type"] { + case "subpages": + return ( + docmostStructuralBlock(kind: .subpages, rawNode: ProseMirrorNode(type: "subpages")), + lines.index(after: index) + ) + case "transclusionSource": + return transclusionSourceHTMLBlock(in: lines, startingAt: index, attributes: attributes) + case "transclusionReference": + let reference = NativeEditorTransclusionReferenceBlock( + sourcePageID: nonEmptyStructuralHTMLAttribute(attributes["data-source-page-id"]), + transclusionID: nonEmptyStructuralHTMLAttribute(attributes["data-transclusion-id"]) + ) + return ( + docmostStructuralBlock( + kind: .transclusionReference(reference), + rawNode: NativeEditorRichBlockNodeFactory.transclusionReferenceNode(from: reference) + ), + lines.index(after: index) + ) + case "base-embed": + let base = NativeEditorBaseBlock( + pageID: nonEmptyStructuralHTMLAttribute(attributes["data-page-id"]), + pendingKey: nil, + previewText: "Base" + ) + return ( + docmostStructuralBlock( + kind: .base(base), + rawNode: NativeEditorRichBlockNodeFactory.baseNode(from: base) + ), + lines.index(after: index) + ) + default: + return nil + } + } + + static func docmostStructuralHTMLMarkdown(from block: NativeEditorBlock) -> String? { + switch block.kind { + case .subpages: + #"
"# + case .transclusionSource(let source): + transclusionSourceHTMLMarkdown(from: source) + case .transclusionReference(let reference): + transclusionReferenceHTMLMarkdown(from: reference) + case .base(let base): + baseHTMLMarkdown(from: base) + default: + nil + } + } + + private static func transclusionSourceHTMLBlock( + in lines: [String], + startingAt index: Array.Index, + attributes: [String: String] + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + var bodyLines: [String] = [] + var currentIndex = lines.index(after: index) + + while currentIndex < lines.endIndex { + let line = lines[currentIndex] + if containsHTMLClosingTag(in: line, tagName: "div") { + let source = NativeEditorTransclusionSourceBlock( + identifier: nonEmptyStructuralHTMLAttribute(attributes["data-id"]), + previewText: structuralHTMLText(from: bodyLines) + ) + return ( + docmostStructuralBlock( + kind: .transclusionSource(source), + rawNode: NativeEditorRichBlockNodeFactory.transclusionSourceNode(from: source) + ), + lines.index(after: currentIndex) + ) + } + + bodyLines.append(line) + currentIndex = lines.index(after: currentIndex) + } + + return nil + } + + private static func transclusionSourceHTMLMarkdown(from source: NativeEditorTransclusionSourceBlock) -> String { + let openingTag = structuralHTMLTag("div", attributes: [ + ("data-type", "transclusionSource"), + ("data-id", source.identifier) + ]) + let previewText = escapedInlineHTMLText(source.previewText.trimmingCharacters(in: .whitespacesAndNewlines)) + + return """ + \(openingTag) + \(previewText) + + """ + } + + private static func transclusionReferenceHTMLMarkdown( + from reference: NativeEditorTransclusionReferenceBlock + ) -> String { + let openingTag = structuralHTMLTag("div", attributes: [ + ("data-type", "transclusionReference"), + ("data-source-page-id", reference.sourcePageID), + ("data-transclusion-id", reference.transclusionID) + ]) + return "\(openingTag)" + } + + private static func baseHTMLMarkdown(from base: NativeEditorBaseBlock) -> String { + let openingTag = structuralHTMLTag("div", attributes: [ + ("data-type", "base-embed"), + ("data-page-id", base.pageID) + ]) + return "\(openingTag)" + } + + private static func docmostStructuralBlock( + kind: NativeEditorBlockKind, + rawNode: ProseMirrorNode + ) -> NativeEditorBlock { + NativeEditorBlock( + kind: kind, + text: AttributedString(NativeEditorDocument.previewText(for: kind)), + alignment: .left, + rawNode: rawNode + ) + } + + private static func docmostStructuralHTMLAttributes(from line: String) -> [String: String]? { + guard let attributes = htmlTagAttributes(from: line, tagName: "div") else { + return nil + } + + guard let dataType = attributes["data-type"], + docmostStructuralHTMLTypes.contains(dataType) else { + return nil + } + + return attributes + } + + private static func structuralHTMLText(from lines: [String]) -> String { + lines.map(unescapedInlineHTMLText) + .joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func structuralHTMLTag(_ name: String, attributes: [(String, String?)]) -> String { + let attributeText = attributes.compactMap { key, value -> String? in + guard let value = nonEmptyStructuralHTMLAttribute(value) else { return nil } + return #"\#(key)="\#(escapedInlineHTMLAttribute(value))""# + }.joined(separator: " ") + + return attributeText.isEmpty ? "<\(name)>" : "<\(name) \(attributeText)>" + } + + private static func nonEmptyStructuralHTMLAttribute(_ value: String?) -> String? { + guard let value, value.isEmpty == false else { + return nil + } + return value + } + + private static var docmostStructuralHTMLTypes: Set { + ["base-embed", "subpages", "transclusionReference", "transclusionSource"] + } +} From 188129957c5ce004c8fd51a25436401c7564f786 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 19:42:29 +0100 Subject: [PATCH 062/201] fix: return structural markdown fallback --- .../Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift index d48c41d..196a098 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift @@ -72,7 +72,7 @@ extension NativeEditorMarkdownParser { return structuralHTML } - switch block.kind { + return switch block.kind { case .callout(let callout): calloutMarkdown(from: callout) case .details(let details): From 721b2c914fd64cf580c4fa1cdcd243ede818d71f Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 19:56:39 +0100 Subject: [PATCH 063/201] test: cover container html block fidelity --- ...tiveEditorContainerHTMLFidelityTests.swift | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 docmostlyTests/Editor/NativeEditorContainerHTMLFidelityTests.swift diff --git a/docmostlyTests/Editor/NativeEditorContainerHTMLFidelityTests.swift b/docmostlyTests/Editor/NativeEditorContainerHTMLFidelityTests.swift new file mode 100644 index 0000000..94ba530 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorContainerHTMLFidelityTests.swift @@ -0,0 +1,96 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorContainerHTMLFidelityTests { + @Test func importsDocmostCalloutDetailsAndMathHTMLAsNativeBlocks() throws { + let markdown = """ +
+ Launch checklist +
+
+ Release notes +
+ Ship build +
+
+
E = mc^2
+ """ + let blocks = NativeEditorMarkdownParser.blocks(from: markdown) + + try #require(blocks.count == 3) + + guard case .callout(let callout) = blocks[0].kind else { + Issue.record("Expected Docmost callout HTML to import as a native callout block.") + return + } + #expect(callout.style == "warning") + #expect(callout.icon == "rocket") + #expect(callout.previewText == "Launch checklist") + #expect(blocks[0].rawNode?.type == "callout") + #expect(blocks[0].rawNode?.attrs?["type"] == .string("warning")) + #expect(blocks[0].rawNode?.attrs?["icon"] == .string("rocket")) + + guard case .details(let details) = blocks[1].kind else { + Issue.record("Expected Docmost details HTML to import as a native details block.") + return + } + #expect(details.summary == "Release notes") + #expect(details.previewText == "Ship build") + #expect(details.isOpen == true) + #expect(blocks[1].rawNode?.type == "details") + #expect(blocks[1].rawNode?.attrs?["open"] == .bool(true)) + + guard case .mathBlock(let math) = blocks[2].kind else { + Issue.record("Expected Docmost math block HTML to import as a native math block.") + return + } + #expect(math.text == "E = mc^2") + #expect(blocks[2].rawNode?.type == "mathBlock") + #expect(blocks[2].rawNode?.attrs?["text"] == .string("E = mc^2")) + } + + @Test func exportsNativeCalloutDetailsAndMathBlocksAsDocmostHTMLWhenNeeded() { + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: [ + NativeEditorBlock( + kind: .callout(NativeEditorCalloutBlock( + style: "warning", + icon: "rocket", + previewText: "Launch checklist" + )), + text: AttributedString("Launch checklist"), + alignment: .left + ), + NativeEditorBlock( + kind: .details(NativeEditorDetailsBlock( + summary: "Release notes", + previewText: "Ship build", + isOpen: true + )), + text: AttributedString("Release notes"), + alignment: .left + ), + NativeEditorBlock( + kind: .mathBlock(NativeEditorMathBlock(text: "E = mc^2")), + text: AttributedString("E = mc^2"), + alignment: .left + ) + ]) + viewModel.resetEditingHistory() + + #expect(viewModel.markdownForDocument() == """ +
+ Launch checklist +
+
+ Release notes +
+ Ship build +
+
+
E = mc^2
+ """) + } +} From 30226da4b231031af76428ae210f67de6178ed44 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 20:06:34 +0100 Subject: [PATCH 064/201] fix: preserve container html blocks --- ...veEditorMarkdownParser+ContainerHTML.swift | 311 ++++++++++++++++++ ...ativeEditorMarkdownParser+RichBlocks.swift | 8 + .../NativeEditorRichMarkdownExportTests.swift | 12 +- 3 files changed, 324 insertions(+), 7 deletions(-) create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+ContainerHTML.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+ContainerHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+ContainerHTML.swift new file mode 100644 index 0000000..0b2836a --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+ContainerHTML.swift @@ -0,0 +1,311 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func docmostContainerHTMLBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + if let callout = calloutHTMLBlock(in: lines, startingAt: index) { + return callout + } + + if let details = detailsContainerHTMLBlock(in: lines, startingAt: index) { + return details + } + + return mathBlockHTMLBlock(in: lines, startingAt: index) + } + + static func docmostContainerHTMLMarkdown(from block: NativeEditorBlock) -> String? { + switch block.kind { + case .callout(let callout): + callout.icon == nil ? nil : calloutHTMLMarkdown(from: callout) + case .details(let details): + detailsHTMLMarkdown(from: details) + case .mathBlock(let math): + mathBlockHTMLMarkdown(from: math) + default: + nil + } + } + + private static func calloutHTMLBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + guard + let attributes = htmlTagAttributes(from: lines[index], tagName: "div"), + attributes["data-type"]?.localizedCaseInsensitiveCompare("callout") == .orderedSame + else { + return nil + } + + let body = htmlContainerBody(in: lines, startingAt: index, tagName: "div") + guard let body else { return nil } + + let callout = NativeEditorCalloutBlock( + style: sanitizedContainerCalloutStyle(attributes["data-callout-type"] ?? "info"), + icon: nonEmptyContainerHTMLAttribute(attributes["data-callout-icon"]), + previewText: containerBodyText(from: body.lines) + ) + return ( + containerBlock( + kind: .callout(callout), + rawNode: NativeEditorRichBlockNodeFactory.calloutNode(from: callout) + ), + body.endIndex + ) + } + + private static func detailsContainerHTMLBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + let line = lines[index].trimmingCharacters(in: .whitespacesAndNewlines) + guard line.localizedCaseInsensitiveCompare("
") != .orderedSame, + let attributes = htmlTagAttributes(from: line, tagName: "details") else { + return nil + } + + var summary = "Details" + var contentLines: [String] = [] + var isInDetailsContent = false + var currentIndex = lines.index(after: index) + + while currentIndex < lines.endIndex { + let currentLine = lines[currentIndex] + let trimmedLine = currentLine.trimmingCharacters(in: .whitespacesAndNewlines) + + if containsHTMLClosingTag(in: trimmedLine, tagName: "details") { + let details = NativeEditorDetailsBlock( + summary: summary, + previewText: containerBodyText(from: contentLines), + isOpen: attributes.keys.contains("open") + ) + return ( + containerBlock( + kind: .details(details), + rawNode: NativeEditorRichBlockNodeFactory.detailsNode(from: details) + ), + lines.index(after: currentIndex) + ) + } + + if let parsedSummary = containerSummaryText(from: trimmedLine) { + summary = parsedSummary + } else if isDetailsContentOpeningLine(trimmedLine) { + isInDetailsContent = true + } else if isInDetailsContent, containsHTMLClosingTag(in: trimmedLine, tagName: "div") { + isInDetailsContent = false + } else if isInDetailsContent || trimmedLine.isEmpty == false { + contentLines.append(currentLine) + } + + currentIndex = lines.index(after: currentIndex) + } + + return nil + } + + private static func mathBlockHTMLBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + guard + let attributes = htmlTagAttributes(from: lines[index], tagName: "div"), + attributes["data-type"]?.localizedCaseInsensitiveCompare("mathBlock") == .orderedSame, + attributes.keys.contains("data-katex") + else { + return nil + } + + let body = htmlContainerBody(in: lines, startingAt: index, tagName: "div") + guard let body else { return nil } + + let math = NativeEditorMathBlock(text: containerBodyText(from: body.lines)) + return ( + containerBlock(kind: .mathBlock(math), rawNode: NativeEditorRichBlockNodeFactory.mathBlockNode(from: math)), + body.endIndex + ) + } + + private static func calloutHTMLMarkdown(from callout: NativeEditorCalloutBlock) -> String { + let openingTag = containerHTMLTag("div", attributes: [ + ("data-type", "callout"), + ("data-callout-type", sanitizedContainerCalloutStyle(callout.style)), + ("data-callout-icon", callout.icon) + ]) + + return """ + \(openingTag) + \(escapedInlineHTMLText(callout.previewText.trimmingCharacters(in: .whitespacesAndNewlines))) + + """ + } + + private static func detailsHTMLMarkdown(from details: NativeEditorDetailsBlock) -> String { + let openingTag = containerHTMLTag("details", attributes: [ + ("open", details.isOpen ? "" : nil) + ]) + let summary = escapedInlineHTMLText(details.summary.trimmingCharacters(in: .whitespacesAndNewlines)) + let body = escapedInlineHTMLText(details.previewText.trimmingCharacters(in: .whitespacesAndNewlines)) + + return """ + \(openingTag) + \(summary) +
+ \(body) +
+
+ """ + } + + private static func mathBlockHTMLMarkdown(from math: NativeEditorMathBlock) -> String { + let text = escapedInlineHTMLText(math.text.trimmingCharacters(in: .whitespacesAndNewlines)) + return #"
\#(text)
"# + } + + private static func htmlContainerBody( + in lines: [String], + startingAt index: Array.Index, + tagName: String + ) -> (lines: [String], endIndex: Array.Index)? { + let line = lines[index] + if let inlineBody = inlineHTMLBody(in: line, tagName: tagName) { + return ([inlineBody], lines.index(after: index)) + } + + var bodyLines: [String] = [] + var currentIndex = lines.index(after: index) + while currentIndex < lines.endIndex { + let currentLine = lines[currentIndex] + if containsHTMLClosingTag(in: currentLine, tagName: tagName) { + if let bodyPrefix = htmlLinePrefixBeforeClosingTag(in: currentLine, tagName: tagName) { + bodyLines.append(bodyPrefix) + } + return (bodyLines, lines.index(after: currentIndex)) + } + + bodyLines.append(currentLine) + currentIndex = lines.index(after: currentIndex) + } + + return nil + } + + private static func inlineHTMLBody(in line: String, tagName: String) -> String? { + guard + containsHTMLClosingTag(in: line, tagName: tagName), + let openingEnd = line.firstIndex(of: ">"), + let closingRange = line.range(of: "", options: [.caseInsensitive, .backwards]) + else { + return nil + } + + let bodyStart = line.index(after: openingEnd) + guard bodyStart <= closingRange.lowerBound else { return "" } + return String(line[bodyStart.. String? { + guard let closingRange = line.range(of: "", options: [.caseInsensitive, .backwards]) else { + return nil + } + + let prefix = String(line[.. String? { + guard + line.localizedCaseInsensitiveContains(""), + let openingEnd = line.firstIndex(of: ">"), + let closingRange = line.range(of: "", options: [.caseInsensitive, .backwards]) + else { + return nil + } + + let contentStart = line.index(after: openingEnd) + guard contentStart <= closingRange.lowerBound else { return "" } + return unescapedInlineHTMLText(String(line[contentStart.. Bool { + guard let attributes = htmlTagAttributes(from: line, tagName: "div") else { + return false + } + + return attributes["data-type"]?.localizedCaseInsensitiveCompare("detailsContent") == .orderedSame + } + + private static func containerBodyText(from lines: [String]) -> String { + lines.compactMap(containerBodyLineText(from:)) + .joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func containerBodyLineText(from line: String) -> String? { + let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedLine.isEmpty == false else { return nil } + + if trimmedLine.localizedCaseInsensitiveCompare("

") == .orderedSame || + trimmedLine.localizedCaseInsensitiveCompare("

") == .orderedSame { + return nil + } + + if let paragraphText = paragraphHTMLText(from: trimmedLine) { + return paragraphText + } + + return unescapedInlineHTMLText(trimmedLine) + } + + private static func paragraphHTMLText(from line: String) -> String? { + guard + line.localizedCaseInsensitiveContains(""), + let openingEnd = line.firstIndex(of: ">"), + let closingRange = line.range(of: "

", options: [.caseInsensitive, .backwards]) + else { + return nil + } + + let contentStart = line.index(after: openingEnd) + guard contentStart <= closingRange.lowerBound else { return "" } + return unescapedInlineHTMLText(String(line[contentStart.. NativeEditorBlock { + NativeEditorBlock( + kind: kind, + text: AttributedString(NativeEditorDocument.previewText(for: kind)), + alignment: .left, + rawNode: rawNode + ) + } + + private static func containerHTMLTag(_ name: String, attributes: [(String, String?)]) -> String { + let attributeText = attributes.compactMap { key, value -> String? in + guard let value else { return nil } + return #"\#(key)="\#(escapedInlineHTMLAttribute(value))""# + }.joined(separator: " ") + + return attributeText.isEmpty ? "<\(name)>" : "<\(name) \(attributeText)>" + } + + private static func nonEmptyContainerHTMLAttribute(_ value: String?) -> String? { + guard let value, value.isEmpty == false else { + return nil + } + return value + } + + private static func sanitizedContainerCalloutStyle(_ value: String) -> String { + let sanitizedScalars = value.lowercased().unicodeScalars.filter { + CharacterSet.alphanumerics.contains($0) + } + let sanitized = String(String.UnicodeScalarView(sanitizedScalars)) + return sanitized.isEmpty ? "info" : sanitized + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift index 196a098..a334364 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift @@ -21,6 +21,10 @@ extension NativeEditorMarkdownParser { return diagramBlock } + if let containerBlock = docmostContainerHTMLBlock(in: lines, startingAt: index) { + return containerBlock + } + if let structuralBlock = docmostStructuralHTMLBlock(in: lines, startingAt: index) { return structuralBlock } @@ -42,6 +46,10 @@ extension NativeEditorMarkdownParser { return mediaMarkdown } + if let containerMarkdown = docmostContainerHTMLMarkdown(from: block) { + return containerMarkdown + } + if let structuralMarkdown = structuralMarkdownLine(from: block) { return structuralMarkdown } diff --git a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift index a45995f..48cae17 100644 --- a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift +++ b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift @@ -193,19 +193,17 @@ struct NativeEditorRichMarkdownExportTests { :::warning Check migration plan ::: -
- Release checklist - +
+ Release checklist +
Ship native editor - +
- $$ - E = mc^2 - $$ +
E = mc^2
""") } From 1adb9b48c30a214ee9c5088581421af4ccc87cfa Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 20:20:02 +0100 Subject: [PATCH 065/201] test: cover compact media html import --- .../NativeEditorMediaHTMLFidelityTests.swift | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/docmostlyTests/Editor/NativeEditorMediaHTMLFidelityTests.swift b/docmostlyTests/Editor/NativeEditorMediaHTMLFidelityTests.swift index 20b2fbf..c1f2c94 100644 --- a/docmostlyTests/Editor/NativeEditorMediaHTMLFidelityTests.swift +++ b/docmostlyTests/Editor/NativeEditorMediaHTMLFidelityTests.swift @@ -26,6 +26,17 @@ struct NativeEditorMediaHTMLFidelityTests { #expect(NativeEditorMarkdownParser.markdown(from: blocks) == markdown) } + @Test func importsCompactDocmostMediaAndEmbedHTMLAsTypedNativeBlocks() throws { + let blocks = NativeEditorMarkdownParser.blocks(from: compactDocmostHTMLMarkdown()) + + try #require(blocks.count == 5) + verifyVideo(blocks[0]) + verifyAudio(blocks[1]) + verifyPDF(blocks[2]) + verifyAttachment(blocks[3]) + verifyEmbed(blocks[4]) + } + private func nativeBlocks() -> [NativeEditorBlock] { [ NativeEditorBlock(kind: .image(imageBlock()), text: AttributedString("Hero"), alignment: .left), @@ -125,6 +136,16 @@ struct NativeEditorMediaHTMLFidelityTests { ].joined(separator: "\n") } + private func compactDocmostHTMLMarkdown() -> String { + [ + compactVideoHTML(), + compactAudioHTML(), + compactPDFHTML(), + compactAttachmentHTML(), + compactEmbedHTML() + ].joined(separator: "\n") + } + private func imageHTML() -> String { htmlTag("img", attributes: [ ("src", "/api/files/image-1/Hero.png"), @@ -146,6 +167,13 @@ struct NativeEditorMediaHTMLFidelityTests { """ } + private func compactVideoHTML() -> String { + [ + htmlTag("video", attributes: compactVideoAttributes()), + #""# + ].joined() + } + private func videoAttributes() -> [(String, String?)] { [ ("controls", "true"), @@ -160,6 +188,10 @@ struct NativeEditorMediaHTMLFidelityTests { ] } + private func compactVideoAttributes() -> [(String, String?)] { + videoAttributes().filter { key, _ in key != "src" } + } + private func audioHTML() -> String { """ \(htmlTag("audio", attributes: audioAttributes())) @@ -168,6 +200,13 @@ struct NativeEditorMediaHTMLFidelityTests { """ } + private func compactAudioHTML() -> String { + [ + htmlTag("audio", attributes: compactAudioAttributes()), + #""# + ].joined() + } + private func audioAttributes() -> [(String, String?)] { [ ("controls", "true"), @@ -178,6 +217,10 @@ struct NativeEditorMediaHTMLFidelityTests { ] } + private func compactAudioAttributes() -> [(String, String?)] { + audioAttributes().filter { key, _ in key != "src" } + } + private func pdfHTML() -> String { """ \(htmlTag("div", attributes: pdfContainerAttributes())) @@ -186,6 +229,13 @@ struct NativeEditorMediaHTMLFidelityTests { """ } + private func compactPDFHTML() -> String { + [ + htmlTag("div", attributes: compactPDFContainerAttributes()), + #""# + ].joined() + } + private func pdfContainerAttributes() -> [(String, String?)] { [ ("data-type", "pdf"), @@ -198,6 +248,10 @@ struct NativeEditorMediaHTMLFidelityTests { ] } + private func compactPDFContainerAttributes() -> [(String, String?)] { + pdfContainerAttributes().filter { key, _ in key != "src" } + } + private func attachmentHTML() -> String { """ \(htmlTag("div", attributes: attachmentContainerAttributes())) @@ -206,6 +260,14 @@ struct NativeEditorMediaHTMLFidelityTests { """ } + private func compactAttachmentHTML() -> String { + [ + htmlTag("div", attributes: compactAttachmentContainerAttributes()), + #""#, + "Archive.zip" + ].joined() + } + private func attachmentContainerAttributes() -> [(String, String?)] { [ ("data-type", "attachment"), @@ -217,6 +279,10 @@ struct NativeEditorMediaHTMLFidelityTests { ] } + private func compactAttachmentContainerAttributes() -> [(String, String?)] { + attachmentContainerAttributes().filter { key, _ in key != "data-attachment-url" } + } + private func embedHTML() -> String { """ \(htmlTag("div", attributes: embedContainerAttributes())) @@ -225,6 +291,14 @@ struct NativeEditorMediaHTMLFidelityTests { """ } + private func compactEmbedHTML() -> String { + [ + htmlTag("div", attributes: compactEmbedContainerAttributes()), + #""#, + "https://www.figma.com/file/demo" + ].joined() + } + private func embedContainerAttributes() -> [(String, String?)] { [ ("data-type", "embed"), @@ -236,6 +310,10 @@ struct NativeEditorMediaHTMLFidelityTests { ] } + private func compactEmbedContainerAttributes() -> [(String, String?)] { + embedContainerAttributes().filter { key, _ in key != "data-src" } + } + private func verifyImage(_ block: NativeEditorBlock) { guard case .image(let image) = block.kind else { Issue.record("Expected Docmost image HTML to import as a native image block.") From df8a8796993c735be5c0070e32df115cfa470ba6 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 20:28:07 +0100 Subject: [PATCH 066/201] fix: import compact media html blocks --- .../Editor/NativeEditorMarkdownParser+Embeds.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift index 3e9db26..aaae4e2 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift @@ -221,7 +221,7 @@ extension NativeEditorMarkdownParser { while currentIndex < lines.endIndex { let line = lines[currentIndex].trimmingCharacters(in: .whitespacesAndNewlines) - if let attributes = htmlTagAttributes(from: line, tagName: "source") { + if let attributes = firstHTMLTagAttributes(in: line, tagName: "source") { sourceAttributes = attributes } @@ -254,15 +254,15 @@ extension NativeEditorMarkdownParser { } var childAttributes: [String: String] = [:] - var currentIndex = lines.index(after: index) + var currentIndex = index while currentIndex < lines.endIndex { let line = lines[currentIndex].trimmingCharacters(in: .whitespacesAndNewlines) - if let attributes = htmlTagAttributes(from: line, tagName: type == "pdf" ? "iframe" : "a") { + if let attributes = firstHTMLTagAttributes(in: line, tagName: type == "pdf" ? "iframe" : "a") { childAttributes = attributes } - if line.localizedCaseInsensitiveCompare("") == .orderedSame { + if containsHTMLClosingTag(in: line, tagName: "div") { let block = typedMediaDivBlock(type: type, attributes: attributes, childAttributes: childAttributes) return (block, lines.index(after: currentIndex)) } From a4ad1c248d1675c9ef76dc9f7fd71a9ad95ca1b6 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 20:39:11 +0100 Subject: [PATCH 067/201] test: cover single segment file routes --- .../NativeEditorMarkdownImportTests.swift | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift index 94a5f46..ff181b9 100644 --- a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift +++ b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift @@ -103,6 +103,37 @@ struct NativeEditorMarkdownImportTests { #expect(blocks[3].rawNode?.attrs?["attachmentId"] == .string("file-1")) } + @Test func oneSegmentFilesRoutesDoNotImportAttachmentIDs() throws { + let blocks = NativeEditorMarkdownParser.blocks(from: """ + ![Manual](/api/files/manual.png) + [Spec.pdf](/api/files/manual.pdf) + [Archive.zip](/api/files/archive.zip) + """) + + try #require(blocks.count == 3) + + guard case .image(let image) = blocks[0].kind else { + Issue.record("Expected image Markdown to import as an image block.") + return + } + #expect(image.attachmentID == nil) + #expect(blocks[0].rawNode?.attrs?["attachmentId"] == nil) + + guard case .pdf(let pdf) = blocks[1].kind else { + Issue.record("Expected PDF Markdown to import as a PDF block.") + return + } + #expect(pdf.attachmentID == nil) + #expect(blocks[1].rawNode?.attrs?["attachmentId"] == nil) + + guard case .attachment(let attachment) = blocks[2].kind else { + Issue.record("Expected file Markdown to import as an attachment block.") + return + } + #expect(attachment.attachmentID == nil) + #expect(blocks[2].rawNode?.attrs?["attachmentId"] == nil) + } + @Test func markdownImageTitleImportsAsNativeMediaTitle() throws { let block = try #require(NativeEditorMarkdownParser.blocks( from: #"![Architecture](/files/image.png "System diagram")"# From a1031f81f087b6ffa594e8248e8f61740059576f Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 20:47:22 +0100 Subject: [PATCH 068/201] fix: guard docmost attachment id routes --- .../Editor/NativeEditorMarkdownParser+RichBlocks.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift index a334364..e1cde88 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift @@ -508,6 +508,9 @@ extension NativeEditorMarkdownParser { let attachmentIndex = pathComponents.index(after: filesIndex) guard pathComponents.indices.contains(attachmentIndex) else { return nil } + let filenameIndex = pathComponents.index(after: attachmentIndex) + guard pathComponents.indices.contains(filenameIndex) else { return nil } + let attachmentID = pathComponents[attachmentIndex] guard attachmentID.isEmpty == false else { return nil } return attachmentID.removingPercentEncoding ?? attachmentID From ebb828ab7cf03ce9234e5699883582f5718a03e6 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 20:57:54 +0100 Subject: [PATCH 069/201] test: cover inline span comment import --- .../NativeEditorInlineCommentMarkTests.swift | 35 +++++++++++++++++++ .../NativeEditorMentionMarkdownTests.swift | 22 ++++++++++++ 2 files changed, 57 insertions(+) diff --git a/docmostlyTests/Editor/NativeEditorInlineCommentMarkTests.swift b/docmostlyTests/Editor/NativeEditorInlineCommentMarkTests.swift index 0ab875f..1c79ceb 100644 --- a/docmostlyTests/Editor/NativeEditorInlineCommentMarkTests.swift +++ b/docmostlyTests/Editor/NativeEditorInlineCommentMarkTests.swift @@ -41,6 +41,41 @@ struct NativeEditorInlineCommentMarkTests { #expect(inlineNodes.contains { $0.marks?.contains(commentMark) == true }) } + @Test func markdownImportTreatsExplicitFalseResolvedAttributeAsUnresolved() throws { + let markdown = #"Review this copy today"# + let block = try #require( + NativeEditorMarkdownParser.blocks(from: markdown).first + ) + + let comment = NativeEditorInlineCommentMark(commentID: "comment-1", isResolved: false) + let markedRun = try #require(block.text.runs.first { $0.nativeEditorInlineComments == [comment] }) + #expect(String(block.text[markedRun.range].characters) == "this copy") + + let inlineNodes = try #require(NativeEditorDocument(blocks: [block]).proseMirrorDocument.content.first?.content) + let commentMark = ProseMirrorMark( + type: "comment", + attrs: ["commentId": .string("comment-1"), "resolved": .bool(false)] + ) + #expect(inlineNodes.contains { $0.marks?.contains(commentMark) == true }) + } + + @Test func markdownImportPreservesCommentBodyContainingLiteralSpanText() throws { + let markdown = #"Review use `` text today"# + let block = try #require( + NativeEditorMarkdownParser.blocks(from: markdown).first + ) + + let comment = NativeEditorInlineCommentMark(commentID: "comment-1", isResolved: false) + let markedRuns = block.text.runs.filter { $0.nativeEditorInlineComments == [comment] } + #expect(markedRuns.isEmpty == false) + + let markedText = markedRuns.reduce(into: "") { text, run in + text += String(block.text[run.range].characters) + } + #expect(markedText == "use text") + } + @Test func preservesOverlappingInlineCommentMarks() throws { let data = Data(""" { diff --git a/docmostlyTests/Editor/NativeEditorMentionMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorMentionMarkdownTests.swift index 44fc516..3435b96 100644 --- a/docmostlyTests/Editor/NativeEditorMentionMarkdownTests.swift +++ b/docmostlyTests/Editor/NativeEditorMentionMarkdownTests.swift @@ -131,6 +131,28 @@ struct NativeEditorMentionMarkdownTests { #expect(attrs["creatorId"] == .string("creator-1")) } + @Test func pasteMarkdownDocmostUserMentionHTMLAllowsLiteralSpanTextBeforeClosingSpan() throws { + let intro = NativeEditorBlock(kind: .paragraph, text: AttributedString("Intro"), alignment: .left) + let viewModel = configuredViewModel(blocks: [intro]) + viewModel.focus(blockID: intro.id) + let mentionHTML = #""# + + "@Taylor ``" + + viewModel.pasteMarkdown("Discuss \(mentionHTML) today") + + let inlineNodes = viewModel.document.proseMirrorDocument.content.last?.content ?? [] + #expect(inlineNodes.map(\.type) == ["text", "mention", "text"]) + #expect(inlineNodes[0].text == "Discuss ") + #expect(inlineNodes[2].text == " today") + + let attrs = try #require(inlineNodes[1].attrs) + #expect(attrs["id"] == .string("mention-1")) + #expect(attrs["label"] == .string("Taylor")) + #expect(attrs["entityType"] == .string("user")) + #expect(attrs["entityId"] == .string("user-1")) + } + private func userMentionHTML() -> String { #""# + From 302645b0fe0a8a86c96fab88ef8cb190fb7a4af9 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 21:01:53 +0100 Subject: [PATCH 070/201] fix: preserve inline comment span imports --- ...itorMarkdownParser+DocmostInlineHTML.swift | 129 +++++++++++++++++- ...iveEditorMarkdownParser+DocmostLinks.swift | 11 +- 2 files changed, 136 insertions(+), 4 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift index 7b02013..86cb795 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift @@ -82,8 +82,12 @@ extension NativeEditorMarkdownParser { while searchStart < markdown.endIndex, let closeRange = markdown[searchStart...].range(of: "", options: .caseInsensitive) { - if let nestedOpenRange = markdown[searchStart...].range(of: " Range? { + var currentSearchStart = searchStart + + while currentSearchStart < upperBound, + let openRange = markdown[currentSearchStart.. Bool { + var activeBacktickRunLength: Int? + var currentIndex = bodyStart + + while currentIndex < index { + guard markdown[currentIndex] == "`" else { + currentIndex = markdown.index(after: currentIndex) + continue + } + + var runLength = 0 + while currentIndex < index, markdown[currentIndex] == "`" { + runLength += 1 + currentIndex = markdown.index(after: currentIndex) + } + + if let activeLength = activeBacktickRunLength { + if activeLength == runLength { + activeBacktickRunLength = nil + } + } else { + activeBacktickRunLength = runLength + } + } + + return activeBacktickRunLength != nil + } + private static func openingHTMLTagRange(in text: String, tagName: String) -> Range? { var searchStart = text.startIndex @@ -114,7 +181,7 @@ extension NativeEditorMarkdownParser { let name = String(text[nameStart..") else { + let closeIndex = htmlOpeningTagCloseIndex(in: text, startingAt: nameEnd) else { searchStart = nameEnd continue } @@ -133,10 +200,66 @@ extension NativeEditorMarkdownParser { return currentIndex } + private static func htmlTagNameEnd(in text: Substring, startingAt index: String.Index) -> String.Index { + var currentIndex = index + while currentIndex < text.endIndex, text[currentIndex].isDocmostHTMLTagNameChar { + currentIndex = text.index(after: currentIndex) + } + return currentIndex + } + private static func isHTMLTagBoundary(at index: String.Index, in text: String) -> Bool { index == text.endIndex || text[index].isWhitespace || text[index] == ">" || text[index] == "/" } + private static func isHTMLTagBoundary(at index: String.Index, in text: Substring) -> Bool { + index == text.endIndex || text[index].isWhitespace || text[index] == ">" || text[index] == "/" + } + + private static func htmlOpeningTagCloseIndex(in text: String, startingAt index: String.Index) -> String.Index? { + var currentIndex = index + var quote: Character? + + while currentIndex < text.endIndex { + let character = text[currentIndex] + if let activeQuote = quote { + if character == activeQuote { + quote = nil + } + } else if character == "\"" || character == "'" { + quote = character + } else if character == ">" { + return currentIndex + } + + currentIndex = text.index(after: currentIndex) + } + + return nil + } + + private static func htmlOpeningTagCloseIndex(in text: Substring, startingAt index: String.Index) -> String.Index? { + var currentIndex = index + var quote: Character? + + while currentIndex < text.endIndex { + let character = text[currentIndex] + if let activeQuote = quote { + if character == activeQuote { + quote = nil + } + } else if character == "\"" || character == "'" { + quote = character + } else if character == ">" { + return currentIndex + } + + currentIndex = text.index(after: currentIndex) + } + + return nil + } + static func escapedInlineHTMLAttribute(_ text: String) -> String { escapedInlineHTMLText(text).replacing("\"", with: """) } diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift index 4089921..c4ec5df 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift @@ -342,7 +342,7 @@ extension NativeEditorMarkdownParser { range: openRange.lowerBound.. Bool { + guard let value else { return false } + + let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard normalizedValue.isEmpty == false else { return true } + + return normalizedValue != "false" && normalizedValue != "0" + } + private static func mention(from attrs: [String: String], fallbackHTMLText: String) -> NativeEditorMention { let entityType = attrs["data-entity-type"]?.nonEmpty let fallbackLabel = unescapedInlineHTMLText(fallbackHTMLText) From 4af924a22a8a18f8dd308767047af181d4924d47 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 21:17:11 +0100 Subject: [PATCH 071/201] test: cover iframe markdown link fidelity --- .../NativeEditorMarkdownImportTests.swift | 22 +++++++++++++++++++ .../NativeEditorRichMarkdownExportTests.swift | 19 ++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift index ff181b9..1f98019 100644 --- a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift +++ b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift @@ -360,6 +360,28 @@ struct NativeEditorMarkdownImportTests { #expect(block.rawNode?.attrs?["provider"] == .string("iframe")) } + @Test func docmostIframeMarkdownLinksWithFileExtensionsImportAsEmbedBlocks() throws { + let source = "https://player.example.com/embed/report.pdf" + let block = try #require(NativeEditorMarkdownParser.blocks(from: "[\(source)](\(source))").first) + + guard case .embed(let embed) = block.kind else { + Issue.record("Expected Docmost iframe Markdown link to import as a native embed block.") + return + } + + #expect(embed.source == source) + #expect(embed.provider == "iframe") + #expect(block.rawNode?.type == "embed") + } + + @Test func genericSelfLabeledMarkdownLinksRemainEditableParagraphText() throws { + let source = "https://example.com" + let block = try #require(NativeEditorMarkdownParser.blocks(from: "[\(source)](\(source))").first) + + #expect(block.kind == .paragraph) + #expect(String(block.text.characters) == source) + } + @Test func genericMarkdownLinksRemainEditableParagraphText() throws { let block = try #require(NativeEditorMarkdownParser.blocks(from: "[Example](https://example.com)").first) diff --git a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift index 48cae17..e99f03a 100644 --- a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift +++ b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift @@ -23,6 +23,25 @@ struct NativeEditorRichMarkdownExportTests { #expect(viewModel.markdownForDocument() == "[\(source)](\(source))") } + @Test func documentMarkdownConversionPreservesSizedIframeEmbedMarkdownShape() { + let source = "https://player.example.com/embed/demo" + let viewModel = configuredViewModel(blocks: [ + NativeEditorBlock( + kind: .embed(NativeEditorEmbedBlock( + source: source, + provider: "iframe", + alignment: "center", + width: "800", + height: "600" + )), + text: AttributedString(source), + alignment: .left + ) + ]) + + #expect(viewModel.markdownForDocument() == "[\(source)](\(source))") + } + @Test func documentMarkdownConversionPreservesImageTitle() { let viewModel = configuredViewModel(blocks: [ NativeEditorBlock( From db0a0aecaf40a2009f228a01398e37f330d251a3 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 21:24:17 +0100 Subject: [PATCH 072/201] fix: preserve iframe markdown links --- .../Editor/NativeEditorMarkdownParser+Embeds.swift | 11 +++++++++-- .../NativeEditorMarkdownParser+RichBlocks.swift | 4 ++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift index aaae4e2..f3905a1 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift @@ -146,6 +146,7 @@ extension NativeEditorMarkdownParser { } static func embedHTMLMarkdown(from embed: NativeEditorEmbedBlock) -> String? { + guard embed.provider?.lowercased() != "iframe" else { return nil } guard embedRequiresDocmostHTML(embed) else { return nil } let openingTag = htmlTag("div", attributes: [ @@ -439,7 +440,9 @@ extension NativeEditorMarkdownParser { } private static func embedRequiresDocmostHTML(_ embed: NativeEditorEmbedBlock) -> Bool { - embed.provider != nil && embed.provider != "iframe" || + guard embed.provider?.lowercased() != "iframe" else { return false } + + return embed.provider != nil || embed.alignment != nil || embed.width != nil || embed.height != nil @@ -524,7 +527,11 @@ extension NativeEditorMarkdownParser { return false } - return true + let path = components.percentEncodedPath.lowercased() + return path == "/embed" || + path.contains("/embed/") || + path == "/live-embed" || + path.contains("/live-embed/") } private static func htmlTag(_ name: String, attributes: [(String, String?)]) -> String { diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift index e1cde88..81f1800 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift @@ -37,8 +37,8 @@ extension NativeEditorMarkdownParser { } static func singleLineRichBlock(from line: String) -> NativeEditorBlock? { - pageBreakHTMLBlock(from: line) ?? imageMarkdownBlock(from: line) ?? linkedFileMarkdownBlock(from: line) ?? - iframeEmbedMarkdownBlock(from: line) + pageBreakHTMLBlock(from: line) ?? imageMarkdownBlock(from: line) ?? iframeEmbedMarkdownBlock(from: line) ?? + linkedFileMarkdownBlock(from: line) } static func richMarkdownLine(from block: NativeEditorBlock) -> String? { From 72fe0dcf04cd7b1298bcde476e6b63c9c6cc22fc Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 21:33:54 +0100 Subject: [PATCH 073/201] test: cover code slash cleanup --- .../Editor/NativeEditorSlashCommandTests.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift b/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift index 0e01dad..c20b93d 100644 --- a/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift +++ b/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift @@ -71,6 +71,20 @@ struct NativeEditorSlashCommandTests { #expect(viewModel.filteredSlashCommands.isEmpty) } + @Test func applyingCodeBlockSlashCommandClearsSlashToken() { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString("/code"), alignment: .left) + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: [block]) + viewModel.focus(blockID: block.id) + + viewModel.applySlashCommand(.codeBlock) + + #expect(viewModel.document.blocks[0].kind == .codeBlock(language: nil)) + #expect(String(viewModel.document.blocks[0].text.characters).isEmpty) + #expect(viewModel.isShowingSlashCommands == false) + #expect(viewModel.isDirty == true) + } + @Test func applyingColumnSlashCommandsCreatesDocmostColumnLayouts() { let expectations = [ ColumnCommandExpectation(command: .columns, layout: "two_equal", columnCount: 2), From 5395dc0c000d6b77bc5df460d2cfdf8b24ff3116 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 21:41:37 +0100 Subject: [PATCH 074/201] fix: clear code slash command text --- .../Editor/NativeRichEditorViewModel+BlockEditing.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift index 1c9ee50..db16b95 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift @@ -22,8 +22,9 @@ extension NativeRichEditorViewModel { return } + let isReplacingSlashCommand = activeSlashCommandQuery != nil document.blocks[index].kind = command.blockKind - if activeSlashCommandQuery != nil { + if isReplacingSlashCommand { document.blocks[index].text = AttributedString("") document.blocks[index].selection = AttributedTextSelection() } From eb4c0db5dcd5da1cb6fb3e5ee0fced1f31b3d83f Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 21:54:04 +0100 Subject: [PATCH 075/201] test: cover currency dollar markdown import --- .../NativeEditorMathMarkdownTests.swift | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift diff --git a/docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift new file mode 100644 index 0000000..fb080d5 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift @@ -0,0 +1,20 @@ +import Foundation +import Testing +@testable import docmostly + +struct NativeEditorMathMarkdownTests { + @Test func markdownImportKeepsCurrencyDollarAmountsAsPlainText() throws { + let block = try #require(NativeEditorMarkdownParser.blocks( + from: "Budget is $5 and $6 tomorrow" + ).first) + + #expect(String(block.text.characters) == "Budget is $5 and $6 tomorrow") + #expect(block.text.runs.contains { run in + run[NativeEditorMathInlineAttribute.self] != nil + } == false) + + let inlineNodes = try #require(NativeEditorDocument.node(from: block).content) + #expect(inlineNodes.map(\.type) == ["text"]) + #expect(inlineNodes.first?.text == "Budget is $5 and $6 tomorrow") + } +} From 669626d035ab1790102a678eb0687641d794336f Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 21:56:37 +0100 Subject: [PATCH 076/201] test: fix math markdown regression isolation --- docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift index fb080d5..2c5f599 100644 --- a/docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift +++ b/docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import docmostly +@MainActor struct NativeEditorMathMarkdownTests { @Test func markdownImportKeepsCurrencyDollarAmountsAsPlainText() throws { let block = try #require(NativeEditorMarkdownParser.blocks( From 40265ab7c625422cc3ea56341acc38bda93c15f2 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 22:05:34 +0100 Subject: [PATCH 077/201] fix: preserve currency dollars in markdown import --- ...ativeEditorMarkdownParser+InlineMath.swift | 156 ++++++++++++++++++ .../Editor/NativeEditorMarkdownParser.swift | 105 ------------ 2 files changed, 156 insertions(+), 105 deletions(-) create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMath.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMath.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMath.swift new file mode 100644 index 0000000..4ee2e18 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMath.swift @@ -0,0 +1,156 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func inlineText(from markdown: String) -> AttributedString { + var result = AttributedString("") + var remaining = markdown[...] + + while let inlineDelimiter = nextInlineMathDelimiter(in: remaining) { + let openRange = inlineDelimiter.range + appendMarkdownText( + String(remaining[.. AttributedString? { + guard let shortcut = trailingInlineMathShortcut(in: text) else { return nil } + + var result = AttributedString(String(text[.. Bool { + let trimmedMarkdown = markdown.trimmingCharacters(in: .whitespacesAndNewlines) + return result.characters.isEmpty && trimmedMarkdown.hasPrefix("<") == false + } + + private static func appendInlineMath(_ text: String, to result: inout AttributedString) { + let math = NativeEditorMathInline(text: text) + var segment = AttributedString(text) + segment[NativeEditorMathInlineAttribute.self] = math + segment.inlinePresentationIntent = .code + result += segment + } + + private static func trailingInlineMathShortcut( + in text: String + ) -> (openingRange: Range, text: String)? { + guard text.hasSuffix("$$") else { return nil } + + let closingStart = text.index(text.endIndex, offsetBy: -2) + guard + let openingRange = text.range( + of: "$$", + options: .backwards, + range: text.startIndex.. text.startIndex { + let previousIndex = text.index(before: openingRange.lowerBound) + guard text[previousIndex].isWhitespace else { return nil } + } + + return (openingRange, mathText) + } + + private static func isValidInlineMathDelimiter( + _ delimiter: String, + openingRange: Range, + closingRange: Range, + in markdown: Substring + ) -> Bool { + let content = markdown[openingRange.upperBound.. markdown.startIndex { + let previousIndex = markdown.index(before: openingRange.lowerBound) + guard markdown[previousIndex] == " " else { return false } + } + + if delimiter == "$", closingRange.upperBound < markdown.endIndex { + return markdown[closingRange.upperBound].isNumber == false + } + + return true + } + + private static func nextInlineMathDelimiter( + in markdown: Substring + ) -> (range: Range, value: String)? { + let singleDollarRange = markdown.range(of: "$") + let doubleDollarRange = markdown.range(of: "$$") + + if let doubleDollarRange, doubleDollarRange.lowerBound == singleDollarRange?.lowerBound { + return (doubleDollarRange, "$$") + } + + if let singleDollarRange { + return (singleDollarRange, "$") + } + + return nil + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift index 2505181..f631d67 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift @@ -301,111 +301,6 @@ enum NativeEditorMarkdownParser { ) } - static func inlineText(from markdown: String) -> AttributedString { - var result = AttributedString("") - var remaining = markdown[...] - - while let inlineDelimiter = nextInlineMathDelimiter(in: remaining) { - let openRange = inlineDelimiter.range - appendMarkdownText( - String(remaining[.. Bool { - let trimmedMarkdown = markdown.trimmingCharacters(in: .whitespacesAndNewlines) - return result.characters.isEmpty && trimmedMarkdown.hasPrefix("<") == false - } - - static func inlineMathInputRuleText(from text: String) -> AttributedString? { - guard let shortcut = trailingInlineMathShortcut(in: text) else { return nil } - - var result = AttributedString(String(text[.. (openingRange: Range, text: String)? { - guard text.hasSuffix("$$") else { return nil } - - let closingStart = text.index(text.endIndex, offsetBy: -2) - guard - let openingRange = text.range( - of: "$$", - options: .backwards, - range: text.startIndex.. text.startIndex { - let previousIndex = text.index(before: openingRange.lowerBound) - guard text[previousIndex].isWhitespace else { return nil } - } - - return (openingRange, mathText) - } - - private static func nextInlineMathDelimiter( - in markdown: Substring - ) -> (range: Range, value: String)? { - let singleDollarRange = markdown.range(of: "$") - let doubleDollarRange = markdown.range(of: "$$") - - if let doubleDollarRange, doubleDollarRange.lowerBound == singleDollarRange?.lowerBound { - return (doubleDollarRange, "$$") - } - - if let singleDollarRange { - return (singleDollarRange, "$") - } - - return nil - } - private static func markdownLine(from block: NativeEditorBlock) -> String { let indent = String(repeating: " ", count: block.indentLevel) let plainText = String(block.text.characters) From b8b96b3deca554dcce24e96a9b4901ec7a1c983a Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 22:12:02 +0100 Subject: [PATCH 078/201] fix: keep invalid inline math delimiters literal --- .../NativeEditorMarkdownParser+InlineMath.swift | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMath.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMath.swift index 4ee2e18..4f898e4 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMath.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMath.swift @@ -15,14 +15,7 @@ extension NativeEditorMarkdownParser { let contentStart = openRange.upperBound guard let closeRange = remaining[contentStart...].range(of: inlineDelimiter.value) else { - appendMarkdownText( - String(remaining), - to: &result, - usesFoundationMarkdownParser: shouldUseFoundationMarkdownParser( - for: String(remaining), - after: result - ) - ) + appendMarkdownText(String(remaining[openRange.lowerBound...]), to: &result) return result } @@ -33,7 +26,7 @@ extension NativeEditorMarkdownParser { in: remaining ) else { appendMarkdownText( - String(remaining[.. Date: Sun, 28 Jun 2026 22:18:14 +0100 Subject: [PATCH 079/201] test: assert currency import semantics --- docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift index 2c5f599..bf27e6f 100644 --- a/docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift +++ b/docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift @@ -15,7 +15,7 @@ struct NativeEditorMathMarkdownTests { } == false) let inlineNodes = try #require(NativeEditorDocument.node(from: block).content) - #expect(inlineNodes.map(\.type) == ["text"]) - #expect(inlineNodes.first?.text == "Budget is $5 and $6 tomorrow") + #expect(inlineNodes.contains { node in node.type == "mathInline" } == false) + #expect(inlineNodes.compactMap(\.text).joined() == "Budget is $5 and $6 tomorrow") } } From 3151a8a27691dce29f7c7b044812601590da6177 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 22:27:31 +0100 Subject: [PATCH 080/201] test: cover highlight markdown fidelity --- .../NativeEditorHighlightMarkdownTests.swift | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docmostlyTests/Editor/NativeEditorHighlightMarkdownTests.swift diff --git a/docmostlyTests/Editor/NativeEditorHighlightMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorHighlightMarkdownTests.swift new file mode 100644 index 0000000..0a4797d --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorHighlightMarkdownTests.swift @@ -0,0 +1,49 @@ +import Foundation +import SwiftUI +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorHighlightMarkdownTests { + @Test func markdownExportPreservesDocmostHighlightMarkAsHTML() { + var text = AttributedString("Review ") + var highlighted = AttributedString("important") + highlighted[NativeEditorHighlightColorAttribute.self] = "#FEF3C7" + highlighted[NativeEditorHighlightColorNameAttribute.self] = "yellow" + highlighted.backgroundColor = Color(docmostlyHex: "#FEF3C7") + text += highlighted + text += AttributedString(" today") + + let block = NativeEditorBlock(kind: .paragraph, text: text, alignment: .left) + let expectedMarkdown = ##"Review important today"## + + #expect(NativeEditorMarkdownParser.markdown(from: [block]) == expectedMarkdown) + } + + @Test func markdownImportPreservesDocmostHighlightMarkAsProseMirrorMark() throws { + let markdown = ##"Review "## + + "important **copy** today" + let block = try #require(NativeEditorMarkdownParser.blocks( + from: markdown + ).first) + + let highlightedRuns = block.text.runs.filter { run in + run[NativeEditorHighlightColorAttribute.self] == "#DCFCE7" && + run[NativeEditorHighlightColorNameAttribute.self] == "green" + } + #expect(highlightedRuns.count == 2) + + let boldRun = try #require(highlightedRuns.last) + #expect(String(block.text[boldRun.range].characters) == "copy") + #expect(boldRun.inlinePresentationIntent?.contains(.stronglyEmphasized) == true) + + let inlineNodes = try #require(NativeEditorDocument.node(from: block).content) + let highlightMark = ProseMirrorMark( + type: "highlight", + attrs: ["color": .string("#DCFCE7"), "colorName": .string("green")] + ) + #expect(inlineNodes.contains { $0.marks?.contains(highlightMark) == true }) + } +} From c83824e57ca2d18c96299d599a3de7573a14badd Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 22:34:50 +0100 Subject: [PATCH 081/201] fix: preserve highlight markdown marks --- ...iveEditorMarkdownParser+DocmostLinks.swift | 11 ++ ...NativeEditorMarkdownParser+Highlight.swift | 133 ++++++++++++++++++ .../Editor/NativeEditorMarkdownParser.swift | 2 +- 3 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+Highlight.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift index c4ec5df..aa86ec3 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift @@ -56,6 +56,17 @@ extension NativeEditorMarkdownParser { remaining = remaining[htmlStatus.range.upperBound...] } + while let htmlHighlight = nextDocmostHighlightHTML(in: remaining) { + appendMarkdownText( + String(remaining[.. + var color: String? + var colorName: String? + var bodyMarkdown: String + } + + static func highlightMarkdown( + from run: AttributedString.Runs.Run, + body: String + ) -> String { + let color = run[NativeEditorHighlightColorAttribute.self]?.trimmedNonEmpty + let colorName = run[NativeEditorHighlightColorNameAttribute.self]?.trimmedNonEmpty + guard color != nil || colorName != nil else { return body } + + var attrs: [(String, String)] = [] + if let color { + attrs.append(("data-color", color)) + attrs.append(("style", "background-color: \(color); color: inherit")) + } + if let colorName { + attrs.append(("data-highlight-color-name", colorName.lowercased())) + } + + let attrText = attrs + .map { name, value in #"\#(name)="\#(escapedInlineHTMLAttribute(value))""# } + .joined(separator: " ") + return "\(body)" + } + + static func nextDocmostHighlightHTML(in markdown: Substring) -> DocmostHighlightHTML? { + var searchStart = markdown.startIndex + + while searchStart < markdown.endIndex, + let openRange = markdown[searchStart...].range(of: "") else { + searchStart = openRange.upperBound + continue + } + + let contentStart = markdown.index(after: openTagEnd) + guard let closeRange = markdown[contentStart...].range(of: "", options: .caseInsensitive) else { + return nil + } + + let attrs = docmostInlineHTMLAttributes(from: String(markdown[openRange.lowerBound...openTagEnd])) + return DocmostHighlightHTML( + range: openRange.lowerBound.. String? { + if let color = attrs["data-color"]?.trimmedNonEmpty { + return color + } + + guard let style = attrs["style"] else { return nil } + return style + .split(separator: ";") + .compactMap { declaration -> String? in + let parts = declaration.split(separator: ":", maxSplits: 1).map(String.init) + guard parts.count == 2, + parts[0].trimmingCharacters(in: .whitespacesAndNewlines) + .localizedCaseInsensitiveCompare("background-color") == .orderedSame else { + return nil + } + + return parts[1].trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + } + .first + } + + private static func isHighlightHTMLTagBoundary(at index: String.Index, in text: Substring) -> Bool { + index == text.endIndex || text[index].isWhitespace || text[index] == ">" || text[index] == "/" + } +} + +private extension String { + var trimmedNonEmpty: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + var nonEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift index f631d67..b81e1a7 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift @@ -370,7 +370,7 @@ enum NativeEditorMarkdownParser { } else if let mention = run[NativeEditorMentionAttribute.self] { runMarkdown = mentionMarkdown(from: mention, fallbackText: runText) } else { - runMarkdown = inlineRunMarkdown(from: run, text: runText) + runMarkdown = highlightMarkdown(from: run, body: inlineRunMarkdown(from: run, text: runText)) } output += commentMarkdown(from: run.nativeEditorInlineComments, body: runMarkdown) From 1a4077db84e9fd61cab39b866faa7d325935bde3 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 22:42:25 +0100 Subject: [PATCH 082/201] test: cover text color markdown fidelity --- .../NativeEditorTextColorMarkdownTests.swift | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docmostlyTests/Editor/NativeEditorTextColorMarkdownTests.swift diff --git a/docmostlyTests/Editor/NativeEditorTextColorMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorTextColorMarkdownTests.swift new file mode 100644 index 0000000..5490f23 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorTextColorMarkdownTests.swift @@ -0,0 +1,44 @@ +import Foundation +import SwiftUI +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorTextColorMarkdownTests { + @Test func markdownExportPreservesDocmostTextColorMarkAsHTML() { + var text = AttributedString("Review ") + var colored = AttributedString("important") + colored[NativeEditorTextColorAttribute.self] = "#2563EB" + colored.foregroundColor = Color(docmostlyHex: "#2563EB") + text += colored + text += AttributedString(" today") + + let block = NativeEditorBlock(kind: .paragraph, text: text, alignment: .left) + + #expect( + NativeEditorMarkdownParser.markdown(from: [block]) == + ##"Review important today"## + ) + } + + @Test func markdownImportPreservesDocmostTextColorSpanAsProseMirrorMark() throws { + let markdown = ##"Review important **copy** today"## + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + + let coloredRuns = block.text.runs.filter { run in + run[NativeEditorTextColorAttribute.self] == "#2563EB" + } + #expect(coloredRuns.count == 2) + + let boldRun = try #require(coloredRuns.last) + #expect(String(block.text[boldRun.range].characters) == "copy") + #expect(boldRun.inlinePresentationIntent?.contains(.stronglyEmphasized) == true) + + let inlineNodes = try #require(NativeEditorDocument.node(from: block).content) + let textColorMark = ProseMirrorMark( + type: "textStyle", + attrs: ["color": .string("#2563EB")] + ) + #expect(inlineNodes.contains { $0.marks?.contains(textColorMark) == true }) + } +} From 86e166914a6c8c250976d1a663e3fc48ed67cd41 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 22:49:25 +0100 Subject: [PATCH 083/201] fix: preserve text color markdown marks --- ...iveEditorMarkdownParser+DocmostLinks.swift | 11 ++ ...NativeEditorMarkdownParser+TextColor.swift | 109 ++++++++++++++++++ .../Editor/NativeEditorMarkdownParser.swift | 6 +- 3 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+TextColor.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift index aa86ec3..f51b511 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift @@ -67,6 +67,17 @@ extension NativeEditorMarkdownParser { remaining = remaining[htmlHighlight.range.upperBound...] } + while let htmlTextColor = nextDocmostTextColorHTML(in: remaining) { + appendMarkdownText( + String(remaining[.. + var color: String + var bodyMarkdown: String + } + + static func textColorMarkdown( + from run: AttributedString.Runs.Run, + body: String + ) -> String { + guard let color = run[NativeEditorTextColorAttribute.self]?.trimmedNonEmpty else { return body } + + return #"\#(body)"# + } + + static func nextDocmostTextColorHTML(in markdown: Substring) -> DocmostTextColorHTML? { + var searchStart = markdown.startIndex + + while searchStart < markdown.endIndex, + let openRange = markdown[searchStart...].range(of: "") else { + searchStart = openRange.upperBound + continue + } + + let openingTag = String(markdown[openRange.lowerBound...openTagEnd]) + let attrs = docmostInlineHTMLAttributes(from: openingTag) + guard attrs["data-type"] == nil, + attrs["data-comment-id"] == nil, + let color = textColor(from: attrs) else { + searchStart = markdown.index(after: openRange.lowerBound) + continue + } + + let contentStart = markdown.index(after: openTagEnd) + guard let closeRange = matchingCloseSpanRange(in: markdown, bodyStart: contentStart) else { + return nil + } + + return DocmostTextColorHTML( + range: openRange.lowerBound.. String? { + guard let style = attrs["style"] else { return nil } + + return style + .split(separator: ";") + .compactMap { declaration -> String? in + let parts = declaration.split(separator: ":", maxSplits: 1).map(String.init) + guard parts.count == 2, + parts[0].trimmingCharacters(in: .whitespacesAndNewlines) + .localizedCaseInsensitiveCompare("color") == .orderedSame else { + return nil + } + + return parts[1].trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + } + .first + } + + private static func isTextColorHTMLTagBoundary(at index: String.Index, in text: Substring) -> Bool { + index == text.endIndex || text[index].isWhitespace || text[index] == ">" || text[index] == "/" + } +} + +private extension String { + var trimmedNonEmpty: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + var nonEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift index b81e1a7..5778960 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift @@ -370,7 +370,11 @@ enum NativeEditorMarkdownParser { } else if let mention = run[NativeEditorMentionAttribute.self] { runMarkdown = mentionMarkdown(from: mention, fallbackText: runText) } else { - runMarkdown = highlightMarkdown(from: run, body: inlineRunMarkdown(from: run, text: runText)) + let coloredMarkdown = textColorMarkdown( + from: run, + body: inlineRunMarkdown(from: run, text: runText) + ) + runMarkdown = highlightMarkdown(from: run, body: coloredMarkdown) } output += commentMarkdown(from: run.nativeEditorInlineComments, body: runMarkdown) From d11371ba4f3a698d7701b64193118bb71b6c77f5 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 22:58:58 +0100 Subject: [PATCH 084/201] test: cover relative table cell links --- .../Editor/NativeRichEditorTableTests.swift | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docmostlyTests/Editor/NativeRichEditorTableTests.swift b/docmostlyTests/Editor/NativeRichEditorTableTests.swift index a664c92..c4e4f88 100644 --- a/docmostlyTests/Editor/NativeRichEditorTableTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorTableTests.swift @@ -205,6 +205,33 @@ struct NativeRichEditorTableTests { #expect(preservedText.marks?.contains(ProseMirrorMark(type: "bold")) == true) } + @Test func markdownExportPreservesRelativeLinksInsideTableCells() { + let table = NativeEditorTable(rows: [ + NativeEditorTableRow(cells: [ + NativeEditorTableCell(plainText: "File", isHeader: true, backgroundColorName: nil) + ]), + NativeEditorTableRow(cells: [ + NativeEditorTableCell( + plainText: "Spec", + inlineContent: [ + .text("Spec", marks: [.link(href: "/api/files/file-1/Spec.pdf")]) + ], + isHeader: false, + backgroundColorName: nil + ) + ]) + ]) + + #expect( + NativeEditorMarkdownParser.tableMarkdown(from: table) == + """ + | File | + | --- | + | [Spec](/api/files/file-1/Spec.pdf) | + """ + ) + } + @Test func editingTableCellPreservesUnsupportedRichContentInOtherCells() throws { let data = Data(""" { From c84de041fbe403cc54fab3cbd1432a815388b784 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 23:05:26 +0100 Subject: [PATCH 085/201] fix: preserve relative table cell links --- .../NativeEditorMarkdownParser+Tables.swift | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift index b199789..aa7a45e 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift @@ -170,9 +170,58 @@ extension NativeEditorMarkdownParser { return cell.plainText } + if tableCellInlineContentContainsUnsafeLink(inlineContent) { + return markdownTableInlineContent(from: inlineContent) + } + return inlineMarkdown(from: NativeEditorDocument.attributedText(from: inlineContent)) } + private static func tableCellInlineContentContainsUnsafeLink( + _ inlineContent: [NativeEditorInlineContent] + ) -> Bool { + inlineContent.contains { item in + guard case .text(_, let marks) = item else { return false } + return marks.contains { mark in + guard case .link(let href) = mark else { return false } + return href.isEmpty == false && NativeEditorDocument.safeLinkURL(from: href) == nil + } + } + } + + private static func markdownTableInlineContent(from inlineContent: [NativeEditorInlineContent]) -> String { + inlineContent.map(markdownTableInlineContent).joined() + } + + private static func markdownTableInlineContent(from item: NativeEditorInlineContent) -> String { + guard case .text(let text, let marks) = item, + let href = unsafeTableCellLinkHref(from: marks) else { + return inlineMarkdown(from: NativeEditorDocument.attributedText(from: item)) + } + + let nonLinkMarks = marks.filter { mark in + guard case .link = mark else { return true } + return false + } + var segment = AttributedString(text) + NativeEditorDocument.apply(nonLinkMarks, to: &segment) + let label = escapedMarkdownTableLinkLabel(inlineMarkdown(from: segment)) + return "[\(label)](\(href))" + } + + private static func unsafeTableCellLinkHref(from marks: [NativeEditorTextMark]) -> String? { + marks.compactMap { mark -> String? in + guard case .link(let href) = mark, + href.isEmpty == false, + NativeEditorDocument.safeLinkURL(from: href) == nil else { + return nil + } + + return href + } + .first + } + private static func markdownTableSeparatorRow(columnCount: Int) -> String { "| \(Array(repeating: "---", count: columnCount).joined(separator: " | ")) |" } @@ -183,4 +232,11 @@ extension NativeEditorMarkdownParser { .replacing("\n", with: " ") .replacing("\r", with: " ") } + + private static func escapedMarkdownTableLinkLabel(_ text: String) -> String { + text + .replacing("\\", with: "\\\\") + .replacing("[", with: "\\[") + .replacing("]", with: "\\]") + } } From 2ecb0c8af2be2d1c22cabddda5c0b6eacf1e3bda Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 23:12:49 +0100 Subject: [PATCH 086/201] test: cover malformed status import recovery --- .../NativeEditorStatusMarkdownTests.swift | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docmostlyTests/Editor/NativeEditorStatusMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorStatusMarkdownTests.swift index 48c038a..1125bed 100644 --- a/docmostlyTests/Editor/NativeEditorStatusMarkdownTests.swift +++ b/docmostlyTests/Editor/NativeEditorStatusMarkdownTests.swift @@ -36,4 +36,24 @@ struct NativeEditorStatusMarkdownTests { #expect(inlineNodes[1].attrs?["text"] == .string("Ship")) #expect(inlineNodes[1].attrs?["color"] == .string("green")) } + + @Test func markdownImportSkipsMalformedStatusSpanAndPreservesLaterStatusAtom() throws { + let markdown = #"Stage Broken "# + + #"Ship now"# + let block = try #require(NativeEditorMarkdownParser.blocks( + from: markdown + ).first) + + let statusRun = try #require(block.text.runs.first { run in + run[NativeEditorStatusAttribute.self]?.text == "Ship" + }) + #expect(statusRun[NativeEditorStatusAttribute.self]?.color == "green") + + let inlineNodes = try #require(NativeEditorDocument.node(from: block).content) + #expect(inlineNodes.contains { node in + node.type == "status" && + node.attrs?["text"] == .string("Ship") && + node.attrs?["color"] == .string("green") + }) + } } From 65e951e4dfa99f271d0058d81e521b865cffb2b9 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 23:18:01 +0100 Subject: [PATCH 087/201] fix: recover after malformed status spans --- .../Features/Editor/NativeEditorMarkdownParser+Status.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Status.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Status.swift index 68d3c73..337c0b5 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Status.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Status.swift @@ -27,7 +27,8 @@ extension NativeEditorMarkdownParser { let contentStart = markdown.index(after: openTagEnd) guard let closeRange = matchingCloseSpanRange(in: markdown, bodyStart: contentStart) else { - return nil + searchStart = markdown.index(after: openRange.lowerBound) + continue } return ( From 8b47d57a969b1c999545b45d9d12d51605ccba16 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 23:27:14 +0100 Subject: [PATCH 088/201] test: cover image html title export --- .../Editor/NativeEditorMediaHTMLFidelityTests.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docmostlyTests/Editor/NativeEditorMediaHTMLFidelityTests.swift b/docmostlyTests/Editor/NativeEditorMediaHTMLFidelityTests.swift index c1f2c94..4f6aae2 100644 --- a/docmostlyTests/Editor/NativeEditorMediaHTMLFidelityTests.swift +++ b/docmostlyTests/Editor/NativeEditorMediaHTMLFidelityTests.swift @@ -56,7 +56,7 @@ struct NativeEditorMediaHTMLFidelityTests { NativeEditorMediaBlock( source: "/api/files/image-1/Hero.png", alternativeText: "Hero", - title: nil, + title: "Launch hero", attachmentID: "image-1", sizeInBytes: 2_048, width: "640", @@ -150,6 +150,7 @@ struct NativeEditorMediaHTMLFidelityTests { htmlTag("img", attributes: [ ("src", "/api/files/image-1/Hero.png"), ("alt", "Hero"), + ("title", "Launch hero"), ("width", "640"), ("height", "360"), ("data-align", "center"), @@ -322,6 +323,7 @@ struct NativeEditorMediaHTMLFidelityTests { #expect(image.source == "/api/files/image-1/Hero.png") #expect(image.alternativeText == "Hero") + #expect(image.title == "Launch hero") #expect(image.attachmentID == "image-1") #expect(image.sizeInBytes == 2_048) #expect(image.width == "640") @@ -329,6 +331,7 @@ struct NativeEditorMediaHTMLFidelityTests { #expect(image.aspectRatio == "1.7777778") #expect(image.alignment == "center") #expect(block.rawNode?.type == "image") + #expect(block.rawNode?.attrs?["title"] == .string("Launch hero")) #expect(block.rawNode?.attrs?["width"] == .int(640)) } From 2445d47ea37458877448aac9e18530e5ae16e3d0 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 23:34:51 +0100 Subject: [PATCH 089/201] fix: preserve image html titles --- .../Features/Editor/NativeEditorMarkdownParser+Embeds.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift index f3905a1..a0b31ec 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift @@ -370,6 +370,7 @@ extension NativeEditorMarkdownParser { htmlTag("img", attributes: [ ("src", media.source), ("alt", media.alternativeText), + ("title", media.title), ("width", media.width), ("height", media.height), ("data-align", media.alignment), From 53111b29e77a60292b40f1d0a2176c8ca721097d Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 23:43:19 +0100 Subject: [PATCH 090/201] test: cover details shortcut import --- .../Editor/NativeRichEditorMechanicsTests.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift b/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift index c907cbd..08282a6 100644 --- a/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift @@ -98,6 +98,19 @@ struct NativeRichEditorMechanicsTests { #expect(String(viewModel.document.blocks[0].text.characters).isEmpty) } + @Test func markdownImportSupportsDocmostDetailsShortcutAfterLineTrimming() throws { + let block = try #require(NativeEditorMarkdownParser.blocks(from: ":::details ").first) + + guard case .details(let details) = block.kind else { + Issue.record("Expected imported Docmost details shortcut to create a native details block.") + return + } + + #expect(details.summary == "Details") + #expect(details.previewText == "Details") + #expect(block.rawNode?.type == "details") + } + @Test func markdownInputRuleSupportsDocmostDefaultCalloutShortcut() { let block = NativeEditorBlock(kind: .paragraph, text: AttributedString(""), alignment: .left) let viewModel = configuredViewModel(blocks: [block]) From 96f70c4e8af2c2f510e75e303456775d9c1561b0 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 23:49:54 +0100 Subject: [PATCH 091/201] fix: import details shortcut after trimming --- docmostly/Features/Editor/NativeEditorMarkdownParser.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift index 5778960..60bce78 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift @@ -206,7 +206,8 @@ enum NativeEditorMarkdownParser { } private static func detailsInputRule(from text: String) -> NativeEditorMarkdownInputRule? { - guard text == ":::details " else { return nil } + let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedText == ":::details" else { return nil } let details = NativeEditorDetailsBlock(summary: "Details", previewText: "Details", isOpen: true) return NativeEditorMarkdownInputRule(kind: .details(details), text: details.summary) From 920049b091e89deca7851f6ca1d70883fab0b546 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sun, 28 Jun 2026 23:59:50 +0100 Subject: [PATCH 092/201] test: assert details shortcut encoded node --- docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift b/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift index 08282a6..21cfdf7 100644 --- a/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift @@ -108,7 +108,7 @@ struct NativeRichEditorMechanicsTests { #expect(details.summary == "Details") #expect(details.previewText == "Details") - #expect(block.rawNode?.type == "details") + #expect(NativeEditorDocument.node(from: block).type == "details") } @Test func markdownInputRuleSupportsDocmostDefaultCalloutShortcut() { From 94eebc57a5f5e359b9dcaffb839af2f5e74c0154 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 00:07:50 +0100 Subject: [PATCH 093/201] test: cover malformed column count clamp --- ...NativeRichEditorStructuralBlockTests.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docmostlyTests/Editor/NativeRichEditorStructuralBlockTests.swift b/docmostlyTests/Editor/NativeRichEditorStructuralBlockTests.swift index 5fe558a..29ebf5c 100644 --- a/docmostlyTests/Editor/NativeRichEditorStructuralBlockTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorStructuralBlockTests.swift @@ -66,6 +66,28 @@ struct NativeRichEditorStructuralBlockTests { #expect(Set([columnsWithoutWidths, columnsWithNilWidths]).count == 1) } + @Test func columnsNormalizationClampsDeclaredCountToDocmostMaximum() { + let malformedColumns = NativeEditorColumnsBlock( + layout: "five_equal", + widthMode: "normal", + columnCount: 999, + previewText: "Plan", + columnTexts: ["Plan"] + ) + let maximumColumns = NativeEditorColumnsBlock( + layout: "five_equal", + widthMode: "normal", + columnCount: 5, + previewText: "Plan", + columnTexts: ["Plan"] + ) + + let node = NativeEditorRichBlockNodeFactory.columnsNode(from: malformedColumns) + #expect(node.content?.count == 5) + #expect(malformedColumns == maximumColumns) + #expect(Set([malformedColumns, maximumColumns]).count == 1) + } + @Test func updatesSyncedBlockIdentifiers() { let viewModel = structuralBlockViewModel() let sourceID = viewModel.document.blocks[1].id From 0fb67c2c023839cc1a32dfe438c15d70a9beddfd Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 00:13:19 +0100 Subject: [PATCH 094/201] fix: clamp malformed columns normalization --- docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift b/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift index ad72abc..ba7c643 100644 --- a/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift +++ b/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift @@ -144,6 +144,8 @@ nonisolated struct NativeEditorDetailsBlock: Equatable, Hashable, Sendable { } nonisolated struct NativeEditorColumnsBlock: Hashable, Sendable { + private static let maximumColumnCount = 5 + var layout: String var widthMode: String var columnCount: Int @@ -181,7 +183,7 @@ nonisolated struct NativeEditorColumnsBlock: Hashable, Sendable { } private var normalizedColumnCount: Int { - max(columnCount, 1) + min(max(columnCount, 1), Self.maximumColumnCount) } private func normalizedValues(_ values: [Value], fallback: Value) -> [Value] { From 286241f0079e265e78324b8ae8b63277b08a451a Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 00:20:40 +0100 Subject: [PATCH 095/201] test: cover slash word-start later match --- docmostlyTests/Editor/NativeEditorSlashCommandTests.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift b/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift index c20b93d..fdd2e53 100644 --- a/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift +++ b/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift @@ -57,6 +57,10 @@ struct NativeEditorSlashCommandTests { } } + @Test func slashCommandTitleWordStartPriorityScansPastMidWordMatches() { + #expect(NativeEditorCommand.iframeEmbed.matchPriority(query: "e") == 0) + } + @Test func slashCommandMenuIsDisabledInsideCodeBlocks() { let block = NativeEditorBlock( kind: .codeBlock(language: nil), From 3c00d107b0ecb0f635a402016c61e94eb785505a Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 00:29:57 +0100 Subject: [PATCH 096/201] fix: scan slash title word-start matches --- .../Editor/NativeEditorCommand+Behavior.swift | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift b/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift index 8ca2ff8..9d2ffe4 100644 --- a/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift +++ b/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift @@ -150,12 +150,25 @@ extension NativeEditorCommand { private extension String { func localizedStandardContainsAtWordStart(_ query: String) -> Bool { - guard let range = localizedStandardRange(of: query) else { return false } - guard range.lowerBound != startIndex else { return true } + guard query.isEmpty == false else { return true } - let previousIndex = index(before: range.lowerBound) - let previousCharacter = self[previousIndex] - return previousCharacter.isWhitespace || previousCharacter.isPunctuation + var searchStart = startIndex + while searchStart < endIndex { + let searchText = self[searchStart.. Bool { From 350a05da6702353f163c7d7457a548e67d3b389a Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 00:37:23 +0100 Subject: [PATCH 097/201] test: assert default table slash shape --- .../Editor/NativeRichEditorViewModelTests.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift b/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift index 52e40f1..5084844 100644 --- a/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift @@ -92,7 +92,7 @@ struct NativeRichEditorViewModelTests { Issue.record("Expected table block") return } - #expect(table.rows.count == 3) + expectDocmostDefaultTableShape(table) #expect(viewModel.document.blocks[0].isEditable == false) #expect(viewModel.document.proseMirrorDocument.content.first?.type == "table") #expect(viewModel.document.proseMirrorDocument.content.first?.content?.count == 3) @@ -493,3 +493,10 @@ private struct SlashCommandExpectation { let nodeType: String let label: String } + +private func expectDocmostDefaultTableShape(_ table: NativeEditorTable) { + #expect(table.rows.count == 3) + #expect(table.columnCount == 3) + #expect(table.rows.first?.cells.allSatisfy(\.isHeader) == true) + #expect(table.rows.dropFirst().flatMap(\.cells).allSatisfy { $0.isHeader == false }) +} From 84de1c7fd539a6d68636d5327e159b184a0a0b90 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 00:43:44 +0100 Subject: [PATCH 098/201] test: cover markdown link title delimiters --- .../NativeEditorMarkdownLinkTitleTests.swift | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 docmostlyTests/Editor/NativeEditorMarkdownLinkTitleTests.swift diff --git a/docmostlyTests/Editor/NativeEditorMarkdownLinkTitleTests.swift b/docmostlyTests/Editor/NativeEditorMarkdownLinkTitleTests.swift new file mode 100644 index 0000000..13bfe79 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorMarkdownLinkTitleTests.swift @@ -0,0 +1,26 @@ +import Foundation +import Testing +@testable import docmostly + +struct NativeEditorMarkdownLinkTitleTests { + @Test func imageTitleImportSupportsCommonMarkdownTitleDelimiters() throws { + let markdownCases = [ + #"![Architecture](/files/image.png 'System diagram')"#, + #"![Architecture](/files/image.png (System diagram))"#, + #"![Architecture]( "System diagram")"# + ] + + for markdown in markdownCases { + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + + guard case .image(let image) = block.kind else { + Issue.record("Expected Markdown image to import as a native image block.") + return + } + + #expect(image.source == "/files/image.png") + #expect(image.title == "System diagram") + #expect(block.rawNode?.attrs?["title"] == .string("System diagram")) + } + } +} From 456446e2e53fac1245633be14ad3f78cef200efa Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 00:46:00 +0100 Subject: [PATCH 099/201] test: run markdown link title tests on main actor --- docmostlyTests/Editor/NativeEditorMarkdownLinkTitleTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/docmostlyTests/Editor/NativeEditorMarkdownLinkTitleTests.swift b/docmostlyTests/Editor/NativeEditorMarkdownLinkTitleTests.swift index 13bfe79..176a186 100644 --- a/docmostlyTests/Editor/NativeEditorMarkdownLinkTitleTests.swift +++ b/docmostlyTests/Editor/NativeEditorMarkdownLinkTitleTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import docmostly +@MainActor struct NativeEditorMarkdownLinkTitleTests { @Test func imageTitleImportSupportsCommonMarkdownTitleDelimiters() throws { let markdownCases = [ From f5ee237f83d0feb818ee965c8a56000cfeb853ca Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 00:53:44 +0100 Subject: [PATCH 100/201] fix: parse common markdown link titles --- ...torMarkdownParser+MarkdownLinkTitles.swift | 103 ++++++++++++++++-- ...ativeEditorMarkdownParser+RichBlocks.swift | 15 --- 2 files changed, 92 insertions(+), 26 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+MarkdownLinkTitles.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+MarkdownLinkTitles.swift index 3957999..c2812de 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+MarkdownLinkTitles.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+MarkdownLinkTitles.swift @@ -2,18 +2,11 @@ import Foundation extension NativeEditorMarkdownParser { static func markdownLinkTitle(from destination: String) -> String? { - let destination = destination.trimmingCharacters(in: .whitespacesAndNewlines) - guard - let titleRange = destination.range(of: " \""), - destination.hasSuffix("\"") - else { - return nil - } + markdownLinkDestinationParts(from: destination).title + } - let titleStart = titleRange.upperBound - let titleEnd = destination.index(before: destination.endIndex) - let title = unescapedMarkdownLinkTitle(String(destination[titleStart.. String { + markdownLinkDestinationParts(from: destination).source } static func markdownLinkTitlePart(from title: String?) -> String { @@ -49,4 +42,92 @@ extension NativeEditorMarkdownParser { return result } + + private static func markdownLinkDestinationParts(from destination: String) -> (source: String, title: String?) { + let destination = destination.trimmingCharacters(in: .whitespacesAndNewlines) + guard let titleMatch = markdownLinkTitleMatch(in: destination) else { + return (normalizedMarkdownLinkSource(destination), nil) + } + + let rawTitle = String(destination[titleMatch.titleRange]) + let title = unescapedMarkdownLinkTitle(rawTitle) + let source = normalizedMarkdownLinkSource(String(destination[.. MarkdownLinkTitleMatch? { + guard destination.isEmpty == false else { return nil } + + let closeIndex = destination.index(before: destination.endIndex) + guard isEscapedCharacter(at: closeIndex, in: destination) == false else { return nil } + + switch destination[closeIndex] { + case "\"": + return delimiterTitleMatch(in: destination, openDelimiter: "\"", closeIndex: closeIndex) + case "'": + return delimiterTitleMatch(in: destination, openDelimiter: "'", closeIndex: closeIndex) + case ")": + return delimiterTitleMatch(in: destination, openDelimiter: "(", closeIndex: closeIndex) + default: + return nil + } + } + + private static func delimiterTitleMatch( + in destination: String, + openDelimiter: Character, + closeIndex: String.Index + ) -> MarkdownLinkTitleMatch? { + var match: MarkdownLinkTitleMatch? + var index = destination.startIndex + + while index < closeIndex { + if destination[index] == openDelimiter, + isEscapedCharacter(at: index, in: destination) == false, + index != destination.startIndex { + let previousIndex = destination.index(before: index) + if destination[previousIndex].isWhitespace { + match = MarkdownLinkTitleMatch( + sourceEnd: previousIndex, + titleRange: destination.index(after: index).. String { + var source = source.trimmingCharacters(in: .whitespacesAndNewlines) + + if source.hasPrefix("<"), source.hasSuffix(">") { + source.removeFirst() + source.removeLast() + } + + return source.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func isEscapedCharacter(at index: String.Index, in text: String) -> Bool { + var backslashCount = 0 + var currentIndex = index + + while currentIndex > text.startIndex { + let previousIndex = text.index(before: currentIndex) + guard text[previousIndex] == "\\" else { break } + + backslashCount += 1 + currentIndex = previousIndex + } + + return backslashCount.isMultiple(of: 2) == false + } +} + +private struct MarkdownLinkTitleMatch { + let sourceEnd: String.Index + let titleRange: Range } diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift index 81f1800..bfb6081 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift @@ -464,21 +464,6 @@ extension NativeEditorMarkdownParser { return unescapedHTMLText(String(line[contentStart.. String { - var source = destination.trimmingCharacters(in: .whitespacesAndNewlines) - - if source.hasPrefix("<"), source.hasSuffix(">") { - source.removeFirst() - source.removeLast() - } - - if let titleRange = source.range(of: " \"") { - source = String(source[.. String? { let path = markdownLinkPath(from: source) guard From af65b6687439b591d7be076d093d4b6db8f85f64 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 01:03:57 +0100 Subject: [PATCH 101/201] test: cover iconless callout html export --- ...tiveEditorContainerHTMLFidelityTests.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docmostlyTests/Editor/NativeEditorContainerHTMLFidelityTests.swift b/docmostlyTests/Editor/NativeEditorContainerHTMLFidelityTests.swift index 94ba530..cc1c70a 100644 --- a/docmostlyTests/Editor/NativeEditorContainerHTMLFidelityTests.swift +++ b/docmostlyTests/Editor/NativeEditorContainerHTMLFidelityTests.swift @@ -93,4 +93,26 @@ struct NativeEditorContainerHTMLFidelityTests {
E = mc^2
""") } + + @Test func exportsIconlessNativeCalloutAsDocmostHTML() { + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: [ + NativeEditorBlock( + kind: .callout(NativeEditorCalloutBlock( + style: "info", + icon: nil, + previewText: "Check migration plan" + )), + text: AttributedString("Check migration plan"), + alignment: .left + ) + ]) + viewModel.resetEditingHistory() + + #expect(viewModel.markdownForDocument() == """ +
+ Check migration plan +
+ """) + } } From ca938ab086002288e750f602e575ba2ecd277984 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 01:12:10 +0100 Subject: [PATCH 102/201] fix: export iconless callouts as docmost html --- .../Editor/NativeEditorMarkdownParser+ContainerHTML.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+ContainerHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+ContainerHTML.swift index 0b2836a..065fbca 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+ContainerHTML.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+ContainerHTML.swift @@ -19,7 +19,7 @@ extension NativeEditorMarkdownParser { static func docmostContainerHTMLMarkdown(from block: NativeEditorBlock) -> String? { switch block.kind { case .callout(let callout): - callout.icon == nil ? nil : calloutHTMLMarkdown(from: callout) + calloutHTMLMarkdown(from: callout) case .details(let details): detailsHTMLMarkdown(from: details) case .mathBlock(let math): From 749b4a0de3be663863b96d2ff2e23c37e5ed30b5 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 01:20:09 +0100 Subject: [PATCH 103/201] test: update rich markdown callout html fixture --- .../Editor/NativeEditorRichMarkdownExportTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift index e99f03a..a11969c 100644 --- a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift +++ b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift @@ -209,9 +209,9 @@ struct NativeEditorRichMarkdownExportTests { [Release audio.m4a](/files/audio.m4a) [Spec.pdf](/files/spec.pdf) [Archive.zip](/files/archive.zip) - :::warning +
Check migration plan - ::: +
Release checklist
From f4855ac1a98b7fd02ac6b18724cc1ea35b5e5c8d Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 01:26:57 +0100 Subject: [PATCH 104/201] test: cover nested docmost embed html import --- .../NativeEditorMediaHTMLFidelityTests.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docmostlyTests/Editor/NativeEditorMediaHTMLFidelityTests.swift b/docmostlyTests/Editor/NativeEditorMediaHTMLFidelityTests.swift index 4f6aae2..6d56499 100644 --- a/docmostlyTests/Editor/NativeEditorMediaHTMLFidelityTests.swift +++ b/docmostlyTests/Editor/NativeEditorMediaHTMLFidelityTests.swift @@ -37,6 +37,22 @@ struct NativeEditorMediaHTMLFidelityTests { verifyEmbed(blocks[4]) } + @Test func importsDocmostEmbedHTMLWithNestedProviderDivBeforeLink() throws { + let markdown = """ +
+
+ Provider metadata +
+ https://www.figma.com/file/demo +
+ """ + let blocks = NativeEditorMarkdownParser.blocks(from: markdown) + + try #require(blocks.count == 1) + verifyEmbed(blocks[0]) + #expect(NativeEditorMarkdownParser.markdown(from: blocks) == embedHTML()) + } + private func nativeBlocks() -> [NativeEditorBlock] { [ NativeEditorBlock(kind: .image(imageBlock()), text: AttributedString("Hero"), alignment: .left), From 3fdf2f038d04439d0538f65c22f64b17ed7f0985 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 01:36:03 +0100 Subject: [PATCH 105/201] fix: parse nested docmost media containers --- ...itorMarkdownParser+DocmostInlineHTML.swift | 47 +++++++++++++++++++ .../NativeEditorMarkdownParser+Embeds.swift | 4 +- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift index 86cb795..6836ce8 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift @@ -73,6 +73,42 @@ extension NativeEditorMarkdownParser { return false } + static func htmlTagDepthDelta(in line: String, tagName: String) -> Int { + let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) + var delta = 0 + var searchStart = trimmedLine.startIndex + + while searchStart < trimmedLine.endIndex, + let openIndex = trimmedLine[searchStart...].firstIndex(of: "<") { + var nameStart = trimmedLine.index(after: openIndex) + guard nameStart < trimmedLine.endIndex else { break } + + let isClosingTag = trimmedLine[nameStart] == "/" + if isClosingTag { + nameStart = trimmedLine.index(after: nameStart) + } + + let nameEnd = htmlTagNameEnd(in: trimmedLine, startingAt: nameStart) + let name = String(trimmedLine[nameStart.. Bool { + guard closeIndex > text.startIndex else { return false } + + var currentIndex = text.index(before: closeIndex) + while currentIndex > text.startIndex, text[currentIndex].isWhitespace { + currentIndex = text.index(before: currentIndex) + } + + return text[currentIndex] == "/" + } + static func escapedInlineHTMLAttribute(_ text: String) -> String { escapedInlineHTMLText(text).replacing("\"", with: """) } diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift index a0b31ec..e1be03c 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift @@ -256,6 +256,7 @@ extension NativeEditorMarkdownParser { var childAttributes: [String: String] = [:] var currentIndex = index + var containerDepth = 0 while currentIndex < lines.endIndex { let line = lines[currentIndex].trimmingCharacters(in: .whitespacesAndNewlines) @@ -263,7 +264,8 @@ extension NativeEditorMarkdownParser { childAttributes = attributes } - if containsHTMLClosingTag(in: line, tagName: "div") { + containerDepth += htmlTagDepthDelta(in: line, tagName: "div") + if containerDepth <= 0 { let block = typedMediaDivBlock(type: type, attributes: attributes, childAttributes: childAttributes) return (block, lines.index(after: currentIndex)) } From 815e4c6264a2ea2e7c76cb87452413d2dc05b0b6 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 01:46:31 +0100 Subject: [PATCH 106/201] test: cover locale independent html tag matching --- .../Editor/NativeEditorHTMLTagMatchingTests.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 docmostlyTests/Editor/NativeEditorHTMLTagMatchingTests.swift diff --git a/docmostlyTests/Editor/NativeEditorHTMLTagMatchingTests.swift b/docmostlyTests/Editor/NativeEditorHTMLTagMatchingTests.swift new file mode 100644 index 0000000..e5668af --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorHTMLTagMatchingTests.swift @@ -0,0 +1,13 @@ +import Foundation +import Testing +@testable import docmostly + +struct NativeEditorHTMLTagMatchingTests { + @Test func htmlTagNameMatchingIsLocaleIndependent() { + let turkish = Locale(identifier: "tr_TR") + + #expect(NativeEditorMarkdownParser.htmlTagNameMatches("DIV", "div", locale: turkish)) + #expect(NativeEditorMarkdownParser.htmlTagNameMatches("IMG", "img", locale: turkish)) + #expect(NativeEditorMarkdownParser.htmlTagNameMatches("SPAN", "span", locale: turkish)) + } +} From 5b44e0e6bff0b3e21a3f475a443f82a2da910623 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 01:50:52 +0100 Subject: [PATCH 107/201] fix: make html tag matching locale independent --- ...ativeEditorMarkdownParser+DocmostInlineHTML.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift index 6836ce8..952cba6 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift @@ -63,7 +63,7 @@ extension NativeEditorMarkdownParser { nameStart = trimmedLine.index(after: nameStart) let nameEnd = htmlTagNameEnd(in: trimmedLine, startingAt: nameStart) let name = String(trimmedLine[nameStart.. Bool { + name.compare(tagName, options: .caseInsensitive) == .orderedSame + } + private static func htmlTagNameEnd(in text: String, startingAt index: String.Index) -> String.Index { var currentIndex = index while currentIndex < text.endIndex, text[currentIndex].isDocmostHTMLTagNameChar { From 342ee2921f78278e9d59a90bf3ef4e0f55eba79c Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 01:53:54 +0100 Subject: [PATCH 108/201] fix: keep html tag matcher nonisolated --- .../Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift index 952cba6..943e10c 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift @@ -228,7 +228,7 @@ extension NativeEditorMarkdownParser { return nil } - static func htmlTagNameMatches(_ name: String, _ tagName: String, locale _: Locale? = nil) -> Bool { + nonisolated static func htmlTagNameMatches(_ name: String, _ tagName: String, locale _: Locale? = nil) -> Bool { name.compare(tagName, options: .caseInsensitive) == .orderedSame } From d33454b2f0644e05154de19e75a4bbbaf1fcdacf Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 01:56:29 +0100 Subject: [PATCH 109/201] fix: precompute markdown code spans --- ...itorMarkdownParser+DocmostInlineHTML.swift | 53 +++++++++++++++---- .../NativeEditorHTMLTagMatchingTests.swift | 15 ++++++ 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift index 943e10c..ffec254 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift @@ -113,6 +113,7 @@ extension NativeEditorMarkdownParser { in markdown: Substring, bodyStart: String.Index ) -> Range? { + let codeSpanRanges = markdownCodeSpanRanges(in: markdown, bodyStart: bodyStart) var depth = 1 var searchStart = bodyStart @@ -122,7 +123,7 @@ extension NativeEditorMarkdownParser { in: markdown, startingAt: searchStart, before: closeRange.lowerBound, - bodyStart: bodyStart + codeSpanRanges: codeSpanRanges ) { depth += 1 searchStart = nestedOpenRange.upperBound @@ -143,13 +144,13 @@ extension NativeEditorMarkdownParser { in markdown: Substring, startingAt searchStart: String.Index, before upperBound: String.Index, - bodyStart: String.Index + codeSpanRanges: [Range] ) -> Range? { var currentSearchStart = searchStart while currentSearchStart < upperBound, let openRange = markdown[currentSearchStart.. Bool { + ) -> [Range] { + var ranges = [Range]() + var activeBacktickRunStart: String.Index? var activeBacktickRunLength: Int? var currentIndex = bodyStart - while currentIndex < index { + while currentIndex < markdown.endIndex { guard markdown[currentIndex] == "`" else { currentIndex = markdown.index(after: currentIndex) continue } + let runStart = currentIndex var runLength = 0 - while currentIndex < index, markdown[currentIndex] == "`" { + while currentIndex < markdown.endIndex, markdown[currentIndex] == "`" { runLength += 1 currentIndex = markdown.index(after: currentIndex) } if let activeLength = activeBacktickRunLength { if activeLength == runLength { + if let activeBacktickRunStart { + ranges.append(activeBacktickRunStart..] + ) -> Bool { + var low = ranges.startIndex + var high = ranges.endIndex + + while low < high { + let mid = low + (high - low) / 2 + let range = ranges[mid] + + if index < range.lowerBound { + high = mid + } else if index >= range.upperBound { + low = mid + 1 + } else { + return true + } + } + + return false } private static func openingHTMLTagRange(in text: String, tagName: String) -> Range? { diff --git a/docmostlyTests/Editor/NativeEditorHTMLTagMatchingTests.swift b/docmostlyTests/Editor/NativeEditorHTMLTagMatchingTests.swift index e5668af..b660a77 100644 --- a/docmostlyTests/Editor/NativeEditorHTMLTagMatchingTests.swift +++ b/docmostlyTests/Editor/NativeEditorHTMLTagMatchingTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import docmostly +@MainActor struct NativeEditorHTMLTagMatchingTests { @Test func htmlTagNameMatchingIsLocaleIndependent() { let turkish = Locale(identifier: "tr_TR") @@ -10,4 +11,18 @@ struct NativeEditorHTMLTagMatchingTests { #expect(NativeEditorMarkdownParser.htmlTagNameMatches("IMG", "img", locale: turkish)) #expect(NativeEditorMarkdownParser.htmlTagNameMatches("SPAN", "span", locale: turkish)) } + + @Test func matchingCloseSpanRangeIgnoresSpanCandidatesInsideMarkdownCode() throws { + let markdown = ##"`` "## + + ##"nested outer"## + let openingTagEnd = try #require(markdown.firstIndex(of: ">")) + let bodyStart = markdown.index(after: openingTagEnd) + + let closeRange = try #require( + NativeEditorMarkdownParser.matchingCloseSpanRange(in: markdown[...], bodyStart: bodyStart) + ) + let expectedCloseRange = try #require(markdown.range(of: "", options: .backwards)) + + #expect(closeRange == expectedCloseRange) + } } From 2a932b4a54ecab394fefe5ee9fb869cc5891ddc7 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 02:04:45 +0100 Subject: [PATCH 110/201] fix: skip code span html closures --- .../NativeEditorMarkdownParser+DocmostInlineHTML.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift index ffec254..4dfada5 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift @@ -119,6 +119,11 @@ extension NativeEditorMarkdownParser { while searchStart < markdown.endIndex, let closeRange = markdown[searchStart...].range(of: "", options: .caseInsensitive) { + if isInsideMarkdownCodeSpan(closeRange.lowerBound, ranges: codeSpanRanges) { + searchStart = closeRange.upperBound + continue + } + if let nestedOpenRange = nextOpeningSpanRange( in: markdown, startingAt: searchStart, From c99ad61f45cb3cfa45b489bd10faf91650ab67d3 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 02:17:57 +0100 Subject: [PATCH 111/201] test: cover table background color fidelity --- .../NativeEditorTablePayloadTests.swift | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift b/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift index 7239858..5d65e5a 100644 --- a/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift +++ b/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift @@ -53,4 +53,56 @@ struct NativeEditorTablePayloadTests { #expect(cell.columnWidths == [120, 160]) #expect(cell.backgroundColorName == "blue") } + + @Test func preservesTableCellBackgroundColorAttribute() throws { + let data = Data(""" + { + "type": "doc", + "content": [ + { + "type": "table", + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": { + "backgroundColor": "rgb(254, 243, 199)", + "backgroundColorName": "yellow" + }, + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Risk" }] + } + ] + } + ] + } + ] + } + ] + } + """.utf8) + + let document = try NativeEditorDocument(proseMirrorJSONData: data) + + guard case .table(let table) = document.blocks.first?.kind else { + Issue.record("Expected table block") + return + } + + let cell = try #require(table.rows.first?.cells.first) + #expect(cell.backgroundColorName == "yellow") + + let reencodedDocument = NativeEditorDocument(blocks: [ + NativeEditorBlock(kind: .table(table), text: AttributedString("Table"), alignment: .left) + ]) + let encodedCell = try #require( + reencodedDocument.proseMirrorDocument.content.first?.content?.first?.content?.first + ) + #expect(encodedCell.attrs?["backgroundColor"] == .string("rgb(254, 243, 199)")) + #expect(encodedCell.attrs?["backgroundColorName"] == .string("yellow")) + } } From afe14080a11e885d2f9fed957f4c56e6218ab4be Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 02:26:43 +0100 Subject: [PATCH 112/201] fix: preserve table cell background colors --- .../NativeEditorDocument+Payloads.swift | 1 + .../NativeEditorRichBlockPayloads.swift | 3 ++ .../Editor/NativeEditorTableLayout.swift | 34 +++++++++++++++++++ .../NativeRichEditorViewModel+Tables.swift | 4 +++ .../NativeEditorTablePayloadTests.swift | 1 + .../Editor/NativeRichEditorTableTests.swift | 2 ++ 6 files changed, 45 insertions(+) diff --git a/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift b/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift index 176b2b2..92bcaf6 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift @@ -171,6 +171,7 @@ nonisolated extension NativeEditorDocument { inlineContent: inlineContent.preservedForTableCell, preservedContent: preservedTableCellContent(from: cellContent, inlineContent: inlineContent), isHeader: cell.type == "tableHeader", + backgroundColor: cell.attrs?["backgroundColor"]?.stringValue, backgroundColorName: cell.attrs?["backgroundColorName"]?.stringValue, columnWidth: columnWidths.first, columnSpan: normalizedTableSpan(cell.attrs?["colspan"]?.intValue), diff --git a/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift b/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift index ba7c643..93cfc5d 100644 --- a/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift +++ b/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift @@ -30,6 +30,7 @@ nonisolated struct NativeEditorTableCell: Equatable, Hashable, Sendable { var inlineContent: [NativeEditorInlineContent]? var preservedContent: [ProseMirrorNode]? var isHeader: Bool + var backgroundColor: String? var backgroundColorName: String? var columnWidth: Int? var columnSpan: Int = 1 @@ -41,6 +42,7 @@ nonisolated struct NativeEditorTableCell: Equatable, Hashable, Sendable { inlineContent: [NativeEditorInlineContent]? = nil, preservedContent: [ProseMirrorNode]? = nil, isHeader: Bool, + backgroundColor: String? = nil, backgroundColorName: String?, columnWidth: Int? = nil, columnSpan: Int = 1, @@ -51,6 +53,7 @@ nonisolated struct NativeEditorTableCell: Equatable, Hashable, Sendable { self.inlineContent = inlineContent self.preservedContent = preservedContent self.isHeader = isHeader + self.backgroundColor = backgroundColor self.backgroundColorName = backgroundColorName self.columnWidth = columnWidth self.columnSpan = columnSpan diff --git a/docmostly/Features/Editor/NativeEditorTableLayout.swift b/docmostly/Features/Editor/NativeEditorTableLayout.swift index f8a4d77..7146b84 100644 --- a/docmostly/Features/Editor/NativeEditorTableLayout.swift +++ b/docmostly/Features/Editor/NativeEditorTableLayout.swift @@ -1,3 +1,4 @@ +import Foundation import SwiftUI enum NativeEditorTableLayout { @@ -25,6 +26,11 @@ enum NativeEditorTableLayout { } static func cellBackground(for cell: NativeEditorTableCell) -> Color { + if let backgroundColor = cell.backgroundColor, + let cssBackground = backgroundColor(cssValue: backgroundColor) { + return cssBackground + } + if let backgroundColorName = cell.backgroundColorName, let namedBackground = backgroundColor(for: backgroundColorName) { return namedBackground @@ -33,6 +39,34 @@ enum NativeEditorTableLayout { return cell.isHeader ? Color.secondary.opacity(0.12) : Color.clear } + private static func backgroundColor(cssValue: String) -> Color? { + let trimmedValue = cssValue.trimmingCharacters(in: .whitespacesAndNewlines) + if let hexColor = Color(docmostlyHex: trimmedValue) { + return hexColor + } + + let lowercasedValue = trimmedValue.lowercased() + guard lowercasedValue.hasPrefix("rgb(") || lowercasedValue.hasPrefix("rgba("), + let openParen = trimmedValue.firstIndex(of: "("), + let closeParen = trimmedValue.lastIndex(of: ")") else { + return nil + } + + let components = trimmedValue[trimmedValue.index(after: openParen)..= 3 else { return nil } + + let opacity = components.indices.contains(3) ? min(max(components[3], 0), 1) : 1 + return Color( + red: min(max(components[0], 0), 255) / 255, + green: min(max(components[1], 0), 255) / 255, + blue: min(max(components[2], 0), 255) / 255, + opacity: opacity + ) + } + private static func backgroundColor(for name: String) -> Color? { switch name.lowercased() { case "blue": diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+Tables.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+Tables.swift index a57ee59..d8ac274 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+Tables.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+Tables.swift @@ -137,6 +137,10 @@ nonisolated enum NativeEditorTableNodeFactory { private static func cellAttrs(from cell: NativeEditorTableCell) -> [String: ProseMirrorJSONValue]? { var attrs: [String: ProseMirrorJSONValue] = [:] + if let backgroundColor = cell.backgroundColor, backgroundColor.isEmpty == false { + attrs["backgroundColor"] = .string(backgroundColor) + } + if let backgroundColorName = cell.backgroundColorName, backgroundColorName.isEmpty == false { attrs["backgroundColorName"] = .string(backgroundColorName.lowercased()) } diff --git a/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift b/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift index 5d65e5a..231feaf 100644 --- a/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift +++ b/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift @@ -94,6 +94,7 @@ struct NativeEditorTablePayloadTests { } let cell = try #require(table.rows.first?.cells.first) + #expect(cell.backgroundColor == "rgb(254, 243, 199)") #expect(cell.backgroundColorName == "yellow") let reencodedDocument = NativeEditorDocument(blocks: [ diff --git a/docmostlyTests/Editor/NativeRichEditorTableTests.swift b/docmostlyTests/Editor/NativeRichEditorTableTests.swift index c4e4f88..f18876a 100644 --- a/docmostlyTests/Editor/NativeRichEditorTableTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorTableTests.swift @@ -100,6 +100,7 @@ struct NativeRichEditorTableTests { NativeEditorTableCell( plainText: "Merged", isHeader: true, + backgroundColor: "rgb(219, 234, 254)", backgroundColorName: "blue", columnWidth: 120, columnSpan: 2, @@ -124,6 +125,7 @@ struct NativeRichEditorTableTests { #expect(firstCell?.attrs?["colspan"] == .int(2)) #expect(firstCell?.attrs?["rowspan"] == .int(2)) #expect(firstCell?.attrs?["colwidth"] == .array([.int(120), .int(160)])) + #expect(firstCell?.attrs?["backgroundColor"] == .string("rgb(219, 234, 254)")) #expect(firstCell?.attrs?["backgroundColorName"] == .string("blue")) #expect(firstCell?.content?.first?.content?.first?.text == "Updated") } From 9da8acc47d48d47488690f96315b30d9e9899869 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 02:30:05 +0100 Subject: [PATCH 113/201] fix: avoid table color helper shadowing --- docmostly/Features/Editor/NativeEditorTableLayout.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorTableLayout.swift b/docmostly/Features/Editor/NativeEditorTableLayout.swift index 7146b84..bf55b51 100644 --- a/docmostly/Features/Editor/NativeEditorTableLayout.swift +++ b/docmostly/Features/Editor/NativeEditorTableLayout.swift @@ -27,7 +27,7 @@ enum NativeEditorTableLayout { static func cellBackground(for cell: NativeEditorTableCell) -> Color { if let backgroundColor = cell.backgroundColor, - let cssBackground = backgroundColor(cssValue: backgroundColor) { + let cssBackground = cssBackgroundColor(from: backgroundColor) { return cssBackground } @@ -39,8 +39,8 @@ enum NativeEditorTableLayout { return cell.isHeader ? Color.secondary.opacity(0.12) : Color.clear } - private static func backgroundColor(cssValue: String) -> Color? { - let trimmedValue = cssValue.trimmingCharacters(in: .whitespacesAndNewlines) + private static func cssBackgroundColor(from value: String) -> Color? { + let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) if let hexColor = Color(docmostlyHex: trimmedValue) { return hexColor } From f6ae47f3afd9f44382a276f3f0782658455fed6c Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 02:51:11 +0100 Subject: [PATCH 114/201] test: cover editable block id fidelity --- .../NativeEditorEditableBlockIDTests.swift | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 docmostlyTests/Editor/NativeEditorEditableBlockIDTests.swift diff --git a/docmostlyTests/Editor/NativeEditorEditableBlockIDTests.swift b/docmostlyTests/Editor/NativeEditorEditableBlockIDTests.swift new file mode 100644 index 0000000..d97107d --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorEditableBlockIDTests.swift @@ -0,0 +1,41 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorEditableBlockIDTests { + @Test func preservesEditableBlockIDsAfterNativeEdits() throws { + let data = Data(""" + { + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": { "level": 2, "id": "heading-deep-link", "textAlign": "center" }, + "content": [{ "type": "text", "text": "Plan" }] + }, + { + "type": "paragraph", + "attrs": { "id": "paragraph-node" }, + "content": [{ "type": "text", "text": "Body" }] + } + ] + } + """.utf8) + var document = try NativeEditorDocument(proseMirrorJSONData: data) + + document.blocks[0].text = AttributedString("Updated plan") + document.blocks[1].text = AttributedString("Updated body") + + let encodedData = try document.proseMirrorJSONData() + let root = try #require(JSONSerialization.jsonObject(with: encodedData) as? [String: Any]) + let content = try #require(root["content"] as? [[String: Any]]) + let headingAttrs = try #require(content[0]["attrs"] as? [String: Any]) + let paragraphAttrs = try #require(content[1]["attrs"] as? [String: Any]) + + #expect(headingAttrs["id"] as? String == "heading-deep-link") + #expect(headingAttrs["level"] as? Int == 2) + #expect(headingAttrs["textAlign"] as? String == "center") + #expect(paragraphAttrs["id"] as? String == "paragraph-node") + } +} From 0a71e436da6f0dbea27b1576df35a7e8e0506022 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 03:00:36 +0100 Subject: [PATCH 115/201] fix: preserve editable block ids --- .../NativeEditorDocument+Decoding.swift | 23 ++++++++++++++++++- .../NativeEditorDocument+Encoding.swift | 16 ++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorDocument+Decoding.swift b/docmostly/Features/Editor/NativeEditorDocument+Decoding.swift index bd5b33e..ab11e69 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+Decoding.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+Decoding.swift @@ -16,13 +16,15 @@ nonisolated extension NativeEditorDocument { static func textBlock(kind: NativeEditorBlockKind, node: ProseMirrorNode) -> NativeEditorBlock { let inlineContent = inlineContent(from: node.content ?? []) let needsRawPreservation = inlineContent.contains(where: \.requiresRawPreservation) + let needsAttrPreservation = editableAttrsNeedRawPreservation(kind: kind, node: node) + let rawNode = needsRawPreservation || needsAttrPreservation ? node : nil return NativeEditorBlock( kind: kind, text: attributedText(from: inlineContent), alignment: NativeEditorTextAlignment(attrs: node.attrs), inlineContent: needsRawPreservation ? inlineContent : nil, - rawNode: needsRawPreservation ? node : nil + rawNode: rawNode ) } @@ -60,6 +62,25 @@ nonisolated extension NativeEditorDocument { } } + private static func editableAttrsNeedRawPreservation( + kind: NativeEditorBlockKind, + node: ProseMirrorNode + ) -> Bool { + guard let attrs = node.attrs, attrs.isEmpty == false else { return false } + + let modeledKeys: Set + switch kind { + case .paragraph: + modeledKeys = ["textAlign"] + case .heading: + modeledKeys = ["level", "textAlign"] + default: + return false + } + + return attrs.keys.contains { modeledKeys.contains($0) == false } + } + private static func editableBlocks(from node: ProseMirrorNode) -> [NativeEditorBlock]? { if let singleBlock = singleEditableBlock(from: node) { return [singleBlock] diff --git a/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift b/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift index abbbd6b..aeac7d1 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift @@ -31,9 +31,15 @@ nonisolated extension NativeEditorDocument { block: NativeEditorBlock, attrs: [String: ProseMirrorJSONValue] = [:] ) -> ProseMirrorNode { - var mergedAttrs = attrs + var mergedAttrs = preservedEditableAttrs(type: type, block: block) + for attr in attrs { + mergedAttrs[attr.key] = attr.value + } + if let alignment = block.alignment.proseMirrorValue { mergedAttrs["textAlign"] = alignment + } else { + mergedAttrs.removeValue(forKey: "textAlign") } return ProseMirrorNode( @@ -43,6 +49,14 @@ nonisolated extension NativeEditorDocument { ) } + private static func preservedEditableAttrs( + type: String, + block: NativeEditorBlock + ) -> [String: ProseMirrorJSONValue] { + guard block.rawNode?.type == type else { return [:] } + return block.rawNode?.attrs ?? [:] + } + static func inlineNodes(from attributedText: AttributedString) -> [ProseMirrorNode] { var nodes: [ProseMirrorNode] = [] From 1f8fb2f6d45ca1341a7e99d537bc5260b5db2d94 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 03:18:55 +0100 Subject: [PATCH 116/201] test: stabilize parallel iOS unit checks --- .../Editor/NativeEditorJSCRDTRuntimeSourceTests.swift | 4 ++-- .../Networking/MultipartFormDataBodyTests.swift | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docmostlyTests/Editor/NativeEditorJSCRDTRuntimeSourceTests.swift b/docmostlyTests/Editor/NativeEditorJSCRDTRuntimeSourceTests.swift index eb448d6..20fedf6 100644 --- a/docmostlyTests/Editor/NativeEditorJSCRDTRuntimeSourceTests.swift +++ b/docmostlyTests/Editor/NativeEditorJSCRDTRuntimeSourceTests.swift @@ -200,8 +200,8 @@ struct NativeEditorJSCRDTRuntimeSourceTests { #expect(selection.anchor.targetName == NativeEditorCollaborationDocument.yjsFragmentName) #expect(selection.head.targetName == NativeEditorCollaborationDocument.yjsFragmentName) - #expect(selection.anchor.type.client != 0) - #expect(selection.head.type.client != 0) + #expect(selection.anchor.assoc == 0) + #expect(selection.head.assoc == 0) } private static let runtimeSource = """ diff --git a/docmostlyTests/Networking/MultipartFormDataBodyTests.swift b/docmostlyTests/Networking/MultipartFormDataBodyTests.swift index 94dd343..b54a7de 100644 --- a/docmostlyTests/Networking/MultipartFormDataBodyTests.swift +++ b/docmostlyTests/Networking/MultipartFormDataBodyTests.swift @@ -4,7 +4,7 @@ import Testing struct MultipartFormDataBodyTests { @Test func writesFieldsAndFileIntoMultipartBody() throws { - let sourceURL = URL.temporaryDirectory.appending(path: "docmostly-upload-source.txt") + let sourceURL = temporarySourceURL() try "hello attachment".write(to: sourceURL, atomically: true, encoding: .utf8) defer { try? FileManager.default.removeItem(at: sourceURL) @@ -40,7 +40,7 @@ struct MultipartFormDataBodyTests { } @Test func rejectsHeaderControlCharacters() throws { - let sourceURL = URL.temporaryDirectory.appending(path: "docmostly-upload-source.txt") + let sourceURL = temporarySourceURL() try "hello attachment".write(to: sourceURL, atomically: true, encoding: .utf8) defer { try? FileManager.default.removeItem(at: sourceURL) @@ -94,4 +94,8 @@ struct MultipartFormDataBodyTests { ) #expect(remainingFiles.isEmpty) } + + private func temporarySourceURL() -> URL { + URL.temporaryDirectory.appending(path: "docmostly-upload-source-\(UUID().uuidString).txt") + } } From 366d74cb0a7735f533cfa613b7a5b52a30cfc021 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 03:21:51 +0100 Subject: [PATCH 117/201] test: harden editable block id regression --- .../Editor/NativeEditorEditableBlockIDTests.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docmostlyTests/Editor/NativeEditorEditableBlockIDTests.swift b/docmostlyTests/Editor/NativeEditorEditableBlockIDTests.swift index d97107d..fe50208 100644 --- a/docmostlyTests/Editor/NativeEditorEditableBlockIDTests.swift +++ b/docmostlyTests/Editor/NativeEditorEditableBlockIDTests.swift @@ -30,12 +30,19 @@ struct NativeEditorEditableBlockIDTests { let encodedData = try document.proseMirrorJSONData() let root = try #require(JSONSerialization.jsonObject(with: encodedData) as? [String: Any]) let content = try #require(root["content"] as? [[String: Any]]) - let headingAttrs = try #require(content[0]["attrs"] as? [String: Any]) - let paragraphAttrs = try #require(content[1]["attrs"] as? [String: Any]) + #expect(content.count == 2) + let headingNode = try #require(content.first) + let paragraphNode = try #require(content.dropFirst().first) + let headingAttrs = try #require(headingNode["attrs"] as? [String: Any]) + let paragraphAttrs = try #require(paragraphNode["attrs"] as? [String: Any]) + let headingContent = try #require(headingNode["content"] as? [[String: Any]]) + let paragraphContent = try #require(paragraphNode["content"] as? [[String: Any]]) #expect(headingAttrs["id"] as? String == "heading-deep-link") #expect(headingAttrs["level"] as? Int == 2) #expect(headingAttrs["textAlign"] as? String == "center") + #expect(headingContent.first?["text"] as? String == "Updated plan") #expect(paragraphAttrs["id"] as? String == "paragraph-node") + #expect(paragraphContent.first?["text"] as? String == "Updated body") } } From a65fd7c43568c0580ead2649a6985f85f0b7a08d Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 03:33:22 +0100 Subject: [PATCH 118/201] test: cover reviewed markdown fidelity gaps --- .../NativeEditorHTMLTagMatchingTests.swift | 6 +++ .../NativeEditorHighlightMarkdownTests.swift | 18 +++++++++ .../NativeEditorMarkdownLinkTitleTests.swift | 9 ++++- .../NativeEditorMathMarkdownTests.swift | 17 +++++++++ .../NativeEditorTablePayloadTests.swift | 17 +++++++++ .../NativeEditorTextColorMarkdownTests.swift | 37 +++++++++++++++++++ .../Editor/NativeRichEditorTableTests.swift | 27 ++++++++++++++ 7 files changed, 130 insertions(+), 1 deletion(-) diff --git a/docmostlyTests/Editor/NativeEditorHTMLTagMatchingTests.swift b/docmostlyTests/Editor/NativeEditorHTMLTagMatchingTests.swift index b660a77..e146e27 100644 --- a/docmostlyTests/Editor/NativeEditorHTMLTagMatchingTests.swift +++ b/docmostlyTests/Editor/NativeEditorHTMLTagMatchingTests.swift @@ -25,4 +25,10 @@ struct NativeEditorHTMLTagMatchingTests { #expect(closeRange == expectedCloseRange) } + + @Test func htmlTagDepthDeltaIgnoresTagLookalikesInsideQuotedAttributes() { + let line = #"
x
"# + + #expect(NativeEditorMarkdownParser.htmlTagDepthDelta(in: line, tagName: "div") == 0) + } } diff --git a/docmostlyTests/Editor/NativeEditorHighlightMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorHighlightMarkdownTests.swift index 0a4797d..bbf0085 100644 --- a/docmostlyTests/Editor/NativeEditorHighlightMarkdownTests.swift +++ b/docmostlyTests/Editor/NativeEditorHighlightMarkdownTests.swift @@ -46,4 +46,22 @@ struct NativeEditorHighlightMarkdownTests { ) #expect(inlineNodes.contains { $0.marks?.contains(highlightMark) == true }) } + + @Test func markdownImportIgnoresHighlightHTMLInsideCodeSpans() throws { + let markdown = ##"Keep `literal` then "## + + ##"real"## + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + + let codeRun = try #require(block.text.runs.first { run in + String(block.text[run.range].characters) == ##"literal"## + }) + #expect(codeRun.inlinePresentationIntent?.contains(.code) == true) + #expect(codeRun[NativeEditorHighlightColorAttribute.self] == nil) + + let highlightRun = try #require(block.text.runs.first { run in + String(block.text[run.range].characters) == "real" + }) + #expect(highlightRun[NativeEditorHighlightColorAttribute.self] == "#DCFCE7") + #expect(highlightRun[NativeEditorHighlightColorNameAttribute.self] == "green") + } } diff --git a/docmostlyTests/Editor/NativeEditorMarkdownLinkTitleTests.swift b/docmostlyTests/Editor/NativeEditorMarkdownLinkTitleTests.swift index 176a186..52f729a 100644 --- a/docmostlyTests/Editor/NativeEditorMarkdownLinkTitleTests.swift +++ b/docmostlyTests/Editor/NativeEditorMarkdownLinkTitleTests.swift @@ -16,7 +16,7 @@ struct NativeEditorMarkdownLinkTitleTests { guard case .image(let image) = block.kind else { Issue.record("Expected Markdown image to import as a native image block.") - return + continue } #expect(image.source == "/files/image.png") @@ -24,4 +24,11 @@ struct NativeEditorMarkdownLinkTitleTests { #expect(block.rawNode?.attrs?["title"] == .string("System diagram")) } } + + @Test func escapedWhitespaceBeforeDelimiterDoesNotStartMarkdownLinkTitle() { + let destination = #"/files/my\ "diagram.png""# + + #expect(NativeEditorMarkdownParser.markdownLinkSource(from: destination) == destination) + #expect(NativeEditorMarkdownParser.markdownLinkTitle(from: destination) == nil) + } } diff --git a/docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift index bf27e6f..4d3efe6 100644 --- a/docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift +++ b/docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift @@ -18,4 +18,21 @@ struct NativeEditorMathMarkdownTests { #expect(inlineNodes.contains { node in node.type == "mathInline" } == false) #expect(inlineNodes.compactMap(\.text).joined() == "Budget is $5 and $6 tomorrow") } + + @Test func markdownImportKeepsMathDelimitersInsideCodeSpansAsCodeText() throws { + let block = try #require(NativeEditorMarkdownParser.blocks( + from: "Use `$value$` and $$total$$" + ).first) + + let codeRun = try #require(block.text.runs.first { run in + String(block.text[run.range].characters) == "$value$" + }) + #expect(codeRun.inlinePresentationIntent?.contains(.code) == true) + #expect(codeRun[NativeEditorMathInlineAttribute.self] == nil) + + let mathRun = try #require(block.text.runs.first { run in + run[NativeEditorMathInlineAttribute.self]?.text == "total" + }) + #expect(String(block.text[mathRun.range].characters) == "total") + } } diff --git a/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift b/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift index 231feaf..e4ca729 100644 --- a/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift +++ b/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftUI import Testing @testable import docmostly @@ -106,4 +107,20 @@ struct NativeEditorTablePayloadTests { #expect(encodedCell.attrs?["backgroundColor"] == .string("rgb(254, 243, 199)")) #expect(encodedCell.attrs?["backgroundColorName"] == .string("yellow")) } + + @Test func tableCellBackgroundRespectsRGBAAlphaPercentages() { + let cell = NativeEditorTableCell( + plainText: "Risk", + isHeader: false, + backgroundColor: "rgba(255, 0, 0, 50%)", + backgroundColorName: nil + ) + + let resolved = NativeEditorTableLayout.cellBackground(for: cell).resolve(in: EnvironmentValues()) + + #expect(resolved.red == 1) + #expect(resolved.green == 0) + #expect(resolved.blue == 0) + #expect(resolved.opacity == 0.5) + } } diff --git a/docmostlyTests/Editor/NativeEditorTextColorMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorTextColorMarkdownTests.swift index 5490f23..863dd07 100644 --- a/docmostlyTests/Editor/NativeEditorTextColorMarkdownTests.swift +++ b/docmostlyTests/Editor/NativeEditorTextColorMarkdownTests.swift @@ -41,4 +41,41 @@ struct NativeEditorTextColorMarkdownTests { ) #expect(inlineNodes.contains { $0.marks?.contains(textColorMark) == true }) } + + @Test func markdownImportIgnoresTextColorHTMLInsideCodeSpans() throws { + let markdown = ##"Keep `literal` then "## + + ##"real"## + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + + let codeRun = try #require(block.text.runs.first { run in + String(block.text[run.range].characters) == #"literal"# + }) + #expect(codeRun.inlinePresentationIntent?.contains(.code) == true) + #expect(codeRun[NativeEditorTextColorAttribute.self] == nil) + + let coloredRun = try #require(block.text.runs.first { run in + String(block.text[run.range].characters) == "real" + }) + #expect(coloredRun[NativeEditorTextColorAttribute.self] == "#2563EB") + } + + @Test func markdownImportPreservesNestedTextColorSpans() throws { + let markdown = ##"outer "## + + ##"inner tail"## + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + + let outerRun = try #require(block.text.runs.first { run in + String(block.text[run.range].characters) == "outer " + }) + let innerRun = try #require(block.text.runs.first { run in + String(block.text[run.range].characters) == "inner" + }) + let tailRun = try #require(block.text.runs.first { run in + String(block.text[run.range].characters) == " tail" + }) + + #expect(outerRun[NativeEditorTextColorAttribute.self] == "#111827") + #expect(innerRun[NativeEditorTextColorAttribute.self] == "#2563EB") + #expect(tailRun[NativeEditorTextColorAttribute.self] == "#111827") + } } diff --git a/docmostlyTests/Editor/NativeRichEditorTableTests.swift b/docmostlyTests/Editor/NativeRichEditorTableTests.swift index f18876a..946bda2 100644 --- a/docmostlyTests/Editor/NativeRichEditorTableTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorTableTests.swift @@ -234,6 +234,33 @@ struct NativeRichEditorTableTests { ) } + @Test func markdownExportWrapsUnsafeRelativeLinksInsideTableCells() { + let table = NativeEditorTable(rows: [ + NativeEditorTableRow(cells: [ + NativeEditorTableCell(plainText: "File", isHeader: true, backgroundColorName: nil) + ]), + NativeEditorTableRow(cells: [ + NativeEditorTableCell( + plainText: "Spec", + inlineContent: [ + .text("Spec", marks: [.link(href: "/p/project plan)/Spec.pdf")]) + ], + isHeader: false, + backgroundColorName: nil + ) + ]) + ]) + + #expect( + NativeEditorMarkdownParser.tableMarkdown(from: table) == + """ + | File | + | --- | + | [Spec](

) | + """ + ) + } + @Test func editingTableCellPreservesUnsupportedRichContentInOtherCells() throws { let data = Data(""" { From 9c0f6f62826cb04fe054db16bfff89a5ac48dfc5 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 03:36:36 +0100 Subject: [PATCH 119/201] test: isolate table color regression --- docmostlyTests/Editor/NativeEditorTablePayloadTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift b/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift index e4ca729..4edcc26 100644 --- a/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift +++ b/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift @@ -108,6 +108,7 @@ struct NativeEditorTablePayloadTests { #expect(encodedCell.attrs?["backgroundColorName"] == .string("yellow")) } + @MainActor @Test func tableCellBackgroundRespectsRGBAAlphaPercentages() { let cell = NativeEditorTableCell( plainText: "Risk", From c55e7a95a46cbcc5382004b8b5d065d02c205acd Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 03:47:43 +0100 Subject: [PATCH 120/201] fix: harden markdown fidelity parsing --- ...itorMarkdownParser+DocmostInlineHTML.swift | 12 ++++-- ...iveEditorMarkdownParser+DocmostLinks.swift | 39 +++++++------------ ...NativeEditorMarkdownParser+Highlight.swift | 32 ++++++++++++++- ...ativeEditorMarkdownParser+InlineMath.swift | 30 +++++++++----- ...torMarkdownParser+MarkdownLinkTitles.swift | 3 +- .../NativeEditorMarkdownParser+Tables.swift | 23 ++++++++++- ...NativeEditorMarkdownParser+TextColor.swift | 8 ++++ .../Editor/NativeEditorTableLayout.swift | 32 ++++++++++++--- 8 files changed, 132 insertions(+), 47 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift index 4dfada5..313b927 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift @@ -90,13 +90,17 @@ extension NativeEditorMarkdownParser { let nameEnd = htmlTagNameEnd(in: trimmedLine, startingAt: nameStart) let name = String(trimmedLine[nameStart.. [Range] { @@ -219,7 +223,7 @@ extension NativeEditorMarkdownParser { return ranges } - private static func isInsideMarkdownCodeSpan( + static func isInsideMarkdownCodeSpan( _ index: String.Index, ranges: [Range] ) -> Bool { diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift index f51b511..7cde543 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift @@ -35,55 +35,35 @@ extension NativeEditorMarkdownParser { var remaining = markdown[...] var didAppendAtom = false while let htmlComment = nextDocmostCommentHTML(in: remaining) { - appendMarkdownText( - String(remaining[.. String { guard mention.entityType == "page" else { if mention.entityType == nil { diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Highlight.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Highlight.swift index 2541d6c..d262775 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Highlight.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Highlight.swift @@ -34,9 +34,15 @@ extension NativeEditorMarkdownParser { static func nextDocmostHighlightHTML(in markdown: Substring) -> DocmostHighlightHTML? { var searchStart = markdown.startIndex + let codeSpanRanges = markdownCodeSpanRanges(in: markdown, bodyStart: markdown.startIndex) while searchStart < markdown.endIndex, let openRange = markdown[searchStart...].range(of: "") else { @@ -45,7 +51,11 @@ extension NativeEditorMarkdownParser { } let contentStart = markdown.index(after: openTagEnd) - guard let closeRange = markdown[contentStart...].range(of: "", options: .caseInsensitive) else { + guard let closeRange = matchingCloseMarkRange( + in: markdown, + startingAt: contentStart, + codeSpanRanges: codeSpanRanges + ) else { return nil } @@ -61,6 +71,26 @@ extension NativeEditorMarkdownParser { return nil } + private static func matchingCloseMarkRange( + in markdown: Substring, + startingAt contentStart: String.Index, + codeSpanRanges: [Range] + ) -> Range? { + var searchStart = contentStart + + while searchStart < markdown.endIndex, + let closeRange = markdown[searchStart...].range(of: "", options: .caseInsensitive) { + guard isInsideMarkdownCodeSpan(closeRange.lowerBound, ranges: codeSpanRanges) == false else { + searchStart = closeRange.upperBound + continue + } + + return closeRange + } + + return nil + } + static func appendHighlight(_ htmlHighlight: DocmostHighlightHTML, to result: inout AttributedString) { var highlightedBody = AttributedString("") appendMarkdownText( diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMath.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMath.swift index 4f898e4..fd0b6cb 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMath.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMath.swift @@ -4,13 +4,17 @@ extension NativeEditorMarkdownParser { static func inlineText(from markdown: String) -> AttributedString { var result = AttributedString("") var remaining = markdown[...] + let codeSpanRanges = markdownCodeSpanRanges(in: remaining, bodyStart: remaining.startIndex) - while let inlineDelimiter = nextInlineMathDelimiter(in: remaining) { + while let inlineDelimiter = nextInlineMathDelimiter(in: remaining, codeSpanRanges: codeSpanRanges) { let openRange = inlineDelimiter.range appendMarkdownText( String(remaining[..] ) -> (range: Range, value: String)? { - let singleDollarRange = markdown.range(of: "$") - let doubleDollarRange = markdown.range(of: "$$") + var searchStart = markdown.startIndex - if let doubleDollarRange, doubleDollarRange.lowerBound == singleDollarRange?.lowerBound { - return (doubleDollarRange, "$$") - } + while searchStart < markdown.endIndex, + let dollarIndex = markdown[searchStart...].firstIndex(of: "$") { + let singleDollarRange = dollarIndex.. String? { @@ -239,4 +239,25 @@ extension NativeEditorMarkdownParser { .replacing("[", with: "\\[") .replacing("]", with: "\\]") } + + private static func markdownTableLinkDestination(from href: String) -> String { + guard href.contains(where: requiresAngleWrappedMarkdownLinkDestination) else { + return href + } + + return "<\(escapedAngleWrappedMarkdownLinkDestination(href))>" + } + + private static func requiresAngleWrappedMarkdownLinkDestination(_ character: Character) -> Bool { + character.isWhitespace || character == "(" || character == ")" || character == "<" || character == ">" + } + + private static func escapedAngleWrappedMarkdownLinkDestination(_ href: String) -> String { + href + .replacing("\\", with: "\\\\") + .replacing("<", with: "%3C") + .replacing(">", with: "%3E") + .replacing("\n", with: "%0A") + .replacing("\r", with: "%0D") + } } diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+TextColor.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+TextColor.swift index f8390e8..5f62089 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+TextColor.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+TextColor.swift @@ -19,9 +19,15 @@ extension NativeEditorMarkdownParser { static func nextDocmostTextColorHTML(in markdown: Substring) -> DocmostTextColorHTML? { var searchStart = markdown.startIndex + let codeSpanRanges = markdownCodeSpanRanges(in: markdown, bodyStart: markdown.startIndex) while searchStart < markdown.endIndex, let openRange = markdown[searchStart...].range(of: "") else { @@ -67,6 +73,8 @@ extension NativeEditorMarkdownParser { private static func applyTextColor(_ color: String, to text: inout AttributedString) { let ranges = text.runs.map(\.range) for range in ranges { + guard text[range][NativeEditorTextColorAttribute.self] == nil else { continue } + text[range][NativeEditorTextColorAttribute.self] = color if let swiftUIColor = Color(docmostlyHex: color) { text[range].foregroundColor = swiftUIColor diff --git a/docmostly/Features/Editor/NativeEditorTableLayout.swift b/docmostly/Features/Editor/NativeEditorTableLayout.swift index bf55b51..8d15813 100644 --- a/docmostly/Features/Editor/NativeEditorTableLayout.swift +++ b/docmostly/Features/Editor/NativeEditorTableLayout.swift @@ -55,18 +55,38 @@ enum NativeEditorTableLayout { let components = trimmedValue[trimmedValue.index(after: openParen)..= 3 else { return nil } + guard components.count >= 3, + let red = cssColorComponent(from: components[0]), + let green = cssColorComponent(from: components[1]), + let blue = cssColorComponent(from: components[2]) else { + return nil + } - let opacity = components.indices.contains(3) ? min(max(components[3], 0), 1) : 1 + let opacity = components.indices.contains(3) ? cssAlphaComponent(from: components[3]) ?? 1 : 1 return Color( - red: min(max(components[0], 0), 255) / 255, - green: min(max(components[1], 0), 255) / 255, - blue: min(max(components[2], 0), 255) / 255, + red: red / 255, + green: green / 255, + blue: blue / 255, opacity: opacity ) } + private static func cssColorComponent(from value: String) -> Double? { + guard let component = Double(value) else { return nil } + return min(max(component, 0), 255) + } + + private static func cssAlphaComponent(from value: String) -> Double? { + if value.hasSuffix("%") { + let percentageText = value.dropLast().trimmingCharacters(in: .whitespacesAndNewlines) + guard let percentage = Double(percentageText) else { return nil } + return min(max(percentage / 100, 0), 1) + } + + guard let alpha = Double(value) else { return nil } + return min(max(alpha, 0), 1) + } + private static func backgroundColor(for name: String) -> Color? { switch name.lowercased() { case "blue": From 0dc83845a96f0e5f63b04cd15071e20e38fc7563 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 04:02:39 +0100 Subject: [PATCH 121/201] test: avoid color resolution in table alpha check --- .../Editor/NativeEditorTableLayout.swift | 29 ++++++++++++++----- .../NativeEditorTablePayloadTests.swift | 19 ++++-------- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorTableLayout.swift b/docmostly/Features/Editor/NativeEditorTableLayout.swift index 8d15813..5f0c483 100644 --- a/docmostly/Features/Editor/NativeEditorTableLayout.swift +++ b/docmostly/Features/Editor/NativeEditorTableLayout.swift @@ -2,6 +2,13 @@ import Foundation import SwiftUI enum NativeEditorTableLayout { + struct CSSRGBAComponents { + var red: Double + var green: Double + var blue: Double + var opacity: Double + } + static let minimumColumnWidth: CGFloat = 128 static let defaultColumnWidth: CGFloat = 184 static let compactColumnWidth: CGFloat = 176 @@ -45,6 +52,17 @@ enum NativeEditorTableLayout { return hexColor } + guard let components = cssRGBAComponents(from: trimmedValue) else { return nil } + return Color( + red: components.red / 255, + green: components.green / 255, + blue: components.blue / 255, + opacity: components.opacity + ) + } + + nonisolated static func cssRGBAComponents(from value: String) -> CSSRGBAComponents? { + let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) let lowercasedValue = trimmedValue.lowercased() guard lowercasedValue.hasPrefix("rgb(") || lowercasedValue.hasPrefix("rgba("), let openParen = trimmedValue.firstIndex(of: "("), @@ -63,20 +81,15 @@ enum NativeEditorTableLayout { } let opacity = components.indices.contains(3) ? cssAlphaComponent(from: components[3]) ?? 1 : 1 - return Color( - red: red / 255, - green: green / 255, - blue: blue / 255, - opacity: opacity - ) + return CSSRGBAComponents(red: red, green: green, blue: blue, opacity: opacity) } - private static func cssColorComponent(from value: String) -> Double? { + nonisolated private static func cssColorComponent(from value: String) -> Double? { guard let component = Double(value) else { return nil } return min(max(component, 0), 255) } - private static func cssAlphaComponent(from value: String) -> Double? { + nonisolated private static func cssAlphaComponent(from value: String) -> Double? { if value.hasSuffix("%") { let percentageText = value.dropLast().trimmingCharacters(in: .whitespacesAndNewlines) guard let percentage = Double(percentageText) else { return nil } diff --git a/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift b/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift index 4edcc26..72edd0d 100644 --- a/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift +++ b/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift @@ -1,5 +1,4 @@ import Foundation -import SwiftUI import Testing @testable import docmostly @@ -108,20 +107,12 @@ struct NativeEditorTablePayloadTests { #expect(encodedCell.attrs?["backgroundColorName"] == .string("yellow")) } - @MainActor @Test func tableCellBackgroundRespectsRGBAAlphaPercentages() { - let cell = NativeEditorTableCell( - plainText: "Risk", - isHeader: false, - backgroundColor: "rgba(255, 0, 0, 50%)", - backgroundColorName: nil - ) - - let resolved = NativeEditorTableLayout.cellBackground(for: cell).resolve(in: EnvironmentValues()) + let components = NativeEditorTableLayout.cssRGBAComponents(from: "rgba(255, 0, 0, 50%)") - #expect(resolved.red == 1) - #expect(resolved.green == 0) - #expect(resolved.blue == 0) - #expect(resolved.opacity == 0.5) + #expect(components?.red == 255) + #expect(components?.green == 0) + #expect(components?.blue == 0) + #expect(components?.opacity == 0.5) } } From 44c007aeab2f52f81981e6772e35af82c5d18160 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 04:07:13 +0100 Subject: [PATCH 122/201] test: isolate table alpha component assertions --- docmostlyTests/Editor/NativeEditorTablePayloadTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift b/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift index 72edd0d..584963e 100644 --- a/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift +++ b/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift @@ -107,6 +107,7 @@ struct NativeEditorTablePayloadTests { #expect(encodedCell.attrs?["backgroundColorName"] == .string("yellow")) } + @MainActor @Test func tableCellBackgroundRespectsRGBAAlphaPercentages() { let components = NativeEditorTableLayout.cssRGBAComponents(from: "rgba(255, 0, 0, 50%)") From f742f80679c0c708ddc3cfc825a76e7cf0e09097 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 04:27:14 +0100 Subject: [PATCH 123/201] fix: keep inline parser on docmost atom prefixes --- ...iveEditorMarkdownParser+DocmostLinks.swift | 39 ++++++++++++------- ...ativeEditorMarkdownParser+InlineMath.swift | 5 +-- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift index 7cde543..f51b511 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift @@ -35,35 +35,55 @@ extension NativeEditorMarkdownParser { var remaining = markdown[...] var didAppendAtom = false while let htmlComment = nextDocmostCommentHTML(in: remaining) { - appendMarkdownTextPrefix(String(remaining[.. String { guard mention.entityType == "page" else { if mention.entityType == nil { diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMath.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMath.swift index fd0b6cb..a326c8f 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMath.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMath.swift @@ -11,10 +11,7 @@ extension NativeEditorMarkdownParser { appendMarkdownText( String(remaining[.. Date: Mon, 29 Jun 2026 04:35:31 +0100 Subject: [PATCH 124/201] fix: address status and table fidelity review gaps --- .../NativeEditorMarkdownParser+Status.swift | 2 +- .../NativeEditorMarkdownParser+Tables.swift | 37 ++++++++++++++----- .../NativeEditorStatusMarkdownTests.swift | 15 ++++++++ .../Editor/NativeRichEditorTableTests.swift | 27 ++++++++++++++ 4 files changed, 71 insertions(+), 10 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Status.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Status.swift index 337c0b5..438fa66 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Status.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Status.swift @@ -27,7 +27,7 @@ extension NativeEditorMarkdownParser { let contentStart = markdown.index(after: openTagEnd) guard let closeRange = matchingCloseSpanRange(in: markdown, bodyStart: contentStart) else { - searchStart = markdown.index(after: openRange.lowerBound) + searchStart = contentStart continue } diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift index cc61985..d39a726 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift @@ -227,10 +227,32 @@ extension NativeEditorMarkdownParser { } private static func escapedMarkdownTableCell(_ text: String) -> String { - text.replacing("\\", with: "\\\\") - .replacing("|", with: "\\|") - .replacing("\n", with: " ") - .replacing("\r", with: " ") + var output = "" + var previousCharacter: Character? + var isInsideAngleWrappedLinkDestination = false + + for character in text { + if character == "<", previousCharacter == "(" { + isInsideAngleWrappedLinkDestination = true + } + + if character == "\\", isInsideAngleWrappedLinkDestination == false { + output += "\\\\" + } else if character == "|" { + output += "\\|" + } else if character == "\n" || character == "\r" { + output += " " + } else { + output.append(character) + } + + if character == ">", isInsideAngleWrappedLinkDestination { + isInsideAngleWrappedLinkDestination = false + } + previousCharacter = character + } + + return output } private static func escapedMarkdownTableLinkLabel(_ text: String) -> String { @@ -254,10 +276,7 @@ extension NativeEditorMarkdownParser { private static func escapedAngleWrappedMarkdownLinkDestination(_ href: String) -> String { href - .replacing("\\", with: "\\\\") - .replacing("<", with: "%3C") - .replacing(">", with: "%3E") - .replacing("\n", with: "%0A") - .replacing("\r", with: "%0D") + .replacing("\n", with: " ") + .replacing("\r", with: " ") } } diff --git a/docmostlyTests/Editor/NativeEditorStatusMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorStatusMarkdownTests.swift index 1125bed..653c99e 100644 --- a/docmostlyTests/Editor/NativeEditorStatusMarkdownTests.swift +++ b/docmostlyTests/Editor/NativeEditorStatusMarkdownTests.swift @@ -56,4 +56,19 @@ struct NativeEditorStatusMarkdownTests { node.attrs?["color"] == .string("green") }) } + + @Test func markdownImportRecoversFromRepeatedMalformedStatusSpans() throws { + let malformedSpans = Array( + repeating: #"Broken "#, + count: 25 + ).joined() + let block = try #require(NativeEditorMarkdownParser.blocks( + from: malformedSpans + #"Ship"# + ).first) + + let statusRun = try #require(block.text.runs.first { run in + run[NativeEditorStatusAttribute.self]?.text == "Ship" + }) + #expect(statusRun[NativeEditorStatusAttribute.self]?.color == "green") + } } diff --git a/docmostlyTests/Editor/NativeRichEditorTableTests.swift b/docmostlyTests/Editor/NativeRichEditorTableTests.swift index 946bda2..703dfea 100644 --- a/docmostlyTests/Editor/NativeRichEditorTableTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorTableTests.swift @@ -261,6 +261,33 @@ struct NativeRichEditorTableTests { ) } + @Test func markdownExportDoesNotDoubleEscapeAngleWrappedTableLinkBackslashes() { + let table = NativeEditorTable(rows: [ + NativeEditorTableRow(cells: [ + NativeEditorTableCell(plainText: "File", isHeader: true, backgroundColorName: nil) + ]), + NativeEditorTableRow(cells: [ + NativeEditorTableCell( + plainText: "Spec", + inlineContent: [ + .text("Spec", marks: [.link(href: #"folder name\doc"#)]) + ], + isHeader: false, + backgroundColorName: nil + ) + ]) + ]) + + #expect( + NativeEditorMarkdownParser.tableMarkdown(from: table) == + #""" + | File | + | --- | + | [Spec]() | + """# + ) + } + @Test func editingTableCellPreservesUnsupportedRichContentInOtherCells() throws { let data = Data(""" { From 9d6e2eab580fbed3a06c470d4cb91c535503deb3 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 04:48:13 +0100 Subject: [PATCH 125/201] test: cover docmost slash command titles --- docmostlyTests/Editor/NativeEditorSlashCommandTests.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift b/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift index fdd2e53..f6de107 100644 --- a/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift +++ b/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift @@ -26,6 +26,14 @@ struct NativeEditorSlashCommandTests { #expect(titles.contains("Google Sheets")) } + @Test func slashCommandInventoryUsesDocmostWebCommandTitles() { + let titles = NativeEditorCommand.allCases.map(\.title) + + #expect(titles.contains("Embed PDF")) + #expect(titles.contains("File attachment")) + #expect(titles.contains("Toggle block")) + } + @Test func slashCommandFilteringUsesDocmostSearchTerms() { let expectations = [ SlashCommandFilterExpectation(query: "today", title: "Date"), From da1273bec4923854d61b28b908ea76c1dbbc0864 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 04:56:16 +0100 Subject: [PATCH 126/201] fix: align slash command titles with docmost web --- docmostly/Features/Editor/NativeEditorCommand.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorCommand.swift b/docmostly/Features/Editor/NativeEditorCommand.swift index cbb1cf4..7f1765d 100644 --- a/docmostly/Features/Editor/NativeEditorCommand.swift +++ b/docmostly/Features/Editor/NativeEditorCommand.swift @@ -128,9 +128,9 @@ enum NativeEditorCommand: String, CaseIterable, Identifiable { case .audio: "Audio" case .pdf: - "PDF" + "Embed PDF" case .fileAttachment: - "File" + "File attachment" case .table: "Table" case .baseInline: @@ -140,7 +140,7 @@ enum NativeEditorCommand: String, CaseIterable, Identifiable { case .callout: "Callout" case .details: - "Details" + "Toggle block" case .mathInline: "Math Inline" case .pageBreak: @@ -229,9 +229,9 @@ enum NativeEditorCommand: String, CaseIterable, Identifiable { case .audio: "Audio placeholder" case .pdf: - "PDF placeholder" + "Upload and embed a PDF file" case .fileAttachment: - "File attachment placeholder" + "Upload any file from your device" case .table: "Two-column table" case .baseInline: @@ -241,7 +241,7 @@ enum NativeEditorCommand: String, CaseIterable, Identifiable { case .callout: "Highlighted note" case .details: - "Collapsible detail section" + "Insert collapsible block" case .mathInline: "Inline equation" case .pageBreak: From ef32715914671fccbcfaa1f9f26d8c1f21a48758 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 05:03:35 +0100 Subject: [PATCH 127/201] test: update slash command title expectations --- docmostlyTests/Editor/NativeRichEditorViewModelTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift b/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift index 5084844..9ec25f2 100644 --- a/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift @@ -38,7 +38,7 @@ struct NativeRichEditorViewModelTests { #expect(viewModel.isShowingSlashCommands == true) #expect(viewModel.slashCommandQuery == "to") - #expect(viewModel.filteredSlashCommands.map(\.title) == ["To-do List"]) + #expect(viewModel.filteredSlashCommands.map(\.title) == ["To-do List", "Toggle block"]) } @Test func slashCommandFilteringUsesSubtitlesWhenTitlesDoNotMatch() { @@ -104,8 +104,8 @@ struct NativeRichEditorViewModelTests { #expect(titles.contains("Image")) #expect(titles.contains("Video")) #expect(titles.contains("Audio")) - #expect(titles.contains("PDF")) - #expect(titles.contains("File")) + #expect(titles.contains("Embed PDF")) + #expect(titles.contains("File attachment")) #expect(titles.contains("Draw.io")) #expect(titles.contains("Excalidraw")) } From 8868fd096187bf72ea1a98d90dbbf7ebc4010750 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 05:15:31 +0100 Subject: [PATCH 128/201] test: cover percentage rgb table backgrounds --- .../Editor/NativeEditorTablePayloadTests.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift b/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift index 584963e..12c81b0 100644 --- a/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift +++ b/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift @@ -116,4 +116,14 @@ struct NativeEditorTablePayloadTests { #expect(components?.blue == 0) #expect(components?.opacity == 0.5) } + + @MainActor + @Test func tableCellBackgroundRespectsRGBColorPercentages() { + let components = NativeEditorTableLayout.cssRGBAComponents(from: "rgb(100%, 0%, 50%)") + + #expect(components?.red == 255) + #expect(components?.green == 0) + #expect(components?.blue == 127.5) + #expect(components?.opacity == 1) + } } From ce585964f8c4c93b276b94d16fa1f1d03b3dd6b6 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 05:23:30 +0100 Subject: [PATCH 129/201] fix: parse percentage rgb table backgrounds --- docmostly/Features/Editor/NativeEditorTableLayout.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docmostly/Features/Editor/NativeEditorTableLayout.swift b/docmostly/Features/Editor/NativeEditorTableLayout.swift index 5f0c483..f9c5eb0 100644 --- a/docmostly/Features/Editor/NativeEditorTableLayout.swift +++ b/docmostly/Features/Editor/NativeEditorTableLayout.swift @@ -85,6 +85,12 @@ enum NativeEditorTableLayout { } nonisolated private static func cssColorComponent(from value: String) -> Double? { + if value.hasSuffix("%") { + let percentageText = value.dropLast().trimmingCharacters(in: .whitespacesAndNewlines) + guard let percentage = Double(percentageText) else { return nil } + return min(max(percentage / 100, 0), 1) * 255 + } + guard let component = Double(value) else { return nil } return min(max(component, 0), 255) } From 3b58d18822b6cacfd8bbbddc8af6b585eac570d6 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 05:32:27 +0100 Subject: [PATCH 130/201] test: cover quoted html closing tag lookalikes --- .../Editor/NativeEditorHTMLTagMatchingTests.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docmostlyTests/Editor/NativeEditorHTMLTagMatchingTests.swift b/docmostlyTests/Editor/NativeEditorHTMLTagMatchingTests.swift index e146e27..4a00474 100644 --- a/docmostlyTests/Editor/NativeEditorHTMLTagMatchingTests.swift +++ b/docmostlyTests/Editor/NativeEditorHTMLTagMatchingTests.swift @@ -31,4 +31,10 @@ struct NativeEditorHTMLTagMatchingTests { #expect(NativeEditorMarkdownParser.htmlTagDepthDelta(in: line, tagName: "div") == 0) } + + @Test func containsHTMLClosingTagIgnoresTagLookalikesInsideQuotedAttributes() { + let line = #"x"# + + #expect(NativeEditorMarkdownParser.containsHTMLClosingTag(in: line, tagName: "div") == false) + } } From a3a27173a4973b57252245f9ae1637de976ca45a Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 05:38:49 +0100 Subject: [PATCH 131/201] fix: ignore quoted html closing tag lookalikes --- ...NativeEditorMarkdownParser+DocmostInlineHTML.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift index 313b927..0c1cbf7 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift @@ -55,8 +55,15 @@ extension NativeEditorMarkdownParser { while searchStart < trimmedLine.endIndex, let openIndex = trimmedLine[searchStart...].firstIndex(of: "<") { var nameStart = trimmedLine.index(after: openIndex) - guard nameStart < trimmedLine.endIndex, trimmedLine[nameStart] == "/" else { - searchStart = nameStart + guard nameStart < trimmedLine.endIndex else { break } + + guard trimmedLine[nameStart] == "/" else { + let nameEnd = htmlTagNameEnd(in: trimmedLine, startingAt: nameStart) + if let closeIndex = htmlOpeningTagCloseIndex(in: trimmedLine, startingAt: nameEnd) { + searchStart = trimmedLine.index(after: closeIndex) + } else { + searchStart = nameStart + } continue } From 470efb3d116473c7d15295bbfeada5e34a811b15 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 05:52:57 +0100 Subject: [PATCH 132/201] test: cover synced block docmost ids --- .../Editor/NativeEditorSlashCommandTests.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift b/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift index f6de107..36390f3 100644 --- a/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift +++ b/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift @@ -177,6 +177,24 @@ struct NativeEditorSlashCommandTests { } } + @Test func applyingSyncedBlockSlashCommandCreatesDocmostNodeID() throws { + let viewModel = viewModelAfterApplying(.syncedBlock) + let block = viewModel.document.blocks[0] + + guard case .transclusionSource(let source) = block.kind else { + Issue.record("Expected synced block slash command to create a transclusion source") + return + } + + let identifier = try #require(source.identifier) + let node = try #require(viewModel.document.proseMirrorDocument.content.first) + + #expect(identifier.count == 12) + #expect(identifier.unicodeScalars.allSatisfy { (97...122).contains(Int($0.value)) }) + #expect(node.type == "transclusionSource") + #expect(node.attrs?["id"] == .string(identifier)) + } + private func viewModelAfterApplying(_ command: NativeEditorCommand) -> NativeRichEditorViewModel { let block = NativeEditorBlock( kind: .paragraph, From b40103becf6f4c7dc1c0ccef0f57fec9e5af7b3b Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 05:58:33 +0100 Subject: [PATCH 133/201] fix: align synced block ids with docmost --- .../Features/Editor/NativeEditorCommand+RichBlocks.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docmostly/Features/Editor/NativeEditorCommand+RichBlocks.swift b/docmostly/Features/Editor/NativeEditorCommand+RichBlocks.swift index b1ec4fa..04fda67 100644 --- a/docmostly/Features/Editor/NativeEditorCommand+RichBlocks.swift +++ b/docmostly/Features/Editor/NativeEditorCommand+RichBlocks.swift @@ -131,9 +131,15 @@ extension NativeEditorCommand { } private var defaultSyncedBlockID: String { - "sync-\(UUID().uuidString)" + var generator = SystemRandomNumberGenerator() + return String((0.. NativeEditorBlock { NativeEditorBlock( id: id, From dec2bb079bc5a52c96bf9880f51972ceb1b23fef Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 06:10:52 +0100 Subject: [PATCH 134/201] test: cover docmost slash command titles --- .../NativeEditorSlashCommandTests.swift | 67 ++++++++++++++++--- .../NativeRichEditorViewModelTests.swift | 14 ++-- 2 files changed, 65 insertions(+), 16 deletions(-) diff --git a/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift b/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift index 36390f3..68b186b 100644 --- a/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift +++ b/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift @@ -28,20 +28,69 @@ struct NativeEditorSlashCommandTests { @Test func slashCommandInventoryUsesDocmostWebCommandTitles() { let titles = NativeEditorCommand.allCases.map(\.title) + let expectedTitles = [ + "Text", + "To-do list", + "Heading 1", + "Heading 2", + "Heading 3", + "Bullet list", + "Numbered list", + "Quote", + "Code", + "Divider", + "Page break", + "Image", + "Video", + "Audio", + "Embed PDF", + "File attachment", + "Table", + "Base (Inline)", + "Kanban", + "Toggle block", + "Callout", + "Math inline", + "Math block", + "Mermaid diagram", + "Draw.io (diagrams.net)", + "Excalidraw (Whiteboard)", + "Date", + "Time", + "Status", + "Emoji", + "Subpages (Child pages)", + "Synced block", + "2 Columns", + "3 Columns", + "4 Columns", + "5 Columns", + "Iframe embed", + "Airtable", + "Loom", + "Figma", + "Typeform", + "Miro", + "YouTube", + "Vimeo", + "Framer", + "Google Drive", + "Google Sheets" + ] - #expect(titles.contains("Embed PDF")) - #expect(titles.contains("File attachment")) - #expect(titles.contains("Toggle block")) + for expectedTitle in expectedTitles { + #expect(titles.contains(expectedTitle)) + } } @Test func slashCommandFilteringUsesDocmostSearchTerms() { let expectations = [ SlashCommandFilterExpectation(query: "today", title: "Date"), SlashCommandFilterExpectation(query: "now", title: "Time"), - SlashCommandFilterExpectation(query: "checkbox", title: "To-do List"), + SlashCommandFilterExpectation(query: "checkbox", title: "To-do list"), SlashCommandFilterExpectation(query: "hr", title: "Divider"), - SlashCommandFilterExpectation(query: "pagebreak", title: "Page Break"), - SlashCommandFilterExpectation(query: "latex", title: "Math Inline"), + SlashCommandFilterExpectation(query: "pagebreak", title: "Page break"), + SlashCommandFilterExpectation(query: "latex", title: "Math inline"), SlashCommandFilterExpectation(query: "lozenge", title: "Status"), SlashCommandFilterExpectation(query: "reaction", title: "Emoji") ] @@ -54,9 +103,9 @@ struct NativeEditorSlashCommandTests { @Test func slashCommandFilteringUsesDocmostFuzzyTitleMatching() { let expectations = [ - SlashCommandFilterExpectation(query: "tdl", title: "To-do List"), - SlashCommandFilterExpectation(query: "nb", title: "Numbered List"), - SlashCommandFilterExpectation(query: "pgb", title: "Page Break") + SlashCommandFilterExpectation(query: "tdl", title: "To-do list"), + SlashCommandFilterExpectation(query: "nb", title: "Numbered list"), + SlashCommandFilterExpectation(query: "pgb", title: "Page break") ] for expectation in expectations { diff --git a/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift b/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift index 9ec25f2..d9be1ec 100644 --- a/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift @@ -38,7 +38,7 @@ struct NativeRichEditorViewModelTests { #expect(viewModel.isShowingSlashCommands == true) #expect(viewModel.slashCommandQuery == "to") - #expect(viewModel.filteredSlashCommands.map(\.title) == ["To-do List", "Toggle block"]) + #expect(viewModel.filteredSlashCommands.map(\.title) == ["To-do list", "Toggle block"]) } @Test func slashCommandFilteringUsesSubtitlesWhenTitlesDoNotMatch() { @@ -48,8 +48,8 @@ struct NativeRichEditorViewModelTests { viewModel.focus(blockID: block.id) let titles = viewModel.filteredSlashCommands.map(\.title) - #expect(titles.contains("Math Inline")) - #expect(titles.contains("Math Block")) + #expect(titles.contains("Math inline")) + #expect(titles.contains("Math block")) } @Test func applyingSlashCommandTransformsActiveBlockAndClearsSlashToken() { @@ -106,8 +106,8 @@ struct NativeRichEditorViewModelTests { #expect(titles.contains("Audio")) #expect(titles.contains("Embed PDF")) #expect(titles.contains("File attachment")) - #expect(titles.contains("Draw.io")) - #expect(titles.contains("Excalidraw")) + #expect(titles.contains("Draw.io (diagrams.net)")) + #expect(titles.contains("Excalidraw (Whiteboard)")) } @Test func slashCommandInventoryIncludesInlineEverydayCommands() { @@ -117,7 +117,7 @@ struct NativeRichEditorViewModelTests { #expect(titles.contains("Time")) #expect(titles.contains("Status")) #expect(titles.contains("Emoji")) - #expect(titles.contains("Math Inline")) + #expect(titles.contains("Math inline")) } @Test func mediaSlashCommandsMapToAttachmentImportKinds() { @@ -220,7 +220,7 @@ struct NativeRichEditorViewModelTests { viewModel.document = NativeEditorDocument(blocks: [block]) viewModel.focus(blockID: block.id) - #expect(viewModel.filteredSlashCommands.map(\.title).contains("Math Inline")) + #expect(viewModel.filteredSlashCommands.map(\.title).contains("Math inline")) viewModel.applySlashCommand(.mathInline) From e64896915b3b21ffb5d8b88afc30d5c5b86a745b Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 06:16:11 +0100 Subject: [PATCH 135/201] fix: align slash command titles with docmost --- .../Features/Editor/NativeEditorCommand.swift | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorCommand.swift b/docmostly/Features/Editor/NativeEditorCommand.swift index 7f1765d..e027aa3 100644 --- a/docmostly/Features/Editor/NativeEditorCommand.swift +++ b/docmostly/Features/Editor/NativeEditorCommand.swift @@ -104,7 +104,7 @@ enum NativeEditorCommand: String, CaseIterable, Identifiable { var title: String { switch self { case .paragraph: - "Paragraph" + "Text" case .heading1: "Heading 1" case .heading2: @@ -112,15 +112,15 @@ enum NativeEditorCommand: String, CaseIterable, Identifiable { case .heading3: "Heading 3" case .bulletedList: - "Bulleted List" + "Bullet list" case .numberedList: - "Numbered List" + "Numbered list" case .todoList: - "To-do List" + "To-do list" case .quote: "Quote" case .codeBlock: - "Code Block" + "Code" case .image: "Image" case .video: @@ -142,9 +142,9 @@ enum NativeEditorCommand: String, CaseIterable, Identifiable { case .details: "Toggle block" case .mathInline: - "Math Inline" + "Math inline" case .pageBreak: - "Page Break" + "Page break" case .divider: "Divider" case .columns: @@ -156,9 +156,9 @@ enum NativeEditorCommand: String, CaseIterable, Identifiable { case .columns5: "5 Columns" case .subpages: - "Subpages" + "Subpages (Child pages)" case .syncedBlock: - "Synced Block" + "Synced block" case .embed: "Embed" case .iframeEmbed: @@ -184,13 +184,13 @@ enum NativeEditorCommand: String, CaseIterable, Identifiable { case .googleSheetsEmbed: "Google Sheets" case .mathBlock: - "Math Block" + "Math block" case .mermaid: - "Mermaid" + "Mermaid diagram" case .drawio: - "Draw.io" + "Draw.io (diagrams.net)" case .excalidraw: - "Excalidraw" + "Excalidraw (Whiteboard)" case .date: "Date" case .time: From c18e8b435747ac9272eb7a58148dcf3699b3390e Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 06:24:56 +0100 Subject: [PATCH 136/201] fix: prioritize slash command aliases --- .../Features/Editor/NativeEditorCommand+Behavior.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift b/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift index 9d2ffe4..d94541a 100644 --- a/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift +++ b/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift @@ -29,7 +29,7 @@ extension NativeEditorCommand { return 0 } - if title.localizedStandardContains(query) || title.fuzzyMatchesSlashCommandQuery(query) { + if title.localizedStandardContains(query) { return 1 } @@ -45,6 +45,10 @@ extension NativeEditorCommand { return 2 } + if title.fuzzyMatchesSlashCommandQuery(query) { + return 3 + } + return nil } From cbe78c5e2b798eaa06fbce1d04ff89933a1eb460 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 06:31:02 +0100 Subject: [PATCH 137/201] fix: rank slash command fuzzy matches --- .../Editor/NativeEditorCommand+Behavior.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift b/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift index d94541a..88257d3 100644 --- a/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift +++ b/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift @@ -29,23 +29,23 @@ extension NativeEditorCommand { return 0 } - if title.localizedStandardContains(query) { - return 1 - } - if rawValue.localizedStandardContains(query) { - return 2 + return 1 } if subtitle.localizedStandardContains(query) { - return 2 + return 1 } if searchTerms.contains(where: { $0.localizedStandardContains(query) }) { - return 2 + return 1 } if title.fuzzyMatchesSlashCommandQuery(query) { + return 2 + } + + if title.localizedStandardContains(query) { return 3 } From 0d865bdd0cf5d5b5bfdac36a325317efbbb8aa8e Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 06:38:47 +0100 Subject: [PATCH 138/201] fix: keep slash command fuzzy abbreviations visible --- .../Editor/NativeEditorCommand+Behavior.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift b/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift index 88257d3..90f0c28 100644 --- a/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift +++ b/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift @@ -29,24 +29,24 @@ extension NativeEditorCommand { return 0 } - if rawValue.localizedStandardContains(query) { + if searchTerms.contains(where: { $0.localizedStandardContains(query) }) { return 1 } - if subtitle.localizedStandardContains(query) { - return 1 + if title.fuzzyMatchesSlashCommandQuery(query) { + return 2 } - if searchTerms.contains(where: { $0.localizedStandardContains(query) }) { - return 1 + if rawValue.localizedStandardContains(query) { + return 3 } - if title.fuzzyMatchesSlashCommandQuery(query) { - return 2 + if subtitle.localizedStandardContains(query) { + return 3 } if title.localizedStandardContains(query) { - return 3 + return 4 } return nil From afd8f3f8a89cc264bd84a16f54c4b4f878afe31b Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 06:50:57 +0100 Subject: [PATCH 139/201] test: cover inline markdown shortcuts --- .../NativeRichEditorMechanicsTests.swift | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift b/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift index 21cfdf7..8b7f121 100644 --- a/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift @@ -187,6 +187,36 @@ struct NativeRichEditorMechanicsTests { #expect(String(viewModel.document.blocks[0].text.characters).isEmpty) } + @Test func markdownInputRuleSupportsDocmostInlineMarkShortcuts() throws { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString(""), alignment: .left) + let viewModel = configuredViewModel(blocks: [block]) + viewModel.focus(blockID: block.id) + + viewModel.document.blocks[0].text = AttributedString("Use **bold** *italic* `code` and ~~strike~~") + viewModel.handleDocumentChanged() + + #expect(String(viewModel.document.blocks[0].text.characters) == "Use bold italic code and strike") + + let inlineNodes = try #require(viewModel.document.proseMirrorDocument.content.first?.content) + #expect(inlineNodes.contains { + $0.text == "bold" && $0.marks?.contains(ProseMirrorMark(type: "bold")) == true + }) + #expect(inlineNodes.contains { + $0.text == "italic" && $0.marks?.contains(ProseMirrorMark(type: "italic")) == true + }) + #expect(inlineNodes.contains { + $0.text == "code" && $0.marks?.contains(ProseMirrorMark(type: "code")) == true + }) + #expect(inlineNodes.contains { + $0.text == "strike" && $0.marks?.contains(ProseMirrorMark(type: "strike")) == true + }) + + viewModel.undo() + + #expect(viewModel.document.blocks[0].kind == .paragraph) + #expect(String(viewModel.document.blocks[0].text.characters).isEmpty) + } + @Test func pasteMarkdownInsertsNativeBlocksAfterActiveBlock() { let intro = NativeEditorBlock(kind: .paragraph, text: AttributedString("Intro"), alignment: .left) let viewModel = configuredViewModel(blocks: [intro]) From 42b485f38ddf3d4b79d43ba784147046a3462a06 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 06:57:49 +0100 Subject: [PATCH 140/201] fix: apply inline markdown shortcuts --- .../NativeEditorMarkdownParser+InlineMarks.swift | 10 ++++++++++ .../Editor/NativeRichEditorViewModel+Mechanics.swift | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift index a618408..f45fbcb 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift @@ -1,6 +1,16 @@ import Foundation extension NativeEditorMarkdownParser { + static func inlineMarkdownInputRuleText(from text: String) -> AttributedString? { + if let inlineMathText = inlineMathInputRuleText(from: text) { + return inlineMathText + } + + let attributedText = attributedInlineMarkdown(from: text) + guard String(attributedText.characters) != text else { return nil } + return attributedText + } + static func attributedInlineMarkdown(from markdown: String) -> AttributedString { var output = AttributedString("") var remaining = markdown[...] diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+Mechanics.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+Mechanics.swift index 3ca6928..b04649f 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+Mechanics.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+Mechanics.swift @@ -80,7 +80,7 @@ extension NativeRichEditorViewModel { guard let index = activeBlockIndex, document.blocks[index].kind.allowsInlineMarkdownInputRules, - let attributedText = NativeEditorMarkdownParser.inlineMathInputRuleText( + let attributedText = NativeEditorMarkdownParser.inlineMarkdownInputRuleText( from: String(document.blocks[index].text.characters) ) else { From df61142500a726ac6ca6b0b40c4706acb0747e3c Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 07:07:57 +0100 Subject: [PATCH 141/201] test: cover underscore markdown shortcuts --- .../Editor/NativeRichEditorMechanicsTests.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift b/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift index 8b7f121..bd3a62c 100644 --- a/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift @@ -192,18 +192,26 @@ struct NativeRichEditorMechanicsTests { let viewModel = configuredViewModel(blocks: [block]) viewModel.focus(blockID: block.id) - viewModel.document.blocks[0].text = AttributedString("Use **bold** *italic* `code` and ~~strike~~") + viewModel.document.blocks[0].text = AttributedString( + "Use **bold** __strong__ *italic* _emphasis_ `code` and ~~strike~~" + ) viewModel.handleDocumentChanged() - #expect(String(viewModel.document.blocks[0].text.characters) == "Use bold italic code and strike") + #expect(String(viewModel.document.blocks[0].text.characters) == "Use bold strong italic emphasis code and strike") let inlineNodes = try #require(viewModel.document.proseMirrorDocument.content.first?.content) #expect(inlineNodes.contains { $0.text == "bold" && $0.marks?.contains(ProseMirrorMark(type: "bold")) == true }) + #expect(inlineNodes.contains { + $0.text == "strong" && $0.marks?.contains(ProseMirrorMark(type: "bold")) == true + }) #expect(inlineNodes.contains { $0.text == "italic" && $0.marks?.contains(ProseMirrorMark(type: "italic")) == true }) + #expect(inlineNodes.contains { + $0.text == "emphasis" && $0.marks?.contains(ProseMirrorMark(type: "italic")) == true + }) #expect(inlineNodes.contains { $0.text == "code" && $0.marks?.contains(ProseMirrorMark(type: "code")) == true }) From 7a6bf77e167d8c7a045a2218eaba75ed3ceb8954 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 07:09:59 +0100 Subject: [PATCH 142/201] test: isolate underscore shortcut coverage --- ...iveEditorInlineMarkdownShortcutTests.swift | 37 +++++++++++++++++++ .../NativeRichEditorMechanicsTests.swift | 12 +----- 2 files changed, 39 insertions(+), 10 deletions(-) create mode 100644 docmostlyTests/Editor/NativeEditorInlineMarkdownShortcutTests.swift diff --git a/docmostlyTests/Editor/NativeEditorInlineMarkdownShortcutTests.swift b/docmostlyTests/Editor/NativeEditorInlineMarkdownShortcutTests.swift new file mode 100644 index 0000000..1c38d02 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorInlineMarkdownShortcutTests.swift @@ -0,0 +1,37 @@ +import Foundation +import SwiftUI +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorInlineMarkdownShortcutTests { + @Test func markdownInputRuleSupportsDocmostUnderscoreMarkShortcuts() throws { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString(""), alignment: .left) + let viewModel = configuredViewModel(blocks: [block]) + viewModel.focus(blockID: block.id) + + viewModel.document.blocks[0].text = AttributedString("Use __strong__ and _emphasis_") + viewModel.handleDocumentChanged() + + #expect(String(viewModel.document.blocks[0].text.characters) == "Use strong and emphasis") + + let inlineNodes = try #require(viewModel.document.proseMirrorDocument.content.first?.content) + #expect(inlineNodes.contains { + $0.text == "strong" && $0.marks?.contains(ProseMirrorMark(type: "bold")) == true + }) + #expect(inlineNodes.contains { + $0.text == "emphasis" && $0.marks?.contains(ProseMirrorMark(type: "italic")) == true + }) + + viewModel.undo() + + #expect(String(viewModel.document.blocks[0].text.characters).isEmpty) + } + + private func configuredViewModel(blocks: [NativeEditorBlock]) -> NativeRichEditorViewModel { + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: blocks) + viewModel.resetEditingHistory() + return viewModel + } +} diff --git a/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift b/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift index bd3a62c..8b7f121 100644 --- a/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift @@ -192,26 +192,18 @@ struct NativeRichEditorMechanicsTests { let viewModel = configuredViewModel(blocks: [block]) viewModel.focus(blockID: block.id) - viewModel.document.blocks[0].text = AttributedString( - "Use **bold** __strong__ *italic* _emphasis_ `code` and ~~strike~~" - ) + viewModel.document.blocks[0].text = AttributedString("Use **bold** *italic* `code` and ~~strike~~") viewModel.handleDocumentChanged() - #expect(String(viewModel.document.blocks[0].text.characters) == "Use bold strong italic emphasis code and strike") + #expect(String(viewModel.document.blocks[0].text.characters) == "Use bold italic code and strike") let inlineNodes = try #require(viewModel.document.proseMirrorDocument.content.first?.content) #expect(inlineNodes.contains { $0.text == "bold" && $0.marks?.contains(ProseMirrorMark(type: "bold")) == true }) - #expect(inlineNodes.contains { - $0.text == "strong" && $0.marks?.contains(ProseMirrorMark(type: "bold")) == true - }) #expect(inlineNodes.contains { $0.text == "italic" && $0.marks?.contains(ProseMirrorMark(type: "italic")) == true }) - #expect(inlineNodes.contains { - $0.text == "emphasis" && $0.marks?.contains(ProseMirrorMark(type: "italic")) == true - }) #expect(inlineNodes.contains { $0.text == "code" && $0.marks?.contains(ProseMirrorMark(type: "code")) == true }) From 8884a53293c3c84be3e34e71de5a4f80ec99c280 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 07:18:57 +0100 Subject: [PATCH 143/201] fix: support underscore inline shortcuts --- ...tiveEditorMarkdownParser+InlineMarks.swift | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift index f45fbcb..4156bc3 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift @@ -80,6 +80,12 @@ extension NativeEditorMarkdownParser { intent: .stronglyEmphasized, priority: 2 ), + delimitedInlineMarkdownMatch( + in: markdown, + delimiter: "__", + intent: .stronglyEmphasized, + priority: 2 + ), delimitedInlineMarkdownMatch( in: markdown, delimiter: "~~", @@ -91,6 +97,12 @@ extension NativeEditorMarkdownParser { delimiter: "*", intent: .emphasized, priority: 4 + ), + delimitedInlineMarkdownMatch( + in: markdown, + delimiter: "_", + intent: .emphasized, + priority: 4 ) ] .compactMap { $0 } @@ -182,7 +194,7 @@ extension NativeEditorMarkdownParser { return nil } - if delimiter == "*", isPartOfStrongDelimiter(openRange, in: markdown) { + if delimiter.count == 1, isPartOfRepeatedDelimiter(openRange, delimiter: delimiter, in: markdown) { return nil } @@ -203,6 +215,21 @@ extension NativeEditorMarkdownParser { return markdown[markdown.index(before: index)] == "!" } + private static func isPartOfRepeatedDelimiter( + _ range: Range, + delimiter: String, + in markdown: Substring + ) -> Bool { + guard let delimiterCharacter = delimiter.first else { return false } + + if range.lowerBound > markdown.startIndex, + markdown[markdown.index(before: range.lowerBound)] == delimiterCharacter { + return true + } + + return range.upperBound < markdown.endIndex && markdown[range.upperBound] == delimiterCharacter + } + private static func markdownLinkDestination(from destination: String) -> String { let trimmedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) @@ -214,16 +241,4 @@ extension NativeEditorMarkdownParser { return trimmedDestination.split(whereSeparator: \.isWhitespace).first.map(String.init) ?? "" } - - private static func isPartOfStrongDelimiter( - _ range: Range, - in markdown: Substring - ) -> Bool { - if range.lowerBound > markdown.startIndex, - markdown[markdown.index(before: range.lowerBound)] == "*" { - return true - } - - return range.upperBound < markdown.endIndex && markdown[range.upperBound] == "*" - } } From 5305d3fdd76f151e362bbd4c11d5193742bb016a Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 07:31:17 +0100 Subject: [PATCH 144/201] test: cover script underline markdown fidelity --- ...veEditorScriptUnderlineMarkdownTests.swift | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 docmostlyTests/Editor/NativeEditorScriptUnderlineMarkdownTests.swift diff --git a/docmostlyTests/Editor/NativeEditorScriptUnderlineMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorScriptUnderlineMarkdownTests.swift new file mode 100644 index 0000000..92862ec --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorScriptUnderlineMarkdownTests.swift @@ -0,0 +1,76 @@ +import Foundation +import SwiftUI +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorScriptUnderlineMarkdownTests { + @Test func markdownExportPreservesDocmostUnderlineAndScriptMarksAsHTML() { + let block = NativeEditorBlock( + kind: .paragraph, + text: scriptUnderlineFixtureText(), + alignment: .left + ) + + #expect( + NativeEditorMarkdownParser.markdown(from: [block]) == + "Use underline, x2, and H2O" + ) + } + + @Test func markdownImportPreservesDocmostUnderlineAndScriptMarksAsProseMirrorMarks() throws { + let markdown = "Use underline **now**, x2, and H2O" + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + + let underlinedRuns = block.text.runs.filter { $0.underlineStyle == .single } + #expect(underlinedRuns.count == 2) + + let boldUnderlinedRun = try #require(underlinedRuns.last) + #expect(String(block.text[boldUnderlinedRun.range].characters) == "now") + #expect(boldUnderlinedRun.inlinePresentationIntent?.contains(.stronglyEmphasized) == true) + + let superscriptRun = try #require(run(in: block.text, matching: "2", baselineOffset: 4)) + #expect(String(block.text[superscriptRun.range].characters) == "2") + + let subscriptRun = try #require(run(in: block.text, matching: "2", baselineOffset: -4)) + #expect(String(block.text[subscriptRun.range].characters) == "2") + + let inlineNodes = try #require(NativeEditorDocument.node(from: block).content) + #expect(inlineNodes.contains { $0.marks?.contains(ProseMirrorMark(type: "underline")) == true }) + #expect(inlineNodes.contains { $0.marks?.contains(ProseMirrorMark(type: "superscript")) == true }) + #expect(inlineNodes.contains { $0.marks?.contains(ProseMirrorMark(type: "subscript")) == true }) + } + + private func scriptUnderlineFixtureText() -> AttributedString { + var text = AttributedString("Use ") + + var underline = AttributedString("underline") + underline.underlineStyle = .single + text += underline + + text += AttributedString(", x") + + var superscript = AttributedString("2") + superscript.baselineOffset = 4 + text += superscript + + text += AttributedString(", and H") + + var subscriptText = AttributedString("2") + subscriptText.baselineOffset = -4 + text += subscriptText + + text += AttributedString("O") + return text + } + + private func run( + in text: AttributedString, + matching value: String, + baselineOffset: Double + ) -> AttributedString.Runs.Run? { + text.runs.first { run in + String(text[run.range].characters) == value && run.baselineOffset == baselineOffset + } + } +} From abbac2694b335e3f15ce050b0af6d4efe229184c Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 07:41:24 +0100 Subject: [PATCH 145/201] fix: preserve script underline markdown marks --- ...iveEditorMarkdownParser+DocmostLinks.swift | 11 ++ ...tiveEditorMarkdownParser+InlineMarks.swift | 18 ++ ...EditorMarkdownParser+ScriptUnderline.swift | 166 ++++++++++++++++++ .../Editor/NativeEditorMarkdownParser.swift | 6 +- 4 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+ScriptUnderline.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift index f51b511..818ab11 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift @@ -78,6 +78,17 @@ extension NativeEditorMarkdownParser { remaining = remaining[htmlTextColor.range.upperBound...] } + while let htmlInlineMark = nextDocmostScriptUnderlineHTML(in: remaining) { + appendMarkdownText( + String(remaining[.. String { + var output = body + + if let baselineOffset = run.baselineOffset, baselineOffset != 0 { + let tagName = baselineOffset > 0 ? "sup" : "sub" + output = "<\(tagName)>\(output)" + } + + if run.underlineStyle != nil { + output = "\(output)" + } + + return output + } + private static func codeMarkdown(from text: String) -> String { let delimiter = text.contains("`") ? "``" : "`" return "\(delimiter)\(text)\(delimiter)" diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+ScriptUnderline.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+ScriptUnderline.swift new file mode 100644 index 0000000..574ba9d --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+ScriptUnderline.swift @@ -0,0 +1,166 @@ +import Foundation + +extension NativeEditorMarkdownParser { + struct DocmostScriptUnderlineHTML { + var range: Range + var mark: NativeEditorTextMark + var bodyMarkdown: String + } + + static func nextDocmostScriptUnderlineHTML(in markdown: Substring) -> DocmostScriptUnderlineHTML? { + ["u", "sup", "sub"] + .compactMap { nextDocmostScriptUnderlineHTML(in: markdown, tagName: $0) } + .min { lhs, rhs in lhs.range.lowerBound < rhs.range.lowerBound } + } + + static func appendScriptUnderline(_ htmlMark: DocmostScriptUnderlineHTML, to result: inout AttributedString) { + var body = AttributedString("") + appendMarkdownText( + htmlMark.bodyMarkdown, + to: &body, + usesFoundationMarkdownParser: false + ) + NativeEditorDocument.apply([htmlMark.mark], to: &body) + result += body + } + + private static func nextDocmostScriptUnderlineHTML( + in markdown: Substring, + tagName: String + ) -> DocmostScriptUnderlineHTML? { + var searchStart = markdown.startIndex + let codeSpanRanges = markdownCodeSpanRanges(in: markdown, bodyStart: markdown.startIndex) + + while searchStart < markdown.endIndex, + let openingRange = nextOpeningScriptUnderlineTag( + in: markdown, + tagName: tagName, + startingAt: searchStart + ) { + guard isInsideMarkdownCodeSpan(openingRange.lowerBound, ranges: codeSpanRanges) == false else { + searchStart = openingRange.upperBound + continue + } + + guard let closeRange = matchingClosingScriptUnderlineTag( + in: markdown, + tagName: tagName, + startingAt: openingRange.upperBound, + codeSpanRanges: codeSpanRanges + ) else { + return nil + } + + return DocmostScriptUnderlineHTML( + range: openingRange.lowerBound.. Range? { + var currentSearchStart = searchStart + + while currentSearchStart < markdown.endIndex, + let openIndex = markdown[currentSearchStart...].firstIndex(of: "<") { + let nameStart = markdown.index(after: openIndex) + guard nameStart < markdown.endIndex, markdown[nameStart] != "/" else { + currentSearchStart = nameStart + continue + } + + let nameEnd = scriptUnderlineTagNameEnd(in: markdown, startingAt: nameStart) + let name = String(markdown[nameStart..] + ) -> Range? { + let closingTag = "" + var searchStart = bodyStart + + while searchStart < markdown.endIndex, + let closeRange = markdown[searchStart...].range(of: closingTag, options: .caseInsensitive) { + guard isInsideMarkdownCodeSpan(closeRange.lowerBound, ranges: codeSpanRanges) == false else { + searchStart = closeRange.upperBound + continue + } + + return closeRange + } + + return nil + } + + private static func scriptUnderlineMark(for tagName: String) -> NativeEditorTextMark { + switch tagName { + case "sup": + .superscript + case "sub": + .subscript + default: + .underline + } + } + + private static func scriptUnderlineTagNameEnd( + in markdown: Substring, + startingAt index: String.Index + ) -> String.Index { + var currentIndex = index + while currentIndex < markdown.endIndex, markdown[currentIndex].isLetter { + currentIndex = markdown.index(after: currentIndex) + } + return currentIndex + } + + private static func isScriptUnderlineTagBoundary(at index: String.Index, in markdown: Substring) -> Bool { + index == markdown.endIndex || markdown[index].isWhitespace || markdown[index] == ">" || markdown[index] == "/" + } + + private static func scriptUnderlineOpeningTagEnd( + in markdown: Substring, + startingAt index: String.Index + ) -> String.Index? { + var currentIndex = index + var quote: Character? + + while currentIndex < markdown.endIndex { + let character = markdown[currentIndex] + if let activeQuote = quote { + if character == activeQuote { + quote = nil + } + } else if character == "\"" || character == "'" { + quote = character + } else if character == ">" { + return currentIndex + } + + currentIndex = markdown.index(after: currentIndex) + } + + return nil + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift index 60bce78..b1d3908 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift @@ -371,10 +371,14 @@ enum NativeEditorMarkdownParser { } else if let mention = run[NativeEditorMentionAttribute.self] { runMarkdown = mentionMarkdown(from: mention, fallbackText: runText) } else { - let coloredMarkdown = textColorMarkdown( + let scriptMarkdown = scriptUnderlineMarkdown( from: run, body: inlineRunMarkdown(from: run, text: runText) ) + let coloredMarkdown = textColorMarkdown( + from: run, + body: scriptMarkdown + ) runMarkdown = highlightMarkdown(from: run, body: coloredMarkdown) } From a4d445c2f9d2e01ff5da6b86fe7eab20feb44f46 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 07:51:14 +0100 Subject: [PATCH 146/201] test: cover table code span pipe import --- ...NativeEditorTableMarkdownImportTests.swift | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 docmostlyTests/Editor/NativeEditorTableMarkdownImportTests.swift diff --git a/docmostlyTests/Editor/NativeEditorTableMarkdownImportTests.swift b/docmostlyTests/Editor/NativeEditorTableMarkdownImportTests.swift new file mode 100644 index 0000000..0300181 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorTableMarkdownImportTests.swift @@ -0,0 +1,27 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorTableMarkdownImportTests { + @Test func markdownTableImportPreservesPipesInsideCodeSpans() throws { + let blocks = NativeEditorMarkdownParser.blocks(from: """ + | Expression | Result | + | --- | --- | + | `a | b` | Ready | + """) + + let block = try #require(blocks.first) + guard case .table(let table) = block.kind else { + Issue.record("Expected markdown table") + return + } + + #expect(table.rows.count == 2) + #expect(table.rows[1].cells[0].plainText == "a | b") + #expect(table.rows[1].cells[1].plainText == "Ready") + #expect(table.rows[1].cells[0].inlineContent == [ + .text("a | b", marks: [.code]) + ]) + } +} From 48a85ca8ea9cfeff3c5943aa287dfc2fce415738 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 07:59:52 +0100 Subject: [PATCH 147/201] fix: keep code span pipes in table import --- .../NativeEditorMarkdownParser+Tables.swift | 46 +++++++++++++------ 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift index d39a726..a95dbcb 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift @@ -97,10 +97,39 @@ extension NativeEditorMarkdownParser { let trimmedLine = line.trimmingCharacters(in: .whitespaces) guard trimmedLine.contains("|") else { return nil } + var cells = splitMarkdownTableCells(from: trimmedLine) + + if trimmedLine.first == "|", cells.first?.isEmpty == true { + cells.removeFirst() + } + + if trimmedLine.last == "|", cells.last?.isEmpty == true { + cells.removeLast() + } + + let trimmedCells = cells.map { $0.trimmingCharacters(in: .whitespaces) } + guard trimmedCells.isEmpty == false, trimmedCells.contains(where: { $0.isEmpty == false }) else { + return nil + } + + return trimmedCells + } + + private static func splitMarkdownTableCells(from line: String) -> [String] { var cells = [""] var isEscaped = false + let codeSpanRanges = markdownCodeSpanRanges(in: line[...], bodyStart: line.startIndex) + var index = line.startIndex + + while index < line.endIndex { + let character = line[index] + defer { index = line.index(after: index) } + + if isInsideMarkdownCodeSpan(index, ranges: codeSpanRanges) { + cells[cells.count - 1].append(character) + continue + } - for character in trimmedLine { if isEscaped { if character == "|" { cells[cells.count - 1].append(character) @@ -125,20 +154,7 @@ extension NativeEditorMarkdownParser { cells[cells.count - 1].append("\\") } - if trimmedLine.first == "|", cells.first?.isEmpty == true { - cells.removeFirst() - } - - if trimmedLine.last == "|", cells.last?.isEmpty == true { - cells.removeLast() - } - - let trimmedCells = cells.map { $0.trimmingCharacters(in: .whitespaces) } - guard trimmedCells.isEmpty == false, trimmedCells.contains(where: { $0.isEmpty == false }) else { - return nil - } - - return trimmedCells + return cells } private static func isMarkdownTableSeparatorRow(_ line: String, columnCount: Int) -> Bool { From 223d4113d1ef3ae078831d045dd4d20af0a3ded1 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 08:12:28 +0100 Subject: [PATCH 148/201] test: cover double backtick code import --- .../NativeEditorInlineCodeMarkdownTests.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 docmostlyTests/Editor/NativeEditorInlineCodeMarkdownTests.swift diff --git a/docmostlyTests/Editor/NativeEditorInlineCodeMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorInlineCodeMarkdownTests.swift new file mode 100644 index 0000000..e9de342 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorInlineCodeMarkdownTests.swift @@ -0,0 +1,18 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorInlineCodeMarkdownTests { + @Test func markdownImportPreservesDoubleBacktickCodeSpans() throws { + let markdown = "Use ``let `tick` = true`` today" + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + + #expect(String(block.text.characters) == "Use let `tick` = true today") + + let inlineNodes = NativeEditorDocument.inlineNodes(from: block.text) + let codeNode = try #require(inlineNodes.first { $0.text == "let `tick` = true" }) + #expect(codeNode.marks?.contains(ProseMirrorMark(type: "code")) == true) + #expect(NativeEditorMarkdownParser.markdown(from: [block]) == markdown) + } +} From c4621ca0d181435751a4bda9951a84404f5aff13 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 08:21:48 +0100 Subject: [PATCH 149/201] fix: parse multi-backtick inline code --- ...tiveEditorMarkdownParser+InlineMarks.swift | 46 +++++++++++++++---- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift index 0fa0402..7623044 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift @@ -134,26 +134,54 @@ extension NativeEditorMarkdownParser { } private static func codeInlineMarkdownMatch(in markdown: Substring) -> InlineMarkdownMatch? { - guard - let openIndex = markdown.firstIndex(of: "`"), - let closeIndex = markdown[markdown.index(after: openIndex)...].firstIndex(of: "`") - else { - return nil - } + guard let openingRun = nextBacktickRun(in: markdown, startingAt: markdown.startIndex), + let closingRun = nextMatchingBacktickRun(in: markdown, after: openingRun) else { return nil } - let contentStart = markdown.index(after: openIndex) - let content = String(markdown[contentStart.. + ) -> Range? { + var searchStart = openingRun.upperBound + + while let run = nextBacktickRun(in: markdown, startingAt: searchStart) { + if markdown.distance(from: run.lowerBound, to: run.upperBound) == + markdown.distance(from: openingRun.lowerBound, to: openingRun.upperBound) { + return run + } + + searchStart = run.upperBound + } + + return nil + } + + private static func nextBacktickRun( + in markdown: Substring, + startingAt searchStart: String.Index + ) -> Range? { + guard searchStart < markdown.endIndex, + let runStart = markdown[searchStart...].firstIndex(of: "`") else { return nil } + + var runEnd = runStart + while runEnd < markdown.endIndex, markdown[runEnd] == "`" { + runEnd = markdown.index(after: runEnd) + } + + return runStart.. InlineMarkdownMatch? { var searchStart = markdown.startIndex From 16fed213f91d8aefdbef78d39c69a371d716c450 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 08:33:02 +0100 Subject: [PATCH 150/201] test: cover markdown front matter import --- ...EditorMarkdownFrontMatterImportTests.swift | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 docmostlyTests/Editor/NativeEditorMarkdownFrontMatterImportTests.swift diff --git a/docmostlyTests/Editor/NativeEditorMarkdownFrontMatterImportTests.swift b/docmostlyTests/Editor/NativeEditorMarkdownFrontMatterImportTests.swift new file mode 100644 index 0000000..c5df60e --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorMarkdownFrontMatterImportTests.swift @@ -0,0 +1,27 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorFrontMatterImportTests { + @Test func markdownImportDropsLeadingYAMLFrontMatter() throws { + let blocks = NativeEditorMarkdownParser.blocks(from: """ + --- + title: Launch plan + tags: + - rollout + --- + + # Overview + Ready to ship + """) + + #expect(blocks.count == 2) + let heading = try #require(blocks.first) + let paragraph = try #require(blocks.last) + #expect(heading.kind == .heading(level: 1)) + #expect(String(heading.text.characters) == "Overview") + #expect(paragraph.kind == .paragraph) + #expect(String(paragraph.text.characters) == "Ready to ship") + } +} From c715221b4df3ccef14cf3f9a628b39a23c0a787e Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 08:39:00 +0100 Subject: [PATCH 151/201] fix: strip markdown front matter on import --- .../Editor/NativeEditorMarkdownParser.swift | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift index b1d3908..4cd5ae4 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift @@ -7,6 +7,7 @@ struct NativeEditorMarkdownInputRule: Equatable { enum NativeEditorMarkdownParser { static func blocks(from markdown: String) -> [NativeEditorBlock] { + let markdown = removingLeadingYAMLFrontMatter(from: markdown) let lines = markdown.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) var blocks: [NativeEditorBlock] = [] var index = lines.startIndex @@ -45,6 +46,25 @@ enum NativeEditorMarkdownParser { return blocks.isEmpty ? [NativeEditorDocument.emptyBlock()] : blocks } + private static func removingLeadingYAMLFrontMatter(from markdown: String) -> String { + var start = markdown.startIndex + while start < markdown.endIndex, markdown[start].isWhitespace { + start = markdown.index(after: start) + } + + guard start < markdown.endIndex, markdown[start...].hasPrefix("---") else { return markdown } + + let bodyStart = markdown.index(start, offsetBy: 3) + guard let closeRange = markdown[bodyStart...].range(of: "---") else { return markdown } + + var contentStart = closeRange.upperBound + while contentStart < markdown.endIndex, markdown[contentStart].isWhitespace { + contentStart = markdown.index(after: contentStart) + } + + return String(markdown[contentStart...]) + } + private static func paragraphBlock( in lines: [String], startingAt index: Array.Index From 154c263635a0d1f0d2796e2dd1b67976be01d0f1 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 09:05:21 +0100 Subject: [PATCH 152/201] test: cover balanced inline link import --- ...ativeEditorMarkdownBalancedLinkTests.swift | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 docmostlyTests/Editor/NativeEditorMarkdownBalancedLinkTests.swift diff --git a/docmostlyTests/Editor/NativeEditorMarkdownBalancedLinkTests.swift b/docmostlyTests/Editor/NativeEditorMarkdownBalancedLinkTests.swift new file mode 100644 index 0000000..b953c3a --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorMarkdownBalancedLinkTests.swift @@ -0,0 +1,25 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorMarkdownBalancedLinkTests { + @Test func inlineMarkdownLinksImportDestinationsWithBalancedParentheses() throws { + let source = "https://example.com/releases/Launch_(June)" + let block = try #require(NativeEditorMarkdownParser.blocks( + from: #"Stage Ship [Launch notes](\#(source))"# + ).first) + + #expect(block.kind == .paragraph) + let statusRun = try #require(block.text.runs.first { run in + run[NativeEditorStatusAttribute.self]?.text == "Ship" + }) + #expect(statusRun[NativeEditorStatusAttribute.self]?.color == "green") + + let linkRun = try #require(block.text.runs.first { run in + run.link?.absoluteString == source + }) + #expect(String(block.text[linkRun.range].characters) == "Launch notes") + #expect(String(block.text.characters) == "Stage Ship Launch notes") + } +} From 754bcde57e2a8ff8cd4964fc5404970d42dd29df Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 09:06:17 +0100 Subject: [PATCH 153/201] fix: parse balanced markdown link destinations --- ...iveEditorMarkdownParser+DocmostLinks.swift | 7 +- ...tiveEditorMarkdownParser+InlineMarks.swift | 137 ++++++++++++++++-- 2 files changed, 130 insertions(+), 14 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift index 818ab11..889beeb 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift @@ -292,9 +292,10 @@ extension NativeEditorMarkdownParser { let closeLabelIndex = markdown[markdown.index(after: openLabelIndex)...].firstIndex(of: "]"), markdown.index(after: closeLabelIndex) < markdown.endIndex, markdown[markdown.index(after: closeLabelIndex)] == "(", - let closeDestinationIndex = markdown[ - markdown.index(after: markdown.index(after: closeLabelIndex))... - ].firstIndex(of: ")") + let closeDestinationIndex = closingMarkdownLinkDestinationIndex( + in: markdown, + startingAt: markdown.index(after: markdown.index(after: closeLabelIndex)) + ) else { return nil } diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift index 7623044..60a8b55 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift @@ -196,9 +196,10 @@ extension NativeEditorMarkdownParser { let closeLabelIndex = markdown[markdown.index(after: openLabelIndex)...].firstIndex(of: "]"), markdown.index(after: closeLabelIndex) < markdown.endIndex, markdown[markdown.index(after: closeLabelIndex)] == "(", - let closeDestinationIndex = markdown[ - markdown.index(after: markdown.index(after: closeLabelIndex))... - ].firstIndex(of: ")") + let closeDestinationIndex = closingMarkdownLinkDestinationIndex( + in: markdown, + startingAt: markdown.index(after: markdown.index(after: closeLabelIndex)) + ) else { return nil } @@ -206,7 +207,7 @@ extension NativeEditorMarkdownParser { let labelStartIndex = markdown.index(after: openLabelIndex) let destinationStartIndex = markdown.index(after: markdown.index(after: closeLabelIndex)) let label = String(markdown[labelStartIndex.. String.Index? { + var scanner = MarkdownLinkDestinationScanner( + markdown: markdown, + destinationStartIndex: destinationStartIndex + ) + return scanner.closingIndex() + } + private static func isPartOfRepeatedDelimiter( _ range: Range, delimiter: String, @@ -276,15 +288,118 @@ extension NativeEditorMarkdownParser { return range.upperBound < markdown.endIndex && markdown[range.upperBound] == delimiterCharacter } - private static func markdownLinkDestination(from destination: String) -> String { - let trimmedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) +} + +private struct MarkdownLinkDestinationScanner { + var markdown: Substring + var index: String.Index + var nestedParenthesisCount = 0 + var quoteDelimiter: Character? + var isInsideAngleDestination = false + var hasReadNonWhitespaceDestinationCharacter = false + + init(markdown: Substring, destinationStartIndex: String.Index) { + self.markdown = markdown + index = destinationStartIndex + } + + mutating func closingIndex() -> String.Index? { + while index < markdown.endIndex { + let character = markdown[index] + defer { index = markdown.index(after: index) } + + if consumesEscapedCharacter(character) || consumesQuotedCharacter(character) || + consumesAngleDestinationCharacter(character) || consumesOpeningAngleDestination(character) || + consumesTitleQuote(character) { + continue + } + + if let closingIndex = consumeParenthesis(character) { + return closingIndex + } + + markDestinationCharacter(character) + } + + return nil + } + + mutating private func consumesEscapedCharacter(_ character: Character) -> Bool { + guard isEscapedCharacter(at: index) else { return false } + markDestinationCharacter(character) + return true + } + + mutating private func consumesQuotedCharacter(_ character: Character) -> Bool { + guard let delimiter = quoteDelimiter else { return false } + if character == delimiter { + quoteDelimiter = nil + } + return true + } + + mutating private func consumesAngleDestinationCharacter(_ character: Character) -> Bool { + guard isInsideAngleDestination else { return false } + if character == ">" { + isInsideAngleDestination = false + } + return true + } + + mutating private func consumesOpeningAngleDestination(_ character: Character) -> Bool { + guard character == "<", hasReadNonWhitespaceDestinationCharacter == false else { return false } + isInsideAngleDestination = true + hasReadNonWhitespaceDestinationCharacter = true + return true + } + + mutating private func consumesTitleQuote(_ character: Character) -> Bool { + guard isMarkdownLinkTitleQuote(character) else { return false } + quoteDelimiter = character + return true + } + + mutating private func consumeParenthesis(_ character: Character) -> String.Index? { + switch character { + case "(": + nestedParenthesisCount += 1 + return nil + case ")": + guard nestedParenthesisCount > 0 else { return index } + nestedParenthesisCount -= 1 + return nil + default: + return nil + } + } + + mutating private func markDestinationCharacter(_ character: Character) { + if character.isWhitespace == false { + hasReadNonWhitespaceDestinationCharacter = true + } + } + + private func isMarkdownLinkTitleQuote(_ character: Character) -> Bool { + guard character == "\"" || character == "'", + index > markdown.startIndex else { + return false + } + + return markdown[markdown.index(before: index)].isWhitespace + } + + private func isEscapedCharacter(at index: String.Index) -> Bool { + var backslashCount = 0 + var currentIndex = index + + while currentIndex > markdown.startIndex { + let previousIndex = markdown.index(before: currentIndex) + guard markdown[previousIndex] == "\\" else { break } - if trimmedDestination.hasPrefix("<"), - let closeIndex = trimmedDestination.firstIndex(of: ">") { - let sourceStartIndex = trimmedDestination.index(after: trimmedDestination.startIndex) - return String(trimmedDestination[sourceStartIndex.. Date: Mon, 29 Jun 2026 09:32:35 +0100 Subject: [PATCH 154/201] fix: import single-line math markdown blocks --- ...ativeEditorMarkdownParser+MathBlocks.swift | 28 +++++++++++++++++++ ...ativeEditorMarkdownParser+RichBlocks.swift | 3 +- .../NativeEditorMathMarkdownTests.swift | 19 +++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+MathBlocks.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+MathBlocks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+MathBlocks.swift new file mode 100644 index 0000000..29b0a99 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+MathBlocks.swift @@ -0,0 +1,28 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func singleLineMathFenceBlock(from line: String) -> NativeEditorBlock? { + guard + line.hasPrefix("$$"), + line.hasPrefix("$$$") == false, + line.hasSuffix("$$") + else { + return nil + } + + let contentStart = line.index(line.startIndex, offsetBy: 2) + let contentEnd = line.index(line.endIndex, offsetBy: -2) + guard contentStart <= contentEnd else { return nil } + + let mathText = String(line[contentStart.. NativeEditorBlock? { - pageBreakHTMLBlock(from: line) ?? imageMarkdownBlock(from: line) ?? iframeEmbedMarkdownBlock(from: line) ?? + singleLineMathFenceBlock(from: line) ?? pageBreakHTMLBlock(from: line) ?? imageMarkdownBlock(from: line) ?? + iframeEmbedMarkdownBlock(from: line) ?? linkedFileMarkdownBlock(from: line) } diff --git a/docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift index 4d3efe6..e2aec44 100644 --- a/docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift +++ b/docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift @@ -4,6 +4,25 @@ import Testing @MainActor struct NativeEditorMathMarkdownTests { + @Test func markdownImportSupportsSingleLineMathBlockFence() throws { + let block = try #require(NativeEditorMarkdownParser.blocks( + from: "$$E = mc^2$$" + ).first) + + guard case .mathBlock(let math) = block.kind else { + Issue.record("Expected standalone double-dollar math to import as a native math block.") + return + } + + #expect(math.text == "E = mc^2") + #expect(block.rawNode?.type == "mathBlock") + #expect(block.rawNode?.attrs?["text"] == .string("E = mc^2")) + #expect( + NativeEditorMarkdownParser.markdown(from: [block]) == + #"
E = mc^2
"# + ) + } + @Test func markdownImportKeepsCurrencyDollarAmountsAsPlainText() throws { let block = try #require(NativeEditorMarkdownParser.blocks( from: "Budget is $5 and $6 tomorrow" From 9e666fc4e5ae9cefc3cdc434f18cdc3bdcf5a81b Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 09:44:03 +0100 Subject: [PATCH 155/201] fix: export math blocks as markdown fences --- ...veEditorMarkdownParser+ContainerHTML.swift | 7 ------ ...tiveEditorContainerHTMLFidelityTests.swift | 6 +++-- .../NativeEditorMathMarkdownTests.swift | 23 +++++++++++++++---- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+ContainerHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+ContainerHTML.swift index 065fbca..c22b55f 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+ContainerHTML.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+ContainerHTML.swift @@ -22,8 +22,6 @@ extension NativeEditorMarkdownParser { calloutHTMLMarkdown(from: callout) case .details(let details): detailsHTMLMarkdown(from: details) - case .mathBlock(let math): - mathBlockHTMLMarkdown(from: math) default: nil } @@ -160,11 +158,6 @@ extension NativeEditorMarkdownParser { """ } - private static func mathBlockHTMLMarkdown(from math: NativeEditorMathBlock) -> String { - let text = escapedInlineHTMLText(math.text.trimmingCharacters(in: .whitespacesAndNewlines)) - return #"
\#(text)
"# - } - private static func htmlContainerBody( in lines: [String], startingAt index: Array.Index, diff --git a/docmostlyTests/Editor/NativeEditorContainerHTMLFidelityTests.swift b/docmostlyTests/Editor/NativeEditorContainerHTMLFidelityTests.swift index cc1c70a..22c8b85 100644 --- a/docmostlyTests/Editor/NativeEditorContainerHTMLFidelityTests.swift +++ b/docmostlyTests/Editor/NativeEditorContainerHTMLFidelityTests.swift @@ -51,7 +51,7 @@ struct NativeEditorContainerHTMLFidelityTests { #expect(blocks[2].rawNode?.attrs?["text"] == .string("E = mc^2")) } - @Test func exportsNativeCalloutDetailsAndMathBlocksAsDocmostHTMLWhenNeeded() { + @Test func exportsNativeCalloutDetailsAsDocmostHTMLAndMathAsFenceMarkdown() { let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") viewModel.document = NativeEditorDocument(blocks: [ NativeEditorBlock( @@ -90,7 +90,9 @@ struct NativeEditorContainerHTMLFidelityTests { Ship build
-
E = mc^2
+ $$ + E = mc^2 + $$ """) } diff --git a/docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift index e2aec44..7fbaae1 100644 --- a/docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift +++ b/docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift @@ -4,6 +4,20 @@ import Testing @MainActor struct NativeEditorMathMarkdownTests { + @Test func nativeMathBlockExportsAsDocmostMathFenceMarkdown() { + let block = NativeEditorBlock( + kind: .mathBlock(NativeEditorMathBlock(text: "E = mc^2")), + text: AttributedString("E = mc^2"), + alignment: .left + ) + + #expect(NativeEditorMarkdownParser.markdown(from: [block]) == """ + $$ + E = mc^2 + $$ + """) + } + @Test func markdownImportSupportsSingleLineMathBlockFence() throws { let block = try #require(NativeEditorMarkdownParser.blocks( from: "$$E = mc^2$$" @@ -17,10 +31,11 @@ struct NativeEditorMathMarkdownTests { #expect(math.text == "E = mc^2") #expect(block.rawNode?.type == "mathBlock") #expect(block.rawNode?.attrs?["text"] == .string("E = mc^2")) - #expect( - NativeEditorMarkdownParser.markdown(from: [block]) == - #"
E = mc^2
"# - ) + #expect(NativeEditorMarkdownParser.markdown(from: [block]) == """ + $$ + E = mc^2 + $$ + """) } @Test func markdownImportKeepsCurrencyDollarAmountsAsPlainText() throws { From 67058e3cfa3a0c778ba097540c331398cb9a07ef Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 09:51:46 +0100 Subject: [PATCH 156/201] test: expect math fences in rich export fixture --- .../Editor/NativeEditorRichMarkdownExportTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift index a11969c..346c69f 100644 --- a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift +++ b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift @@ -222,7 +222,9 @@ struct NativeEditorRichMarkdownExportTests { -
E = mc^2
+ $$ + E = mc^2 + $$ """) } From fcb39194a00ed785c0212566b92a469f5552a029 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 10:14:55 +0100 Subject: [PATCH 157/201] fix: import standalone iframe embeds --- .../NativeEditorMarkdownParser+Embeds.swift | 91 +----------- ...iveEditorMarkdownParser+IframeEmbeds.swift | 129 ++++++++++++++++++ .../NativeEditorMediaHTMLFidelityTests.swift | 18 +++ 3 files changed, 151 insertions(+), 87 deletions(-) create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+IframeEmbeds.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift index e1be03c..4fc6dc3 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift @@ -7,6 +7,10 @@ extension NativeEditorMarkdownParser { ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { let line = lines[index] + if let iframe = iframeHTMLBlock(in: lines, startingAt: index) { + return iframe + } + if let image = imageHTMLBlock(from: line) { return (image, lines.index(after: index)) } @@ -60,26 +64,6 @@ extension NativeEditorMarkdownParser { return nil } - static func iframeEmbedMarkdownBlock(from line: String) -> NativeEditorBlock? { - guard let link = iframeMarkdownLink(from: line), isWebEmbedSource(link.source) else { - return nil - } - - let embed = NativeEditorEmbedBlock( - source: link.source, - provider: "iframe", - alignment: nil, - width: nil, - height: nil - ) - return NativeEditorBlock( - kind: .embed(embed), - text: AttributedString(link.source), - alignment: .left, - rawNode: NativeEditorRichBlockNodeFactory.embedNode(from: embed) - ) - } - static func mediaHTMLMarkdown(from media: NativeEditorMediaBlock, type: String) -> String? { guard mediaRequiresDocmostHTML(media, type: type) else { return nil } @@ -470,73 +454,6 @@ extension NativeEditorMarkdownParser { nonEmptyHTMLAttribute(attributes["data-attachment-id"]) ?? source.flatMap(docmostAttachmentID) } - private static func iframeMarkdownLink(from line: String) -> (label: String, source: String)? { - guard line.hasPrefix("["), let closeLabelIndex = line.firstIndex(of: "]") else { - return nil - } - - let openDestinationIndex = line.index(after: closeLabelIndex) - guard - openDestinationIndex < line.endIndex, - line[openDestinationIndex] == "(", - let closeDestinationIndex = line.lastIndex(of: ")"), - closeDestinationIndex == line.index(before: line.endIndex) - else { - return nil - } - - let labelStartIndex = line.index(after: line.startIndex) - let label = unescapedMarkdownLinkLabel(String(line[labelStartIndex.. String { - var source = destination.trimmingCharacters(in: .whitespacesAndNewlines) - if source.hasPrefix("<"), source.hasSuffix(">") { - source.removeFirst() - source.removeLast() - } - return source.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private static func unescapedMarkdownLinkLabel(_ text: String) -> String { - var result = "" - var isEscaped = false - - for character in text { - if isEscaped { - result.append(character) - isEscaped = false - } else if character == "\\" { - isEscaped = true - } else { - result.append(character) - } - } - - return isEscaped ? result + "\\" : result - } - - private static func isWebEmbedSource(_ source: String) -> Bool { - guard - let components = URLComponents(string: source), - let scheme = components.scheme?.lowercased(), - scheme == "https" || scheme == "http", - components.host?.isEmpty == false - else { - return false - } - - let path = components.percentEncodedPath.lowercased() - return path == "/embed" || - path.contains("/embed/") || - path == "/live-embed" || - path.contains("/live-embed/") - } - private static func htmlTag(_ name: String, attributes: [(String, String?)]) -> String { let attributeText = attributes.compactMap { key, value -> String? in guard let value = nonEmptyHTMLAttribute(value) else { return nil } diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+IframeEmbeds.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+IframeEmbeds.swift new file mode 100644 index 0000000..bc56307 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+IframeEmbeds.swift @@ -0,0 +1,129 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func iframeHTMLBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + guard + let attributes = htmlTagAttributes(from: lines[index], tagName: "iframe"), + let source = nonEmptyIframeAttribute(attributes["src"]), + isWebURL(source) + else { + return nil + } + + var currentIndex = index + while currentIndex < lines.endIndex { + if containsHTMLClosingTag(in: lines[currentIndex], tagName: "iframe") { + return (iframeEmbedBlock(source: source), lines.index(after: currentIndex)) + } + currentIndex = lines.index(after: currentIndex) + } + + return (iframeEmbedBlock(source: source), lines.index(after: index)) + } + + static func iframeEmbedMarkdownBlock(from line: String) -> NativeEditorBlock? { + guard let link = iframeMarkdownLink(from: line), isWebEmbedSource(link.source) else { + return nil + } + + return iframeEmbedBlock(source: link.source) + } + + private static func iframeMarkdownLink(from line: String) -> (label: String, source: String)? { + guard line.hasPrefix("["), let closeLabelIndex = line.firstIndex(of: "]") else { + return nil + } + + let openDestinationIndex = line.index(after: closeLabelIndex) + guard + openDestinationIndex < line.endIndex, + line[openDestinationIndex] == "(", + let closeDestinationIndex = line.lastIndex(of: ")"), + closeDestinationIndex == line.index(before: line.endIndex) + else { + return nil + } + + let labelStartIndex = line.index(after: line.startIndex) + let label = unescapedMarkdownLinkLabel(String(line[labelStartIndex.. String { + var source = destination.trimmingCharacters(in: .whitespacesAndNewlines) + if source.hasPrefix("<"), source.hasSuffix(">") { + source.removeFirst() + source.removeLast() + } + return source.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func iframeEmbedBlock(source: String) -> NativeEditorBlock { + let embed = NativeEditorEmbedBlock( + source: source, + provider: "iframe", + alignment: nil, + width: nil, + height: nil + ) + + return NativeEditorBlock( + kind: .embed(embed), + text: AttributedString(source), + alignment: .left, + rawNode: NativeEditorRichBlockNodeFactory.embedNode(from: embed) + ) + } + + private static func unescapedMarkdownLinkLabel(_ text: String) -> String { + var result = "" + var isEscaped = false + + for character in text { + if isEscaped { + result.append(character) + isEscaped = false + } else if character == "\\" { + isEscaped = true + } else { + result.append(character) + } + } + + return isEscaped ? result + "\\" : result + } + + private static func isWebEmbedSource(_ source: String) -> Bool { + guard isWebURL(source) else { return false } + + let path = URLComponents(string: source)?.percentEncodedPath.lowercased() ?? "" + return path == "/embed" || + path.contains("/embed/") || + path == "/live-embed" || + path.contains("/live-embed/") + } + + private static func isWebURL(_ source: String) -> Bool { + guard + let components = URLComponents(string: source), + let scheme = components.scheme?.lowercased(), + scheme == "https" || scheme == "http", + components.host?.isEmpty == false + else { + return false + } + + return true + } + + private static func nonEmptyIframeAttribute(_ value: String?) -> String? { + let trimmedValue = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedValue?.isEmpty == false ? trimmedValue : nil + } +} diff --git a/docmostlyTests/Editor/NativeEditorMediaHTMLFidelityTests.swift b/docmostlyTests/Editor/NativeEditorMediaHTMLFidelityTests.swift index 6d56499..2c1952e 100644 --- a/docmostlyTests/Editor/NativeEditorMediaHTMLFidelityTests.swift +++ b/docmostlyTests/Editor/NativeEditorMediaHTMLFidelityTests.swift @@ -53,6 +53,24 @@ struct NativeEditorMediaHTMLFidelityTests { #expect(NativeEditorMarkdownParser.markdown(from: blocks) == embedHTML()) } + @Test func importsStandaloneIframeHTMLAsIframeEmbedBlock() throws { + let markdown = #""# + let blocks = NativeEditorMarkdownParser.blocks(from: markdown) + + try #require(blocks.count == 1) + guard case .embed(let embed) = blocks[0].kind else { + Issue.record("Expected standalone iframe HTML to import as an iframe embed block.") + return + } + + #expect(embed.source == "https://example.com/embed/demo") + #expect(embed.provider == "iframe") + #expect(NativeEditorDocument.node(from: blocks[0]).type == "embed") + #expect(NativeEditorMarkdownParser.markdown(from: blocks) == """ + [https://example.com/embed/demo](https://example.com/embed/demo) + """) + } + private func nativeBlocks() -> [NativeEditorBlock] { [ NativeEditorBlock(kind: .image(imageBlock()), text: AttributedString("Hero"), alignment: .left), From 2d32dec130d698b439e494b3e25196c147423f57 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 10:20:04 +0100 Subject: [PATCH 158/201] ci: use generic simulator for iPad build --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa73b13..cb625d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,7 +92,7 @@ jobs: xcodebuild build \ -project docmostly.xcodeproj \ -scheme docmostly \ - -destination 'platform=iOS Simulator,name=iPad Pro 13-inch (M5),OS=latest' \ + -destination 'generic/platform=iOS Simulator' \ CODE_SIGNING_ALLOWED=NO macos-unit-tests: From dd677f0941f972dcd96042a5ee712e84faa96117 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 10:37:49 +0100 Subject: [PATCH 159/201] fix: match Docmost slash command ordering --- .../NativeEditorCommand+SlashMenu.swift | 53 +++++++++++++++++++ .../Editor/NativeRichEditorViewModel.swift | 2 +- .../NativeEditorSlashCommandTests.swift | 52 ++++++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 docmostly/Features/Editor/NativeEditorCommand+SlashMenu.swift diff --git a/docmostly/Features/Editor/NativeEditorCommand+SlashMenu.swift b/docmostly/Features/Editor/NativeEditorCommand+SlashMenu.swift new file mode 100644 index 0000000..679be25 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorCommand+SlashMenu.swift @@ -0,0 +1,53 @@ +import Foundation + +extension NativeEditorCommand { + static let slashMenuCases: [NativeEditorCommand] = [ + .paragraph, + .todoList, + .heading1, + .heading2, + .heading3, + .bulletedList, + .numberedList, + .quote, + .codeBlock, + .divider, + .pageBreak, + .image, + .video, + .audio, + .pdf, + .fileAttachment, + .table, + .baseInline, + .kanban, + .details, + .callout, + .mathInline, + .mathBlock, + .mermaid, + .drawio, + .excalidraw, + .date, + .time, + .status, + .emoji, + .subpages, + .syncedBlock, + .columns, + .columns3, + .columns4, + .columns5, + .iframeEmbed, + .airtableEmbed, + .loomEmbed, + .figmaEmbed, + .typeformEmbed, + .miroEmbed, + .youtubeEmbed, + .vimeoEmbed, + .framerEmbed, + .googleDriveEmbed, + .googleSheetsEmbed + ] +} diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel.swift b/docmostly/Features/Editor/NativeRichEditorViewModel.swift index ce508ff..bc94c14 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel.swift @@ -115,7 +115,7 @@ final class NativeRichEditorViewModel { var filteredSlashCommands: [NativeEditorCommand] { guard let slashCommandQuery = activeSlashCommandQuery else { return [] } - let matches = NativeEditorCommand.allCases.compactMap { command in + let matches = NativeEditorCommand.slashMenuCases.compactMap { command in command.matchPriority(query: slashCommandQuery).map { priority in (command: command, priority: priority) } diff --git a/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift b/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift index 68b186b..2851f63 100644 --- a/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift +++ b/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift @@ -83,6 +83,58 @@ struct NativeEditorSlashCommandTests { } } + @Test func slashCommandInventoryFollowsDocmostWebMenuOrder() { + #expect(slashCommandTitles(for: "") == [ + "Text", + "To-do list", + "Heading 1", + "Heading 2", + "Heading 3", + "Bullet list", + "Numbered list", + "Quote", + "Code", + "Divider", + "Page break", + "Image", + "Video", + "Audio", + "Embed PDF", + "File attachment", + "Table", + "Base (Inline)", + "Kanban", + "Toggle block", + "Callout", + "Math inline", + "Math block", + "Mermaid diagram", + "Draw.io (diagrams.net)", + "Excalidraw (Whiteboard)", + "Date", + "Time", + "Status", + "Emoji", + "Subpages (Child pages)", + "Synced block", + "2 Columns", + "3 Columns", + "4 Columns", + "5 Columns", + "Iframe embed", + "Airtable", + "Loom", + "Figma", + "Typeform", + "Miro", + "YouTube", + "Vimeo", + "Framer", + "Google Drive", + "Google Sheets" + ]) + } + @Test func slashCommandFilteringUsesDocmostSearchTerms() { let expectations = [ SlashCommandFilterExpectation(query: "today", title: "Date"), From 92b4b48536be153fab77688dbd62f37eccd20213 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 10:54:00 +0100 Subject: [PATCH 160/201] fix: preserve table paragraph attributes --- .../NativeEditorDocument+Payloads.swift | 4 +- .../NativeEditorTablePayloadTests.swift | 61 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift b/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift index 92bcaf6..7bf20f8 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift @@ -188,6 +188,8 @@ nonisolated extension NativeEditorDocument { guard content.isEmpty == false else { return nil } let hasBlockContent = content.count != 1 || content.first?.type != "paragraph" + let hasParagraphAttrs = content.first?.type == "paragraph" && + content.first?.attrs?.isEmpty == false let hasUnsupportedInlineContent = inlineContent.contains { item in if case .unsupported = item { return true @@ -196,7 +198,7 @@ nonisolated extension NativeEditorDocument { return false } - return hasBlockContent || hasUnsupportedInlineContent ? content : nil + return hasBlockContent || hasParagraphAttrs || hasUnsupportedInlineContent ? content : nil } private static func tableColumnWidths(from attrs: [String: ProseMirrorJSONValue]?) -> [Int] { diff --git a/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift b/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift index 12c81b0..262e50e 100644 --- a/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift +++ b/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift @@ -107,6 +107,67 @@ struct NativeEditorTablePayloadTests { #expect(encodedCell.attrs?["backgroundColorName"] == .string("yellow")) } + @Test func editingTableCellPreservesParagraphAttributesInOtherCells() throws { + let data = Data(""" + { + "type": "doc", + "content": [ + { + "type": "table", + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "content": [ + { + "type": "paragraph", + "attrs": { "textAlign": "right" }, + "content": [{ "type": "text", "text": "Metric" }] + } + ] + }, + { + "type": "tableCell", + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Draft" }] + } + ] + } + ] + } + ] + } + ] + } + """.utf8) + + let document = try NativeEditorDocument(proseMirrorJSONData: data) + + guard case .table(var table) = document.blocks.first?.kind else { + Issue.record("Expected table block") + return + } + + table.rows[0].cells[1].plainText = "Ready" + table.rows[0].cells[1].inlineContent = nil + table.rows[0].cells[1].preservedContent = nil + + let reencodedDocument = NativeEditorDocument(blocks: [ + NativeEditorBlock(kind: .table(table), text: AttributedString("Table"), alignment: .left) + ]) + let firstCell = try #require( + reencodedDocument.proseMirrorDocument.content.first?.content?.first?.content?.first + ) + let firstParagraph = try #require(firstCell.content?.first) + + #expect(firstParagraph.attrs?["textAlign"] == .string("right")) + #expect(firstParagraph.content?.first?.text == "Metric") + } + @MainActor @Test func tableCellBackgroundRespectsRGBAAlphaPercentages() { let components = NativeEditorTableLayout.cssRGBAComponents(from: "rgba(255, 0, 0, 50%)") From 885f41c45d9c3d0c0f326f275b995b1f16bbab73 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 11:13:53 +0100 Subject: [PATCH 161/201] fix: preserve markdown table alignment --- .../NativeEditorDocument+Payloads.swift | 14 +++ .../NativeEditorMarkdownParser+Tables.swift | 115 ++++++++++++++++-- .../NativeEditorRichBlockPayloads.swift | 3 + .../NativeRichEditorViewModel+Tables.swift | 6 + .../Editor/NativeRichEditorTableTests.swift | 73 +++++++++++ 5 files changed, 198 insertions(+), 13 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift b/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift index 7bf20f8..32e11e3 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift @@ -171,6 +171,7 @@ nonisolated extension NativeEditorDocument { inlineContent: inlineContent.preservedForTableCell, preservedContent: preservedTableCellContent(from: cellContent, inlineContent: inlineContent), isHeader: cell.type == "tableHeader", + textAlignment: tableCellTextAlignment(from: cellContent), backgroundColor: cell.attrs?["backgroundColor"]?.stringValue, backgroundColorName: cell.attrs?["backgroundColorName"]?.stringValue, columnWidth: columnWidths.first, @@ -181,6 +182,19 @@ nonisolated extension NativeEditorDocument { } } + private static func tableCellTextAlignment(from content: [ProseMirrorNode]) -> NativeEditorTextAlignment? { + guard + let firstNode = content.first, + firstNode.type == "paragraph", + let attrs = firstNode.attrs, + attrs.keys.contains("textAlign") + else { + return nil + } + + return NativeEditorTextAlignment(attrs: attrs) + } + private static func preservedTableCellContent( from content: [ProseMirrorNode], inlineContent: [NativeEditorInlineContent] diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift index a95dbcb..324f23d 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift @@ -9,13 +9,24 @@ extension NativeEditorMarkdownParser { guard separatorIndex < lines.endIndex, let headerCells = markdownTableCells(from: lines[index]), - isMarkdownTableSeparatorRow(lines[separatorIndex], columnCount: headerCells.count) + let separatorAlignments = markdownTableSeparatorColumnAlignments( + from: lines[separatorIndex], + columnCount: headerCells.count + ) else { return nil } let columnCount = min(headerCells.count, NativeEditorTable.maximumColumnCount) - var rows = [tableRow(from: headerCells, isHeader: true, columnCount: columnCount)] + let columnAlignments = normalizedTableColumnAlignments(separatorAlignments, columnCount: columnCount) + var rows = [ + tableRow( + from: headerCells, + isHeader: true, + columnCount: columnCount, + columnAlignments: columnAlignments + ) + ] var currentIndex = lines.index(after: separatorIndex) while currentIndex < lines.endIndex, rows.count < NativeEditorTable.maximumRowCount { @@ -27,7 +38,14 @@ extension NativeEditorMarkdownParser { break } - rows.append(tableRow(from: cells, isHeader: false, columnCount: columnCount)) + rows.append( + tableRow( + from: cells, + isHeader: false, + columnCount: columnCount, + columnAlignments: columnAlignments + ) + ) currentIndex = lines.index(after: currentIndex) } @@ -49,7 +67,7 @@ extension NativeEditorMarkdownParser { let columnCount = max(table.columnCount, 1) let rows = [ markdownTableRow(from: headerRow, columnCount: columnCount), - markdownTableSeparatorRow(columnCount: columnCount) + markdownTableSeparatorRow(columnAlignments: tableColumnAlignments(from: table, columnCount: columnCount)) ] + table.rows.dropFirst().map { markdownTableRow(from: $0, columnCount: columnCount) } @@ -60,16 +78,23 @@ extension NativeEditorMarkdownParser { private static func tableRow( from cells: [String], isHeader: Bool, - columnCount: Int + columnCount: Int, + columnAlignments: [NativeEditorTextAlignment?] = [] ) -> NativeEditorTableRow { - NativeEditorTableRow( - cells: normalizedTableCells(cells, columnCount: columnCount).map { - tableCell(from: $0, isHeader: isHeader) + let normalizedCells = normalizedTableCells(cells, columnCount: columnCount) + return NativeEditorTableRow( + cells: normalizedCells.enumerated().map { offset, cell in + let textAlignment = columnAlignments.indices.contains(offset) ? columnAlignments[offset] : nil + return tableCell(from: cell, isHeader: isHeader, textAlignment: textAlignment) } ) } - private static func tableCell(from markdown: String, isHeader: Bool) -> NativeEditorTableCell { + private static func tableCell( + from markdown: String, + isHeader: Bool, + textAlignment: NativeEditorTextAlignment? + ) -> NativeEditorTableCell { let attributedText = inlineText(from: markdown) let inlineContent = NativeEditorDocument .inlineContent(from: NativeEditorDocument.inlineNodes(from: attributedText)) @@ -79,6 +104,7 @@ extension NativeEditorMarkdownParser { plainText: String(attributedText.characters), inlineContent: inlineContent, isHeader: isHeader, + textAlignment: textAlignment, backgroundColorName: nil ) } @@ -158,15 +184,23 @@ extension NativeEditorMarkdownParser { } private static func isMarkdownTableSeparatorRow(_ line: String, columnCount: Int) -> Bool { + markdownTableSeparatorColumnAlignments(from: line, columnCount: columnCount) != nil + } + + private static func markdownTableSeparatorColumnAlignments( + from line: String, + columnCount: Int + ) -> [NativeEditorTextAlignment?]? { guard columnCount > 0, let cells = markdownTableCells(from: line), cells.count == columnCount else { - return false + return nil } - return cells.allSatisfy(isMarkdownTableSeparatorCell) + guard cells.allSatisfy(isMarkdownTableSeparatorCell) else { return nil } + return cells.map(markdownTableSeparatorAlignment) } private static func isMarkdownTableSeparatorCell(_ text: String) -> Bool { @@ -176,6 +210,21 @@ extension NativeEditorMarkdownParser { return separator.allSatisfy { $0 == "-" } } + private static func markdownTableSeparatorAlignment(from text: String) -> NativeEditorTextAlignment? { + let trimmedText = text.trimmingCharacters(in: .whitespaces) + + switch (trimmedText.first == ":", trimmedText.last == ":") { + case (true, true): + return .center + case (false, true): + return .right + case (true, false): + return .left + case (false, false): + return nil + } + } + private static func markdownTableRow(from row: NativeEditorTableRow, columnCount: Int) -> String { let cells = normalizedTableCells(row.cells.map(markdownTableCellContent), columnCount: columnCount) return "| \(cells.map(escapedMarkdownTableCell).joined(separator: " | ")) |" @@ -238,8 +287,48 @@ extension NativeEditorMarkdownParser { .first } - private static func markdownTableSeparatorRow(columnCount: Int) -> String { - "| \(Array(repeating: "---", count: columnCount).joined(separator: " | ")) |" + private static func tableColumnAlignments( + from table: NativeEditorTable, + columnCount: Int + ) -> [NativeEditorTextAlignment?] { + (0.. NativeEditorTextAlignment? in + guard row.cells.indices.contains(columnIndex) else { return nil } + return row.cells[columnIndex].textAlignment + } + .first + } + } + + private static func normalizedTableColumnAlignments( + _ alignments: [NativeEditorTextAlignment?], + columnCount: Int + ) -> [NativeEditorTextAlignment?] { + var result = Array(alignments.prefix(columnCount)) + + if result.count < columnCount { + result.append(contentsOf: Array(repeating: nil, count: columnCount - result.count)) + } + + return result + } + + private static func markdownTableSeparatorRow(columnAlignments: [NativeEditorTextAlignment?]) -> String { + let cells = columnAlignments.map(markdownTableSeparatorCell) + return "| \(cells.joined(separator: " | ")) |" + } + + private static func markdownTableSeparatorCell(for alignment: NativeEditorTextAlignment?) -> String { + switch alignment { + case .left: + ":---" + case .center: + ":---:" + case .right: + "---:" + case .justify, nil: + "---" + } } private static func escapedMarkdownTableCell(_ text: String) -> String { diff --git a/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift b/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift index 93cfc5d..0360c01 100644 --- a/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift +++ b/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift @@ -30,6 +30,7 @@ nonisolated struct NativeEditorTableCell: Equatable, Hashable, Sendable { var inlineContent: [NativeEditorInlineContent]? var preservedContent: [ProseMirrorNode]? var isHeader: Bool + var textAlignment: NativeEditorTextAlignment? var backgroundColor: String? var backgroundColorName: String? var columnWidth: Int? @@ -42,6 +43,7 @@ nonisolated struct NativeEditorTableCell: Equatable, Hashable, Sendable { inlineContent: [NativeEditorInlineContent]? = nil, preservedContent: [ProseMirrorNode]? = nil, isHeader: Bool, + textAlignment: NativeEditorTextAlignment? = nil, backgroundColor: String? = nil, backgroundColorName: String?, columnWidth: Int? = nil, @@ -53,6 +55,7 @@ nonisolated struct NativeEditorTableCell: Equatable, Hashable, Sendable { self.inlineContent = inlineContent self.preservedContent = preservedContent self.isHeader = isHeader + self.textAlignment = textAlignment self.backgroundColor = backgroundColor self.backgroundColorName = backgroundColorName self.columnWidth = columnWidth diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+Tables.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+Tables.swift index d8ac274..2131186 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+Tables.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+Tables.swift @@ -167,8 +167,14 @@ nonisolated enum NativeEditorTableNodeFactory { private static func paragraphNode(from cell: NativeEditorTableCell) -> ProseMirrorNode { ProseMirrorNode( type: "paragraph", + attrs: paragraphAttrs(from: cell), content: cell.inlineContent.map(NativeEditorDocument.inlineNodes(from:)) ?? NativeEditorDocument.inlineNodes(from: AttributedString(cell.plainText)) ) } + + private static func paragraphAttrs(from cell: NativeEditorTableCell) -> [String: ProseMirrorJSONValue]? { + guard let textAlignment = cell.textAlignment else { return nil } + return ["textAlign": .string(textAlignment.rawValue)] + } } diff --git a/docmostlyTests/Editor/NativeRichEditorTableTests.swift b/docmostlyTests/Editor/NativeRichEditorTableTests.swift index 703dfea..6df6d77 100644 --- a/docmostlyTests/Editor/NativeRichEditorTableTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorTableTests.swift @@ -130,6 +130,46 @@ struct NativeRichEditorTableTests { #expect(firstCell?.content?.first?.content?.first?.text == "Updated") } + @Test func editingAlignedTableCellPreservesParagraphAlignmentAttribute() throws { + let data = Data(""" + { + "type": "doc", + "content": [ + { + "type": "table", + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "content": [ + { + "type": "paragraph", + "attrs": { "textAlign": "center" }, + "content": [{ "type": "text", "text": "Status" }] + } + ] + } + ] + } + ] + } + ] + } + """.utf8) + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = try NativeEditorDocument(proseMirrorJSONData: data) + let blockID = try #require(viewModel.document.blocks.first?.id) + + viewModel.updateTableCell(blockID: blockID, rowIndex: 0, columnIndex: 0, text: "Ready") + + let cell = try #require(viewModel.document.proseMirrorDocument.content.first?.content?.first?.content?.first) + let paragraph = try #require(cell.content?.first) + #expect(paragraph.attrs?["textAlign"] == .string("center")) + #expect(paragraph.content?.first?.text == "Ready") + } + @Test func editingTableCellPreservesInlineMarksInOtherCells() throws { let data = Data(""" { @@ -288,6 +328,39 @@ struct NativeRichEditorTableTests { ) } + @Test func markdownImportExportPreservesTableColumnAlignmentMarkers() throws { + let blocks = NativeEditorMarkdownParser.blocks(from: """ + | Metric | Owner | Status | + | :--- | :---: | ---: | + | Launch | Taylor | Ready | + """) + + let block = try #require(blocks.first) + guard case .table(let table) = block.kind else { + Issue.record("Expected table block") + return + } + + #expect( + NativeEditorMarkdownParser.tableMarkdown(from: table) == + """ + | Metric | Owner | Status | + | :--- | :---: | ---: | + | Launch | Taylor | Ready | + """ + ) + + let rows = try #require(block.rawNode?.content) + let headerCells = try #require(rows.first?.content) + let bodyCells = try #require(rows.dropFirst().first?.content) + #expect(headerCells[0].content?.first?.attrs?["textAlign"] == .string("left")) + #expect(headerCells[1].content?.first?.attrs?["textAlign"] == .string("center")) + #expect(headerCells[2].content?.first?.attrs?["textAlign"] == .string("right")) + #expect(bodyCells[0].content?.first?.attrs?["textAlign"] == .string("left")) + #expect(bodyCells[1].content?.first?.attrs?["textAlign"] == .string("center")) + #expect(bodyCells[2].content?.first?.attrs?["textAlign"] == .string("right")) + } + @Test func editingTableCellPreservesUnsupportedRichContentInOtherCells() throws { let data = Data(""" { From 7bdaef94f3c719469537be91d64c9c98cc25b14d Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 11:31:42 +0100 Subject: [PATCH 162/201] fix: preserve rich list item content --- .../NativeEditorDocument+Decoding.swift | 33 +++++- .../NativeEditorDocument+Encoding.swift | 100 +++++++++++++++- .../NativeEditorListFidelityTests.swift | 107 ++++++++++++++++++ 3 files changed, 235 insertions(+), 5 deletions(-) create mode 100644 docmostlyTests/Editor/NativeEditorListFidelityTests.swift diff --git a/docmostly/Features/Editor/NativeEditorDocument+Decoding.swift b/docmostly/Features/Editor/NativeEditorDocument+Decoding.swift index ab11e69..e07d03f 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+Decoding.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+Decoding.swift @@ -138,9 +138,14 @@ nonisolated extension NativeEditorDocument { item: ProseMirrorNode, indentLevel: Int ) -> [NativeEditorBlock] { - var block = textBlock(kind: kind, node: firstTextContainer(in: item) ?? item) + let textContainer = firstTextContainer(in: item) ?? item + var block = textBlock(kind: kind, node: textContainer) block.indentLevel = indentLevel + if listItemNeedsRawPreservation(kind: kind, item: item) { + block.rawNode = item + } + let nestedBlocks: [NativeEditorBlock] = (item.content ?? []).flatMap { child in if child.isListContainer { listBlocks(from: child, indentLevel: indentLevel + 1) @@ -152,6 +157,32 @@ nonisolated extension NativeEditorDocument { return [block] + nestedBlocks } + private static func listItemNeedsRawPreservation( + kind: NativeEditorBlockKind, + item: ProseMirrorNode + ) -> Bool { + listItemAttrsNeedRawPreservation(kind: kind, item: item) || + listItemHasAdditionalNonListContent(item) + } + + private static func listItemAttrsNeedRawPreservation( + kind: NativeEditorBlockKind, + item: ProseMirrorNode + ) -> Bool { + guard let attrs = item.attrs, attrs.isEmpty == false else { return false } + + if case .taskListItem = kind { + return attrs.keys.contains { $0 != "checked" } + } + + return true + } + + private static func listItemHasAdditionalNonListContent(_ item: ProseMirrorNode) -> Bool { + let nonListContent = (item.content ?? []).filter { $0.isListContainer == false } + return nonListContent.count > 1 + } + private static func orderedListOrdinal(start: Int, offset: Int) -> Int { let result = start.addingReportingOverflow(offset) return result.overflow ? Int.max : result.partialValue diff --git a/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift b/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift index aeac7d1..a504ad2 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift @@ -147,7 +147,11 @@ nonisolated extension NativeEditorDocument { private static func append(_ nestedList: ProseMirrorNode, toLastItemIn itemNodes: inout [ProseMirrorNode]) { guard var lastItem = itemNodes.popLast() else { return } var content = lastItem.content ?? [] - content.append(nestedList) + if let nestedIndex = content.firstIndex(where: \.isListContainer) { + content[nestedIndex] = nestedList + } else { + content.append(nestedList) + } lastItem.content = content itemNodes.append(lastItem) } @@ -272,17 +276,105 @@ nonisolated extension NativeEditorDocument { } private static func listItemNode(from block: NativeEditorBlock) -> ProseMirrorNode { - ProseMirrorNode(type: "listItem", content: [textContainerNode(type: "paragraph", block: block)]) + ProseMirrorNode( + type: "listItem", + attrs: preservedListItemAttrs(type: "listItem", block: block), + content: listItemContent(type: "listItem", block: block) + ) } private static func taskItemNode(from block: NativeEditorBlock) -> ProseMirrorNode { ProseMirrorNode( type: "taskItem", - attrs: ["checked": .bool(taskItemCheckedState(from: block))], - content: [textContainerNode(type: "paragraph", block: block)] + attrs: taskItemAttrs(from: block), + content: listItemContent(type: "taskItem", block: block) + ) + } + + private static func preservedListItemAttrs( + type: String, + block: NativeEditorBlock + ) -> [String: ProseMirrorJSONValue]? { + guard block.rawNode?.type == type else { return nil } + let attrs = block.rawNode?.attrs ?? [:] + return attrs.isEmpty ? nil : attrs + } + + private static func taskItemAttrs(from block: NativeEditorBlock) -> [String: ProseMirrorJSONValue] { + var attrs = block.rawNode?.type == "taskItem" ? block.rawNode?.attrs ?? [:] : [:] + attrs["checked"] = .bool(taskItemCheckedState(from: block)) + return attrs + } + + private static func listItemContent( + type: String, + block: NativeEditorBlock + ) -> [ProseMirrorNode] { + let textNode = listItemTextContainerNode(type: type, block: block) + guard block.rawNode?.type == type, var content = block.rawNode?.content else { + return [textNode] + } + + if let textIndex = content.firstIndex(where: isListItemTextContainer) { + content[textIndex] = textNode + } else { + content.insert(textNode, at: content.startIndex) + } + + return content + } + + private static func listItemTextContainerNode( + type: String, + block: NativeEditorBlock + ) -> ProseMirrorNode { + let originalNode = originalListItemTextContainer(type: type, block: block) + let nodeType = originalNode?.type ?? "paragraph" + var attrs = originalNode?.attrs ?? [:] + + if let alignment = block.alignment.proseMirrorValue { + attrs["textAlign"] = alignment + } else { + attrs.removeValue(forKey: "textAlign") + } + + return ProseMirrorNode( + type: nodeType, + attrs: attrs.isEmpty ? nil : attrs, + content: listItemTextContainerContent(type: nodeType, block: block) ) } + private static func originalListItemTextContainer( + type: String, + block: NativeEditorBlock + ) -> ProseMirrorNode? { + if block.rawNode?.type == type { + return block.rawNode?.content?.first(where: isListItemTextContainer) + } + + if let rawNode = block.rawNode, isListItemTextContainer(rawNode) { + return rawNode + } + + return nil + } + + private static func listItemTextContainerContent( + type: String, + block: NativeEditorBlock + ) -> [ProseMirrorNode] { + if type == "codeBlock" { + return plainTextNodes(from: String(block.text.characters)) + } + + return block.inlineContent.map(inlineNodes(from:)) ?? inlineNodes(from: block.text) + } + + private static func isListItemTextContainer(_ node: ProseMirrorNode) -> Bool { + node.type == "paragraph" || node.type == "heading" || node.type == "codeBlock" + } + private static func taskItemCheckedState(from block: NativeEditorBlock) -> Bool { if case .taskListItem(let isChecked) = block.kind { return isChecked diff --git a/docmostlyTests/Editor/NativeEditorListFidelityTests.swift b/docmostlyTests/Editor/NativeEditorListFidelityTests.swift new file mode 100644 index 0000000..660dc47 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorListFidelityTests.swift @@ -0,0 +1,107 @@ +import Foundation +import Testing +@testable import docmostly + +struct NativeEditorListFidelityTests { + @Test func editingListItemPreservesAdditionalDocmostListItemContent() throws { + let original = try JSONDecoder().decode( + ProseMirrorDocument.self, + from: Data(""" + { + "type": "doc", + "content": [ + { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Launch checklist" }] + }, + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Keep rollout notes attached" }] + } + ] + } + ] + } + ] + } + """.utf8) + ) + var document = NativeEditorDocument(proseMirrorDocument: original) + + #expect(document.blocks.count == 1) + #expect(document.proseMirrorDocument == original) + + document.blocks[0].text = AttributedString("Release checklist") + + let listItem = try #require(document.proseMirrorDocument.content.first?.content?.first) + let paragraphs = try #require(listItem.content) + #expect(paragraphs.count == 2) + #expect(paragraphs[0].content?.first?.text == "Release checklist") + #expect(paragraphs[1].content?.first?.text == "Keep rollout notes attached") + } + + @Test func editingListItemPreservesAdditionalContentAndNestedLists() throws { + let original = try JSONDecoder().decode( + ProseMirrorDocument.self, + from: Data(""" + { + "type": "doc", + "content": [ + { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Parent" }] + }, + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Context" }] + }, + { + "type": "taskList", + "content": [ + { + "type": "taskItem", + "attrs": { "checked": true }, + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Child task" }] + } + ] + } + ] + } + ] + } + ] + } + ] + } + """.utf8) + ) + var document = NativeEditorDocument(proseMirrorDocument: original) + + #expect(document.blocks.count == 2) + document.blocks[0].text = AttributedString("Updated parent") + document.blocks[1].text = AttributedString("Updated child task") + + let listItem = try #require(document.proseMirrorDocument.content.first?.content?.first) + let content = try #require(listItem.content) + #expect(content.count == 3) + #expect(content[0].content?.first?.text == "Updated parent") + #expect(content[1].content?.first?.text == "Context") + #expect(content[2].type == "taskList") + #expect(content[2].content?.first?.content?.first?.content?.first?.text == "Updated child task") + } +} From 8d459a6f037164a2cc4ccf154f079289a90eb747 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 11:49:52 +0100 Subject: [PATCH 163/201] fix: preserve relative inline links --- .../NativeEditorAttributedAttributes.swift | 10 ++++ .../NativeEditorDocument+InlineDecoding.swift | 25 ++++++++-- .../NativeEditorDocument+InlineEncoding.swift | 16 +++++-- .../Editor/NativeEditorInlineContent.swift | 2 +- ...tiveEditorMarkdownParser+InlineMarks.swift | 5 +- .../NativeEditorMarkdownParser+Tables.swift | 4 +- ...tiveRichEditorViewModel+BlockEditing.swift | 5 ++ .../NativeEditorLinkFidelityTests.swift | 47 +++++++++++++++++++ 8 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 docmostlyTests/Editor/NativeEditorLinkFidelityTests.swift diff --git a/docmostly/Features/Editor/NativeEditorAttributedAttributes.swift b/docmostly/Features/Editor/NativeEditorAttributedAttributes.swift index 9df2b33..77b232c 100644 --- a/docmostly/Features/Editor/NativeEditorAttributedAttributes.swift +++ b/docmostly/Features/Editor/NativeEditorAttributedAttributes.swift @@ -15,6 +15,16 @@ enum NativeEditorTextColorAttribute: CodableAttributedStringKey { static let name = "docmostly.textColor" } +nonisolated struct NativeEditorLink: Equatable, Hashable, Sendable, Codable { + var href: String + var isInternal: Bool +} + +enum NativeEditorLinkAttribute: CodableAttributedStringKey { + typealias Value = NativeEditorLink + static let name = "docmostly.link" +} + enum NativeEditorCommentIDAttribute: CodableAttributedStringKey { typealias Value = String static let name = "docmostly.commentID" diff --git a/docmostly/Features/Editor/NativeEditorDocument+InlineDecoding.swift b/docmostly/Features/Editor/NativeEditorDocument+InlineDecoding.swift index c31a765..c869775 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+InlineDecoding.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+InlineDecoding.swift @@ -89,7 +89,10 @@ nonisolated extension NativeEditorDocument { static func richTextMark(from mark: ProseMirrorMark) -> NativeEditorTextMark { switch mark.type { case "link": - .link(href: mark.attrs?["href"]?.stringValue ?? "") + .link( + href: mark.attrs?["href"]?.stringValue ?? "", + isInternal: mark.attrs?["internal"]?.boolValue ?? false + ) case "highlight": .highlight( color: mark.attrs?["color"]?.stringValue, @@ -148,8 +151,8 @@ nonisolated extension NativeEditorDocument { switch mark { case .underline: text.underlineStyle = .single - case .link(let href): - text.link = Self.safeLinkURL(from: href) + case .link(let href, let isInternal): + applyLinkMark(href: href, isInternal: isInternal, to: &text) case .highlight(let color, let colorName): if let color { text[NativeEditorHighlightColorAttribute.self] = color @@ -178,6 +181,12 @@ nonisolated extension NativeEditorDocument { } } + static func applyLinkMark(href: String, isInternal: Bool, to text: inout AttributedString) { + guard let link = preservedLink(href: href, isInternal: isInternal) else { return } + text[NativeEditorLinkAttribute.self] = link + text.link = safeLinkURL(from: href) + } + static func safeLinkURL(from href: String) -> URL? { guard let url = URL(string: href) else { return nil } let allowedSchemes = ["https", "http", "mailto"] @@ -185,6 +194,16 @@ nonisolated extension NativeEditorDocument { return allowedSchemes.contains(scheme) ? url : nil } + static func preservedLink(href: String, isInternal: Bool = false) -> NativeEditorLink? { + guard href.isEmpty == false else { return nil } + + if safeLinkURL(from: href) != nil || href.hasPrefix("/") || href.hasPrefix("#") { + return NativeEditorLink(href: href, isInternal: isInternal) + } + + return nil + } + static func insertPresentationIntent( _ presentationIntent: InlinePresentationIntent, into text: inout AttributedString diff --git a/docmostly/Features/Editor/NativeEditorDocument+InlineEncoding.swift b/docmostly/Features/Editor/NativeEditorDocument+InlineEncoding.swift index 11747e8..4a88bb0 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+InlineEncoding.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+InlineEncoding.swift @@ -74,7 +74,9 @@ nonisolated extension NativeEditorDocument { from run: AttributedString.Runs.Run, to marks: inout [ProseMirrorMark] ) { - if let href = run.link?.absoluteString { + if let link = run[NativeEditorLinkAttribute.self] { + appendMarkIfMissing(linkProseMirrorMark(href: link.href, isInternal: link.isInternal), to: &marks) + } else if let href = run.link?.absoluteString { appendMarkIfMissing(ProseMirrorMark(type: "link", attrs: ["href": .string(href)]), to: &marks) } } @@ -144,8 +146,8 @@ nonisolated extension NativeEditorDocument { private static func richProseMirrorMark(from mark: NativeEditorTextMark) -> ProseMirrorMark { switch mark { - case .link(let href): - ProseMirrorMark(type: "link", attrs: ["href": .string(href)]) + case .link(let href, let isInternal): + linkProseMirrorMark(href: href, isInternal: isInternal) case .highlight(let color, let colorName): ProseMirrorMark(type: "highlight", attrs: optionalAttrs(markAttrs(color, colorName))) case .textColor(let color): @@ -159,6 +161,14 @@ nonisolated extension NativeEditorDocument { } } + private static func linkProseMirrorMark(href: String, isInternal: Bool) -> ProseMirrorMark { + var attrs: [String: ProseMirrorJSONValue] = ["href": .string(href)] + if isInternal { + attrs["internal"] = .bool(true) + } + return ProseMirrorMark(type: "link", attrs: attrs) + } + private static func markAttrs(_ color: String?, _ colorName: String?) -> [String: String?] { [ "color": color, diff --git a/docmostly/Features/Editor/NativeEditorInlineContent.swift b/docmostly/Features/Editor/NativeEditorInlineContent.swift index 7b41982..87cfcaf 100644 --- a/docmostly/Features/Editor/NativeEditorInlineContent.swift +++ b/docmostly/Features/Editor/NativeEditorInlineContent.swift @@ -75,7 +75,7 @@ nonisolated enum NativeEditorTextMark: Equatable, Hashable, Sendable, Codable { case underline case strikethrough case code - case link(href: String) + case link(href: String, isInternal: Bool = false) case highlight(color: String?, colorName: String?) case textColor(String) case `subscript` diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift index 60a8b55..f949ff8 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift @@ -45,7 +45,7 @@ extension NativeEditorMarkdownParser { } } - if let href = run.link?.absoluteString { + if let href = run[NativeEditorLinkAttribute.self]?.href ?? run.link?.absoluteString { output = "[\(escapedMarkdownLinkLabel(output))](\(href))" } @@ -218,6 +218,9 @@ extension NativeEditorMarkdownParser { var text = attributedInlineMarkdown(from: label) text.link = url + if let link = NativeEditorDocument.preservedLink(href: destination) { + text[NativeEditorLinkAttribute.self] = link + } return InlineMarkdownMatch( range: openLabelIndex.. String? { marks.compactMap { mark -> String? in - guard case .link(let href) = mark, + guard case .link(let href, _) = mark, href.isEmpty == false, NativeEditorDocument.safeLinkURL(from: href) == nil else { return nil diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift index db16b95..baf1720 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift @@ -127,15 +127,18 @@ extension NativeRichEditorViewModel { else { return } + let link = NativeEditorLink(href: url.absoluteString, isInternal: false) if document.blocks[index].selection.hasSelectedRanges(in: document.blocks[index].text) { var selection = document.blocks[index].selection document.blocks[index].text.transformAttributes(in: &selection) { attributes in attributes.link = url + attributes[NativeEditorLinkAttribute.self] = link } document.blocks[index].selection = selection } else { document.blocks[index].text.link = url + document.blocks[index].text[NativeEditorLinkAttribute.self] = link } } } @@ -148,10 +151,12 @@ extension NativeRichEditorViewModel { var selection = document.blocks[index].selection document.blocks[index].text.transformAttributes(in: &selection) { attributes in attributes.link = nil + attributes[NativeEditorLinkAttribute.self] = nil } document.blocks[index].selection = selection } else { document.blocks[index].text.link = nil + document.blocks[index].text[NativeEditorLinkAttribute.self] = nil } } } diff --git a/docmostlyTests/Editor/NativeEditorLinkFidelityTests.swift b/docmostlyTests/Editor/NativeEditorLinkFidelityTests.swift new file mode 100644 index 0000000..12f1ac3 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorLinkFidelityTests.swift @@ -0,0 +1,47 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorLinkFidelityTests { + @Test func preservesDocmostRelativeInternalLinksFromProseMirror() throws { + let document = try NativeEditorDocument(proseMirrorJSONData: Data(""" + { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Spec", + "marks": [ + { + "type": "link", + "attrs": { + "href": "/api/files/file-1/Spec.pdf", + "internal": true + } + } + ] + } + ] + } + ] + } + """.utf8)) + + let reencodedText = try #require(document.proseMirrorDocument.content.first?.content?.first) + #expect(reencodedText.marks == [ + ProseMirrorMark( + type: "link", + attrs: [ + "href": .string("/api/files/file-1/Spec.pdf"), + "internal": .bool(true) + ] + ) + ]) + + #expect(NativeEditorMarkdownParser.markdown(from: document.blocks) == "[Spec](/api/files/file-1/Spec.pdf)") + } +} From 32ee269d06661d34f0568cf8c34f4b137630c10a Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 12:04:41 +0100 Subject: [PATCH 164/201] fix: preserve aligned table inline markdown --- .../NativeEditorMarkdownParser+Tables.swift | 27 ++++++- ...NativeEditorTableMarkdownExportTests.swift | 71 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 docmostlyTests/Editor/NativeEditorTableMarkdownExportTests.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift index 232ae98..59bb18e 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift @@ -231,7 +231,10 @@ extension NativeEditorMarkdownParser { } private static func markdownTableCellContent(from cell: NativeEditorTableCell) -> String { - guard cell.preservedContent == nil, let inlineContent = cell.inlineContent else { + guard + let inlineContent = cell.inlineContent, + tableCellCanExportInlineMarkdown(cell) + else { return cell.plainText } @@ -242,6 +245,28 @@ extension NativeEditorMarkdownParser { return inlineMarkdown(from: NativeEditorDocument.attributedText(from: inlineContent)) } + private static func tableCellCanExportInlineMarkdown(_ cell: NativeEditorTableCell) -> Bool { + guard let preservedContent = cell.preservedContent else { return true } + let hasUnsupportedInlineContent = cell.inlineContent?.contains(where: isUnsupportedInlineContent) ?? false + guard preservedContent.count == 1, + let paragraph = preservedContent.first, + paragraph.type == "paragraph", + hasUnsupportedInlineContent == false else { + return false + } + + let attrs = paragraph.attrs ?? [:] + return attrs.keys.allSatisfy { $0 == "textAlign" } + } + + private static func isUnsupportedInlineContent(_ item: NativeEditorInlineContent) -> Bool { + if case .unsupported = item { + return true + } + + return false + } + private static func tableCellInlineContentContainsUnsafeLink( _ inlineContent: [NativeEditorInlineContent] ) -> Bool { diff --git a/docmostlyTests/Editor/NativeEditorTableMarkdownExportTests.swift b/docmostlyTests/Editor/NativeEditorTableMarkdownExportTests.swift new file mode 100644 index 0000000..0b42769 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorTableMarkdownExportTests.swift @@ -0,0 +1,71 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorTableMarkdownExportTests { + @Test func markdownExportPreservesInlineMarksInsideAlignedTableCells() throws { + let document = try NativeEditorDocument(proseMirrorJSONData: Data(""" + { + "type": "doc", + "content": [ + { + "type": "table", + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableHeader", + "content": [ + { + "type": "paragraph", + "attrs": { "textAlign": "center" }, + "content": [{ "type": "text", "text": "File" }] + } + ] + } + ] + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "content": [ + { + "type": "paragraph", + "attrs": { "textAlign": "center" }, + "content": [ + { + "type": "text", + "text": "Spec", + "marks": [ + { + "type": "link", + "attrs": { "href": "/api/files/file-1/Spec.pdf" } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + """.utf8)) + + #expect( + NativeEditorMarkdownParser.markdown(from: document.blocks) == + """ + | File | + | :---: | + | [Spec](/api/files/file-1/Spec.pdf) | + """ + ) + } +} From 81bf734b01ab4a5e58169561aabad264ed7b95bc Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 12:16:57 +0100 Subject: [PATCH 165/201] fix: label exported media links by filename --- ...ativeEditorMarkdownParser+RichBlocks.swift | 23 +++++- ...NativeEditorMediaMarkdownExportTests.swift | 71 +++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 docmostlyTests/Editor/NativeEditorMediaMarkdownExportTests.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift index e988db8..20b8493 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift @@ -67,10 +67,14 @@ extension NativeEditorMarkdownParser { case .audio(let media): mediaHTMLMarkdown(from: media, type: "audio") ?? mediaLinkMarkdown(from: media, fallbackTitle: "Audio") case .pdf(let pdf): - pdfHTMLMarkdown(from: pdf) ?? linkMarkdown(title: pdf.name ?? pdf.source ?? "PDF", url: pdf.source) + pdfHTMLMarkdown(from: pdf) ?? + linkMarkdown(title: pdf.name ?? markdownLinkDisplayName(from: pdf.source) ?? "PDF", url: pdf.source) case .attachment(let attachment): attachmentHTMLMarkdown(from: attachment) ?? - linkMarkdown(title: attachment.name ?? attachment.url ?? "Attachment", url: attachment.url) + linkMarkdown( + title: attachment.name ?? markdownLinkDisplayName(from: attachment.url) ?? "Attachment", + url: attachment.url + ) default: nil } @@ -384,7 +388,8 @@ extension NativeEditorMarkdownParser { } private static func mediaLinkMarkdown(from media: NativeEditorMediaBlock, fallbackTitle: String) -> String { - linkMarkdown(title: media.alternativeText ?? media.title ?? media.source ?? fallbackTitle, url: media.source) + let title = media.alternativeText ?? media.title ?? markdownLinkDisplayName(from: media.source) ?? fallbackTitle + return linkMarkdown(title: title, url: media.source) } private static func calloutMarkdown(from callout: NativeEditorCalloutBlock) -> String { @@ -427,6 +432,18 @@ extension NativeEditorMarkdownParser { return "[\(escapedMarkdownLinkText(title))](\(url))" } + private static func markdownLinkDisplayName(from source: String?) -> String? { + guard let source, source.isEmpty == false else { return nil } + let path = markdownLinkPath(from: source) + .trimmingCharacters(in: CharacterSet(charactersIn: "/\\")) + guard path.isEmpty == false else { return nil } + + let separatorIndex = path.lastIndex { $0 == "/" || $0 == "\\" } + let nameStart = separatorIndex.map { path.index(after: $0) } ?? path.startIndex + let name = String(path[nameStart...]) + return name.isEmpty ? nil : name + } + private static func sanitizedCalloutStyle(_ value: String) -> String { let sanitizedScalars = value.lowercased().unicodeScalars.filter { CharacterSet.alphanumerics.contains($0) diff --git a/docmostlyTests/Editor/NativeEditorMediaMarkdownExportTests.swift b/docmostlyTests/Editor/NativeEditorMediaMarkdownExportTests.swift new file mode 100644 index 0000000..d7623b7 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorMediaMarkdownExportTests.swift @@ -0,0 +1,71 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorMediaMarkdownExportTests { + @Test func markdownExportUsesFilenameLabelsForUnlabeledFileBlocks() { + let blocks = [ + NativeEditorBlock( + kind: .video(NativeEditorMediaBlock( + source: "/api/files/video-1/Launch%20demo.mp4", + alternativeText: nil, + title: nil, + attachmentID: nil, + sizeInBytes: nil, + width: nil, + height: nil, + aspectRatio: nil, + alignment: nil + )), + text: AttributedString(""), + alignment: .left + ), + NativeEditorBlock( + kind: .audio(NativeEditorMediaBlock( + source: #"folder name\Briefing.m4a"#, + alternativeText: nil, + title: nil, + attachmentID: nil, + sizeInBytes: nil, + width: nil, + height: nil, + aspectRatio: nil, + alignment: nil + )), + text: AttributedString(""), + alignment: .left + ), + NativeEditorBlock( + kind: .pdf(NativeEditorPDFBlock( + source: "/api/files/pdf-1/Spec.pdf?download=1", + name: nil, + attachmentID: nil, + sizeInBytes: nil, + width: nil, + height: nil + )), + text: AttributedString(""), + alignment: .left + ), + NativeEditorBlock( + kind: .attachment(NativeEditorAttachmentBlock( + url: "/api/files/file-1/Archive.zip#download", + name: nil, + mimeType: nil, + sizeInBytes: nil, + attachmentID: nil + )), + text: AttributedString(""), + alignment: .left + ) + ] + + #expect(NativeEditorMarkdownParser.markdown(from: blocks) == #""" + [Launch%20demo.mp4](/api/files/video-1/Launch%20demo.mp4) + [Briefing.m4a](folder name\Briefing.m4a) + [Spec.pdf](/api/files/pdf-1/Spec.pdf?download=1) + [Archive.zip](/api/files/file-1/Archive.zip#download) + """#) + } +} From 2f4d39a83c5dc833fe9f1cc42e8ca1190f935752 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 12:33:51 +0100 Subject: [PATCH 166/201] fix: preserve inline comments on atom nodes --- .../NativeEditorDocument+Encoding.swift | 49 +++++++++++++++---- .../NativeEditorDocument+InlineDecoding.swift | 18 ++++--- .../Editor/NativeEditorInlineContent.swift | 22 ++++++--- .../NativeEditorInlineCommentMarkTests.swift | 49 +++++++++++++++++++ 4 files changed, 115 insertions(+), 23 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift b/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift index a504ad2..6dab538 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift @@ -452,24 +452,30 @@ nonisolated extension NativeEditorDocument { private static func inlineAtom(from run: AttributedString.Runs.Run) -> InlineAtomEncoding? { if let mention = run[NativeEditorMentionAttribute.self] { + var node = ProseMirrorNode(type: "mention", attrs: attrs(from: mention)) + node.marks = atomMarks(from: run, presentationMarkType: nil) return InlineAtomEncoding( displayText: mention.displayText, - node: ProseMirrorNode(type: "mention", attrs: attrs(from: mention)) + node: node ) } if let status = run[NativeEditorStatusAttribute.self] { + var node = ProseMirrorNode(type: "status", attrs: statusAttrs(from: status)) + node.marks = atomMarks(from: run, presentationMarkType: "bold") return InlineAtomEncoding( displayText: status.text, - node: ProseMirrorNode(type: "status", attrs: statusAttrs(from: status)), + node: node, presentationMarkType: "bold" ) } if let math = run[NativeEditorMathInlineAttribute.self] { + var node = ProseMirrorNode(type: "mathInline", attrs: ["text": .string(math.text)]) + node.marks = atomMarks(from: run, presentationMarkType: "code") return InlineAtomEncoding( displayText: math.text, - node: ProseMirrorNode(type: "mathInline", attrs: ["text": .string(math.text)]), + node: node, presentationMarkType: "code" ) } @@ -477,6 +483,19 @@ nonisolated extension NativeEditorDocument { return nil } + private static func atomMarks( + from run: AttributedString.Runs.Run, + presentationMarkType: String? + ) -> [ProseMirrorMark]? { + guard var marks = marks(from: run) else { return nil } + + if let presentationMarkType { + marks.removeAll { $0.type == presentationMarkType } + } + + return marks.isEmpty ? nil : marks + } + private static func marksForTextSurroundingAtom( from run: AttributedString.Runs.Run, atom: InlineAtomEncoding @@ -496,12 +515,24 @@ nonisolated extension NativeEditorDocument { ProseMirrorNode(type: "text", marks: proseMirrorMarks(from: marks), text: text) case .hardBreak: ProseMirrorNode(type: "hardBreak") - case .mention(let mention): - ProseMirrorNode(type: "mention", attrs: attrs(from: mention)) - case .status(let status): - ProseMirrorNode(type: "status", attrs: statusAttrs(from: status)) - case .mathInline(let math): - ProseMirrorNode(type: "mathInline", attrs: ["text": .string(math.text)]) + case .mention(let mention, let marks): + ProseMirrorNode( + type: "mention", + attrs: attrs(from: mention), + marks: proseMirrorMarks(from: marks) + ) + case .status(let status, let marks): + ProseMirrorNode( + type: "status", + attrs: statusAttrs(from: status), + marks: proseMirrorMarks(from: marks) + ) + case .mathInline(let math, let marks): + ProseMirrorNode( + type: "mathInline", + attrs: ["text": .string(math.text)], + marks: proseMirrorMarks(from: marks) + ) case .unsupported(let node): node } diff --git a/docmostly/Features/Editor/NativeEditorDocument+InlineDecoding.swift b/docmostly/Features/Editor/NativeEditorDocument+InlineDecoding.swift index c869775..87b48bb 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+InlineDecoding.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+InlineDecoding.swift @@ -8,11 +8,14 @@ nonisolated extension NativeEditorDocument { case "hardBreak": [.hardBreak] case "mention": - [.mention(mention(from: node))] + [.mention(mention(from: node), marks: textMarks(from: node.marks ?? []))] case "status": - [.status(statusBadge(from: node))] + [.status(statusBadge(from: node), marks: textMarks(from: node.marks ?? []))] case "mathInline": - [.mathInline(NativeEditorMathInline(text: node.attrs?["text"]?.stringValue ?? ""))] + [.mathInline( + NativeEditorMathInline(text: node.attrs?["text"]?.stringValue ?? ""), + marks: textMarks(from: node.marks ?? []) + )] default: nestedInlineContent(from: node) } @@ -34,19 +37,22 @@ nonisolated extension NativeEditorDocument { return segment case .hardBreak: return AttributedString("\n") - case .mention(let mention): + case .mention(let mention, let marks): var segment = AttributedString(mention.displayText) segment[NativeEditorMentionAttribute.self] = mention segment.foregroundColor = DocmostlyTheme.primary + apply(marks, to: &segment) return segment - case .status(let status): + case .status(let status, let marks): var segment = AttributedString(status.text) segment[NativeEditorStatusAttribute.self] = status + apply(marks, to: &segment) return segment - case .mathInline(let math): + case .mathInline(let math, let marks): var segment = AttributedString(math.text) segment[NativeEditorMathInlineAttribute.self] = math segment.inlinePresentationIntent = .code + apply(marks, to: &segment) return segment case .unsupported(let node): return AttributedString(node.text ?? "") diff --git a/docmostly/Features/Editor/NativeEditorInlineContent.swift b/docmostly/Features/Editor/NativeEditorInlineContent.swift index 87cfcaf..6decb8a 100644 --- a/docmostly/Features/Editor/NativeEditorInlineContent.swift +++ b/docmostly/Features/Editor/NativeEditorInlineContent.swift @@ -3,9 +3,9 @@ import Foundation nonisolated enum NativeEditorInlineContent: Equatable, Hashable, Sendable, Codable { case text(String, marks: [NativeEditorTextMark]) case hardBreak - case mention(NativeEditorMention) - case status(NativeEditorStatusBadge) - case mathInline(NativeEditorMathInline) + case mention(NativeEditorMention, marks: [NativeEditorTextMark] = []) + case status(NativeEditorStatusBadge, marks: [NativeEditorTextMark] = []) + case mathInline(NativeEditorMathInline, marks: [NativeEditorTextMark] = []) case unsupported(ProseMirrorNode) var plainText: String { @@ -14,11 +14,11 @@ nonisolated enum NativeEditorInlineContent: Equatable, Hashable, Sendable, Codab text case .hardBreak: "\n" - case .mention(let mention): + case .mention(let mention, _): mention.displayText - case .status(let status): + case .status(let status, _): status.text - case .mathInline(let math): + case .mathInline(let math, _): math.text case .unsupported(let node): node.text ?? "" @@ -40,8 +40,14 @@ nonisolated enum NativeEditorInlineContent: Equatable, Hashable, Sendable, Codab } case .hardBreak: false - case .mention, .status, .mathInline: - false + case .mention(_, let marks), .status(_, let marks), .mathInline(_, let marks): + marks.contains { mark in + if case .unknown = mark { + return true + } + + return false + } case .unsupported: true } diff --git a/docmostlyTests/Editor/NativeEditorInlineCommentMarkTests.swift b/docmostlyTests/Editor/NativeEditorInlineCommentMarkTests.swift index 1c79ceb..bac41b2 100644 --- a/docmostlyTests/Editor/NativeEditorInlineCommentMarkTests.swift +++ b/docmostlyTests/Editor/NativeEditorInlineCommentMarkTests.swift @@ -121,4 +121,53 @@ struct NativeEditorInlineCommentMarkTests { ProseMirrorMark(type: "comment", attrs: ["commentId": .string("comment-2"), "resolved": .bool(true)]) ]) } + + @Test func preservesInlineCommentMarksOnMentionAtoms() throws { + let data = Data(""" + { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Ask " }, + { + "type": "mention", + "attrs": { + "id": "mention-1", + "label": "Taylor", + "entityType": "user", + "entityId": "user-1" + }, + "marks": [ + { + "type": "comment", + "attrs": { "commentId": "comment-1", "resolved": false } + } + ] + }, + { "type": "text", "text": " today" } + ] + } + ] + } + """.utf8) + + let document = try NativeEditorDocument(proseMirrorJSONData: data) + let block = try #require(document.blocks.first) + let mentionRun = try #require(block.text.runs.first { run in + run[NativeEditorMentionAttribute.self]?.identifier == "mention-1" + }) + + #expect(mentionRun.nativeEditorInlineComments == [ + NativeEditorInlineCommentMark(commentID: "comment-1", isResolved: false) + ]) + + let mentionNode = try #require( + document.proseMirrorDocument.content.first?.content?.first { $0.type == "mention" } + ) + #expect(mentionNode.marks == [ + ProseMirrorMark(type: "comment", attrs: ["commentId": .string("comment-1"), "resolved": .bool(false)]) + ]) + } } From db87ea8467de41cee6807c704e740e398d199942 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 12:49:47 +0100 Subject: [PATCH 167/201] fix: match docmost mermaid slash seed --- .../Editor/NativeEditorCommand+RichBlocks.swift | 2 +- .../Editor/NativeRichEditorViewModelTests.swift | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorCommand+RichBlocks.swift b/docmostly/Features/Editor/NativeEditorCommand+RichBlocks.swift index 04fda67..7abba88 100644 --- a/docmostly/Features/Editor/NativeEditorCommand+RichBlocks.swift +++ b/docmostly/Features/Editor/NativeEditorCommand+RichBlocks.swift @@ -102,7 +102,7 @@ extension NativeEditorCommand { return NativeEditorBlock( id: id, kind: .codeBlock(language: "mermaid"), - text: AttributedString("graph TD; A-->B"), + text: AttributedString("flowchart LR\n A --> B"), alignment: .left ) } diff --git a/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift b/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift index d9be1ec..a7b1ed7 100644 --- a/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift @@ -168,10 +168,13 @@ struct NativeRichEditorViewModelTests { viewModel.applySlashCommand(.mermaid) + let mermaidSeed = "flowchart LR\n A --> B" #expect(viewModel.document.blocks[0].kind == .codeBlock(language: "mermaid")) + #expect(String(viewModel.document.blocks[0].text.characters) == mermaidSeed) #expect(viewModel.document.blocks[0].isEditable == true) #expect(viewModel.document.proseMirrorDocument.content.first?.type == "codeBlock") #expect(viewModel.document.proseMirrorDocument.content.first?.attrs?["language"] == .string("mermaid")) + #expect(viewModel.document.proseMirrorDocument.content.first?.content?.first?.text == mermaidSeed) } @Test func applyingDateTimeAndEmojiSlashCommandsReplacesSlashToken() throws { @@ -445,10 +448,6 @@ struct NativeRichEditorViewModelTests { proseMirrorInlineNodes(from: viewModel).flatMap { $0.marks ?? [] } } - private func proseMirrorInlineNodes(from viewModel: NativeRichEditorViewModel) -> [ProseMirrorNode] { - viewModel.document.proseMirrorDocument.content.first?.content ?? [] - } - private func inlineSlashCommandText( command: NativeEditorCommand, slashText: String, @@ -494,6 +493,10 @@ private struct SlashCommandExpectation { let label: String } +private func proseMirrorInlineNodes(from viewModel: NativeRichEditorViewModel) -> [ProseMirrorNode] { + viewModel.document.proseMirrorDocument.content.first?.content ?? [] +} + private func expectDocmostDefaultTableShape(_ table: NativeEditorTable) { #expect(table.rows.count == 3) #expect(table.columnCount == 3) From dd95d4bb08a81123ba186c88cc7d0c1e9be8ef80 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 12:53:03 +0100 Subject: [PATCH 168/201] fix: keep slash command test helper main actor isolated --- docmostlyTests/Editor/NativeRichEditorViewModelTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift b/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift index a7b1ed7..2d3df49 100644 --- a/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift @@ -493,6 +493,7 @@ private struct SlashCommandExpectation { let label: String } +@MainActor private func proseMirrorInlineNodes(from viewModel: NativeRichEditorViewModel) -> [ProseMirrorNode] { viewModel.document.proseMirrorDocument.content.first?.content ?? [] } From 485de73da52f59dc54a916d2ada93dec76b68e64 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 13:06:59 +0100 Subject: [PATCH 169/201] fix: preserve attachment mime type in markdown export --- .../NativeEditorMarkdownParser+Embeds.swift | 1 + ...NativeEditorMediaMarkdownExportTests.swift | 44 +++++++++++++++++++ .../NativeEditorRichMarkdownExportTests.swift | 2 +- 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift index 4fc6dc3..de34203 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift @@ -423,6 +423,7 @@ extension NativeEditorMarkdownParser { private static func attachmentRequiresDocmostHTML(_ attachment: NativeEditorAttachmentBlock) -> Bool { attachment.attachmentID != nil || + attachment.mimeType != nil || attachment.sizeInBytes != nil } diff --git a/docmostlyTests/Editor/NativeEditorMediaMarkdownExportTests.swift b/docmostlyTests/Editor/NativeEditorMediaMarkdownExportTests.swift index d7623b7..533186d 100644 --- a/docmostlyTests/Editor/NativeEditorMediaMarkdownExportTests.swift +++ b/docmostlyTests/Editor/NativeEditorMediaMarkdownExportTests.swift @@ -68,4 +68,48 @@ struct NativeEditorMediaMarkdownExportTests { [Archive.zip](/api/files/file-1/Archive.zip#download) """#) } + + @Test func markdownExportPreservesAttachmentMimeTypeAsDocmostHTML() { + let block = NativeEditorBlock( + kind: .attachment(NativeEditorAttachmentBlock( + url: "/api/files/file-1/Archive.zip", + name: "Archive.zip", + mimeType: "application/zip", + sizeInBytes: nil, + attachmentID: nil + )), + text: AttributedString("Archive.zip"), + alignment: .left + ) + + #expect( + NativeEditorMarkdownParser.markdown(from: [block]) == + docmostAttachmentHTML( + url: "/api/files/file-1/Archive.zip", + name: "Archive.zip", + mimeType: "application/zip" + ) + ) + } +} + +private func docmostAttachmentHTML(url: String, name: String, mimeType: String) -> String { + let openingTag = htmlTag("div", attributes: [ + ("data-type", "attachment"), + ("data-attachment-url", url), + ("data-attachment-name", name), + ("data-attachment-mime", mimeType) + ]) + return """ + \(openingTag) + \(name) + + """ +} + +private func htmlTag(_ name: String, attributes: [(String, String?)]) -> String { + let attributeText = attributes.compactMap { key, value -> String? in + value.map { #"\#(key)="\#($0)""# } + }.joined(separator: " ") + return "<\(name) \(attributeText)>" } diff --git a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift index 346c69f..85d9801 100644 --- a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift +++ b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift @@ -368,7 +368,7 @@ struct NativeEditorRichMarkdownExportTests { kind: .attachment(NativeEditorAttachmentBlock( url: "/files/archive.zip", name: "Archive.zip", - mimeType: "application/zip", + mimeType: nil, sizeInBytes: nil, attachmentID: nil )), From 9a6dba71f0655c48dffbf21af6aa8bd366b6c648 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 13:20:04 +0100 Subject: [PATCH 170/201] fix: support underscore divider markdown --- .../Editor/NativeEditorMarkdownParser.swift | 2 +- .../NativeEditorDividerMarkdownTests.swift | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 docmostlyTests/Editor/NativeEditorDividerMarkdownTests.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift index 4cd5ae4..bd887d1 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift @@ -358,7 +358,7 @@ enum NativeEditorMarkdownParser { } private static func isDivider(_ text: String) -> Bool { - text == "---" || text == "***" + text == "---" || text == "***" || text == "___" } private static func listIndentLevel(from line: String) -> Int { diff --git a/docmostlyTests/Editor/NativeEditorDividerMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorDividerMarkdownTests.swift new file mode 100644 index 0000000..9c8e739 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorDividerMarkdownTests.swift @@ -0,0 +1,34 @@ +import Foundation +import SwiftUI +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorDividerMarkdownTests { + @Test func markdownImportSupportsUnderscoreDivider() throws { + let block = try #require(NativeEditorMarkdownParser.blocks(from: "___").first) + + #expect(block.kind == .divider) + #expect(String(block.text.characters) == "Divider") + } + + @Test func markdownInputRuleSupportsUnderscoreDividerShortcut() { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString(""), alignment: .left) + let viewModel = configuredViewModel(blocks: [block]) + viewModel.focus(blockID: block.id) + + viewModel.document.blocks[0].text = AttributedString("___") + viewModel.handleDocumentChanged() + + #expect(viewModel.document.blocks[0].kind == .divider) + #expect(String(viewModel.document.blocks[0].text.characters) == "Divider") + #expect(viewModel.markdownForDocument() == "---") + } + + private func configuredViewModel(blocks: [NativeEditorBlock]) -> NativeRichEditorViewModel { + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: blocks) + viewModel.resetEditingHistory() + return viewModel + } +} From 38ff1d745bb54b4320f5fe0e442893ff2dce3948 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 13:29:46 +0100 Subject: [PATCH 171/201] fix: support plus list markdown markers --- .../Editor/NativeEditorMarkdownParser.swift | 5 +-- .../NativeEditorListMarkdownTests.swift | 34 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 docmostlyTests/Editor/NativeEditorListMarkdownTests.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift index bd887d1..62f5276 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift @@ -272,6 +272,7 @@ enum NativeEditorMarkdownParser { ("# ", .heading(level: 1)), ("- ", .bulletListItem), ("* ", .bulletListItem), + ("+ ", .bulletListItem), ("> ", .blockquote) ] @@ -280,8 +281,8 @@ enum NativeEditorMarkdownParser { } private static func taskInputRule(from text: String) -> NativeEditorMarkdownInputRule? { - let uncheckedPrefixes = ["- [ ] ", "* [ ] ", "[] ", "[ ] "] - let checkedPrefixes = ["- [x] ", "- [X] ", "* [x] ", "* [X] ", "[x] ", "[X] "] + let uncheckedPrefixes = ["- [ ] ", "* [ ] ", "+ [ ] ", "[] ", "[ ] "] + let checkedPrefixes = ["- [x] ", "- [X] ", "* [x] ", "* [X] ", "+ [x] ", "+ [X] ", "[x] ", "[X] "] if let prefix = uncheckedPrefixes.first(where: { text.hasPrefix($0) }) { return NativeEditorMarkdownInputRule( diff --git a/docmostlyTests/Editor/NativeEditorListMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorListMarkdownTests.swift new file mode 100644 index 0000000..c7b27d3 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorListMarkdownTests.swift @@ -0,0 +1,34 @@ +import Foundation +import Testing +@testable import docmostly + +struct NativeEditorListMarkdownTests { + @Test func markdownImportSupportsPlusListMarkers() throws { + let blocks = NativeEditorMarkdownParser.blocks(from: """ + + Release notes + + [ ] QA pass + + [x] Ship build + """) + + try #require(blocks.count == 3) + #expect(blocks[0].kind == .bulletListItem) + #expect(String(blocks[0].text.characters) == "Release notes") + #expect(blocks[1].kind == .taskListItem(isChecked: false)) + #expect(String(blocks[1].text.characters) == "QA pass") + #expect(blocks[2].kind == .taskListItem(isChecked: true)) + #expect(String(blocks[2].text.characters) == "Ship build") + } + + @Test func markdownInputRuleSupportsPlusListMarkers() throws { + let bulletRule = try #require(NativeEditorMarkdownParser.inputRule(from: "+ Release notes")) + let uncheckedTaskRule = try #require(NativeEditorMarkdownParser.inputRule(from: "+ [ ] QA pass")) + let checkedTaskRule = try #require(NativeEditorMarkdownParser.inputRule(from: "+ [x] Ship build")) + + #expect(bulletRule.kind == .bulletListItem) + #expect(bulletRule.text == "Release notes") + #expect(uncheckedTaskRule.kind == .taskListItem(isChecked: false)) + #expect(uncheckedTaskRule.text == "QA pass") + #expect(checkedTaskRule.kind == .taskListItem(isChecked: true)) + #expect(checkedTaskRule.text == "Ship build") + } +} From a547b9971a34d937aaf2106f6b42e8ba148baf17 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 13:32:52 +0100 Subject: [PATCH 172/201] fix: keep plus list tests main actor isolated --- docmostlyTests/Editor/NativeEditorListMarkdownTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/docmostlyTests/Editor/NativeEditorListMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorListMarkdownTests.swift index c7b27d3..2617d58 100644 --- a/docmostlyTests/Editor/NativeEditorListMarkdownTests.swift +++ b/docmostlyTests/Editor/NativeEditorListMarkdownTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import docmostly +@MainActor struct NativeEditorListMarkdownTests { @Test func markdownImportSupportsPlusListMarkers() throws { let blocks = NativeEditorMarkdownParser.blocks(from: """ From aa290421e15f4258d993660d2fdff427a58d87a2 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 13:45:11 +0100 Subject: [PATCH 173/201] fix: preserve inline code backtick runs --- ...tiveEditorMarkdownParser+InlineMarks.swift | 18 ++++++++++++++++- .../NativeEditorInlineCodeMarkdownTests.swift | 20 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift index f949ff8..b8a605d 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift @@ -71,10 +71,26 @@ extension NativeEditorMarkdownParser { } private static func codeMarkdown(from text: String) -> String { - let delimiter = text.contains("`") ? "``" : "`" + let delimiter = String(repeating: "`", count: longestBacktickRunLength(in: text) + 1) return "\(delimiter)\(text)\(delimiter)" } + private static func longestBacktickRunLength(in text: String) -> Int { + var longestRunLength = 0 + var currentRunLength = 0 + + for character in text { + if character == "`" { + currentRunLength += 1 + longestRunLength = max(longestRunLength, currentRunLength) + } else { + currentRunLength = 0 + } + } + + return longestRunLength + } + private static func escapedMarkdownLinkLabel(_ text: String) -> String { text .replacing("\\", with: "\\\\") diff --git a/docmostlyTests/Editor/NativeEditorInlineCodeMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorInlineCodeMarkdownTests.swift index e9de342..9c39663 100644 --- a/docmostlyTests/Editor/NativeEditorInlineCodeMarkdownTests.swift +++ b/docmostlyTests/Editor/NativeEditorInlineCodeMarkdownTests.swift @@ -15,4 +15,24 @@ struct NativeEditorInlineCodeMarkdownTests { #expect(codeNode.marks?.contains(ProseMirrorMark(type: "code")) == true) #expect(NativeEditorMarkdownParser.markdown(from: [block]) == markdown) } + + @Test func markdownExportUsesLongerBacktickRunForCodeContainingDoubleBackticks() throws { + var text = AttributedString("Use ") + var code = AttributedString("value `` fence") + code.inlinePresentationIntent = .code + text += code + text += AttributedString(" today") + let block = NativeEditorBlock(kind: .paragraph, text: text, alignment: .left) + + let markdown = NativeEditorMarkdownParser.markdown(from: [block]) + + #expect(markdown == "Use ```value `` fence``` today") + + let importedBlock = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + #expect(String(importedBlock.text.characters) == "Use value `` fence today") + let codeNode = try #require(NativeEditorDocument.inlineNodes(from: importedBlock.text).first { + $0.text == "value `` fence" + }) + #expect(codeNode.marks?.contains(ProseMirrorMark(type: "code")) == true) + } } From b6e80d6cc5f39d614e37f172c17326bfcb74a50f Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 13:59:41 +0100 Subject: [PATCH 174/201] fix: preserve code fences in markdown code blocks --- .../Editor/NativeEditorMarkdownParser.swift | 79 ++++++++++++++++--- .../NativeEditorCodeBlockMarkdownTests.swift | 45 +++++++++++ 2 files changed, 113 insertions(+), 11 deletions(-) create mode 100644 docmostlyTests/Editor/NativeEditorCodeBlockMarkdownTests.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift index 62f5276..2a4cc34 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift @@ -6,6 +6,12 @@ struct NativeEditorMarkdownInputRule: Equatable { } enum NativeEditorMarkdownParser { + private struct CodeFence { + var marker: Character + var length: Int + var language: String + } + static func blocks(from markdown: String) -> [NativeEditorBlock] { let markdown = removingLeadingYAMLFrontMatter(from: markdown) let lines = markdown.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) @@ -175,17 +181,16 @@ enum NativeEditorMarkdownParser { startingAt index: Array.Index ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { let line = lines[index].trimmingCharacters(in: .whitespaces) - guard line.hasPrefix("```") else { return nil } + guard let openingFence = codeFenceOpening(from: line) else { return nil } - let language = String(line.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) var content: [String] = [] var currentIndex = lines.index(after: index) while currentIndex < lines.endIndex { let currentLine = lines[currentIndex] - if currentLine.trimmingCharacters(in: .whitespaces).hasPrefix("```") { + if isCodeFenceClosingLine(currentLine, matching: openingFence) { return ( - codeBlock(language: language, text: content.joined(separator: "\n")), + codeBlock(language: openingFence.language, text: content.joined(separator: "\n")), lines.index(after: currentIndex) ) } @@ -194,7 +199,38 @@ enum NativeEditorMarkdownParser { currentIndex = lines.index(after: currentIndex) } - return (codeBlock(language: language, text: content.joined(separator: "\n")), currentIndex) + return (codeBlock(language: openingFence.language, text: content.joined(separator: "\n")), currentIndex) + } + + private static func codeFenceOpening(from line: String) -> CodeFence? { + guard let marker = line.first, marker == "`" || marker == "~" else { return nil } + + let length = leadingRunLength(of: marker, in: line) + guard length >= 3 else { return nil } + + let languageStartIndex = line.index(line.startIndex, offsetBy: length) + let language = String(line[languageStartIndex...]).trimmingCharacters(in: .whitespacesAndNewlines) + return CodeFence(marker: marker, length: length, language: language) + } + + private static func isCodeFenceClosingLine(_ line: String, matching openingFence: CodeFence) -> Bool { + let line = line.trimmingCharacters(in: .whitespaces) + guard line.first == openingFence.marker else { return false } + + let closingLength = leadingRunLength(of: openingFence.marker, in: line) + guard closingLength >= openingFence.length else { return false } + + let remainingStartIndex = line.index(line.startIndex, offsetBy: closingLength) + return line[remainingStartIndex...].allSatisfy(\.isWhitespace) + } + + private static func leadingRunLength(of marker: Character, in text: String) -> Int { + var length = 0 + for character in text { + guard character == marker else { break } + length += 1 + } + return length } private static func codeBlock(language: String, text: String) -> NativeEditorBlock { @@ -206,10 +242,12 @@ enum NativeEditorMarkdownParser { } private static func codeInputRule(from text: String) -> NativeEditorMarkdownInputRule? { - guard text.hasPrefix("```") else { return nil } + guard let openingFence = codeFenceOpening(from: text) else { return nil } - let language = String(text.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) - return NativeEditorMarkdownInputRule(kind: .codeBlock(language: language.isEmpty ? nil : language), text: "") + return NativeEditorMarkdownInputRule( + kind: .codeBlock(language: openingFence.language.isEmpty ? nil : openingFence.language), + text: "" + ) } private static func mathBlockInputRule(from text: String) -> NativeEditorMarkdownInputRule? { @@ -351,13 +389,32 @@ enum NativeEditorMarkdownParser { } private static func codeMarkdown(language: String?, text: String) -> String { - """ - ```\(language ?? "") + let fenceLength = max(3, longestBacktickRunLength(in: text) + 1) + let fence = String(repeating: "`", count: fenceLength) + + return """ + \(fence)\(language ?? "") \(text) - ``` + \(fence) """ } + private static func longestBacktickRunLength(in text: String) -> Int { + var longestRun = 0 + var currentRun = 0 + + for character in text { + if character == "`" { + currentRun += 1 + longestRun = max(longestRun, currentRun) + } else { + currentRun = 0 + } + } + + return longestRun + } + private static func isDivider(_ text: String) -> Bool { text == "---" || text == "***" || text == "___" } diff --git a/docmostlyTests/Editor/NativeEditorCodeBlockMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorCodeBlockMarkdownTests.swift new file mode 100644 index 0000000..373a2a1 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorCodeBlockMarkdownTests.swift @@ -0,0 +1,45 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorCodeBlockMarkdownTests { + @Test func codeBlockInputRuleUsesWholeOpeningFenceForLanguage() throws { + let rule = try #require(NativeEditorMarkdownParser.inputRule(from: "````swift")) + + #expect(rule.kind == .codeBlock(language: "swift")) + #expect(rule.text.isEmpty) + } + + @Test func codeBlockMarkdownExportUsesLongerFenceWhenBodyContainsBacktickFence() throws { + let code = """ + ```swift + let value = true + ``` + """ + let block = NativeEditorBlock( + kind: .codeBlock(language: "markdown"), + text: AttributedString(code), + alignment: .left + ) + + let markdown = NativeEditorMarkdownParser.markdown(from: [block]) + + #expect(markdown == """ + ````markdown + ```swift + let value = true + ``` + ```` + """) + + let importedBlock = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + guard case .codeBlock(let language) = importedBlock.kind else { + Issue.record("Expected Markdown to reimport as a code block.") + return + } + + #expect(language == "markdown") + #expect(String(importedBlock.text.characters) == code) + } +} From 485d560df1de5c912ff84dd1258a286ed993d48b Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 14:14:16 +0100 Subject: [PATCH 175/201] fix: preserve table cell backslashes in markdown --- .../NativeEditorMarkdownParser+Tables.swift | 2 ++ ...NativeEditorTableMarkdownExportTests.swift | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift index 59bb18e..dec1083 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift @@ -159,6 +159,8 @@ extension NativeEditorMarkdownParser { if isEscaped { if character == "|" { cells[cells.count - 1].append(character) + } else if character == "\\" { + cells[cells.count - 1].append(character) } else { cells[cells.count - 1].append("\\") cells[cells.count - 1].append(character) diff --git a/docmostlyTests/Editor/NativeEditorTableMarkdownExportTests.swift b/docmostlyTests/Editor/NativeEditorTableMarkdownExportTests.swift index 0b42769..7c21112 100644 --- a/docmostlyTests/Editor/NativeEditorTableMarkdownExportTests.swift +++ b/docmostlyTests/Editor/NativeEditorTableMarkdownExportTests.swift @@ -4,6 +4,28 @@ import Testing @MainActor struct NativeEditorTableMarkdownExportTests { + @Test func markdownTableRoundTripPreservesLiteralBackslashesInCells() throws { + let table = NativeEditorTable(rows: [ + NativeEditorTableRow(cells: [ + NativeEditorTableCell(plainText: "Path", isHeader: true, backgroundColorName: nil) + ]), + NativeEditorTableRow(cells: [ + NativeEditorTableCell(plainText: #"C:\Temp\spec.txt"#, isHeader: false, backgroundColorName: nil) + ]) + ]) + let block = NativeEditorBlock(kind: .table(table), text: AttributedString("Table"), alignment: .left) + + let markdown = NativeEditorMarkdownParser.markdown(from: [block]) + let importedBlock = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + guard case .table(let importedTable) = importedBlock.kind else { + Issue.record("Expected table Markdown to reimport as a table.") + return + } + + #expect(importedTable.rows[1].cells[0].plainText == #"C:\Temp\spec.txt"#) + #expect(NativeEditorMarkdownParser.markdown(from: [importedBlock]) == markdown) + } + @Test func markdownExportPreservesInlineMarksInsideAlignedTableCells() throws { let document = try NativeEditorDocument(proseMirrorJSONData: Data(""" { From 41e0e316554707a29e3221ce95c39a2db1dc5c7a Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 14:25:37 +0100 Subject: [PATCH 176/201] fix: expose generic embed slash command --- .../Features/Editor/NativeEditorCommand+SlashMenu.swift | 1 + docmostlyTests/Editor/NativeEditorSlashCommandTests.swift | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/docmostly/Features/Editor/NativeEditorCommand+SlashMenu.swift b/docmostly/Features/Editor/NativeEditorCommand+SlashMenu.swift index 679be25..b2a585c 100644 --- a/docmostly/Features/Editor/NativeEditorCommand+SlashMenu.swift +++ b/docmostly/Features/Editor/NativeEditorCommand+SlashMenu.swift @@ -38,6 +38,7 @@ extension NativeEditorCommand { .columns3, .columns4, .columns5, + .embed, .iframeEmbed, .airtableEmbed, .loomEmbed, diff --git a/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift b/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift index 2851f63..ec27bc9 100644 --- a/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift +++ b/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift @@ -5,6 +5,11 @@ import Testing @MainActor struct NativeEditorSlashCommandTests { + @Test func slashCommandMenuExposesGenericEmbedCommand() { + #expect(NativeEditorCommand.slashMenuCases.contains(.embed)) + #expect(slashCommandTitles(for: "embed").contains("Embed")) + } + @Test func slashCommandInventoryIncludesBaseColumnsAndProviderEmbeds() { let titles = NativeEditorCommand.allCases.map(\.title) @@ -65,6 +70,7 @@ struct NativeEditorSlashCommandTests { "3 Columns", "4 Columns", "5 Columns", + "Embed", "Iframe embed", "Airtable", "Loom", @@ -121,6 +127,7 @@ struct NativeEditorSlashCommandTests { "3 Columns", "4 Columns", "5 Columns", + "Embed", "Iframe embed", "Airtable", "Loom", From b3d74a8552e59909a1c2a611294afb30eb7d06e0 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 14:46:57 +0100 Subject: [PATCH 177/201] fix: preserve code block attrs --- .../NativeEditorDocument+Decoding.swift | 8 +++++- .../NativeEditorDocument+Encoding.swift | 16 +++++++++-- .../NativeEditorCodeBlockAttributeTests.swift | 28 +++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 docmostlyTests/Editor/NativeEditorCodeBlockAttributeTests.swift diff --git a/docmostly/Features/Editor/NativeEditorDocument+Decoding.swift b/docmostly/Features/Editor/NativeEditorDocument+Decoding.swift index e07d03f..8084890 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+Decoding.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+Decoding.swift @@ -200,13 +200,19 @@ nonisolated extension NativeEditorDocument { NativeEditorBlock( kind: .codeBlock(language: node.attrs?["language"]?.stringValue), text: AttributedString(plainText(in: node.content ?? [])), - alignment: .left + alignment: .left, + rawNode: codeBlockAttrsNeedRawPreservation(node) ? node : nil ) default: nil } } + private static func codeBlockAttrsNeedRawPreservation(_ node: ProseMirrorNode) -> Bool { + guard let attrs = node.attrs, attrs.isEmpty == false else { return false } + return attrs.keys.contains { $0 != "language" } + } + private static func richBlock(from node: ProseMirrorNode) -> NativeEditorBlock? { if let mediaBlock = mediaRichBlock(from: node) { return mediaBlock diff --git a/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift b/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift index 6dab538..54102d6 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift @@ -195,13 +195,25 @@ nonisolated extension NativeEditorDocument { } private static func codeBlockNode(language: String?, block: NativeEditorBlock) -> ProseMirrorNode { - ProseMirrorNode( + var attrs = preservedCodeBlockAttrs(from: block) + if let language { + attrs["language"] = .string(language) + } else { + attrs.removeValue(forKey: "language") + } + + return ProseMirrorNode( type: "codeBlock", - attrs: language.map { ["language": .string($0)] }, + attrs: attrs.isEmpty ? nil : attrs, content: plainTextNodes(from: String(block.text.characters)) ) } + private static func preservedCodeBlockAttrs(from block: NativeEditorBlock) -> [String: ProseMirrorJSONValue] { + guard block.rawNode?.type == "codeBlock" else { return [:] } + return block.rawNode?.attrs ?? [:] + } + private static func richFallbackNode(from block: NativeEditorBlock) -> ProseMirrorNode { if let mediaNode = mediaFallbackNode(from: block) { return mediaNode diff --git a/docmostlyTests/Editor/NativeEditorCodeBlockAttributeTests.swift b/docmostlyTests/Editor/NativeEditorCodeBlockAttributeTests.swift new file mode 100644 index 0000000..4437bbd --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorCodeBlockAttributeTests.swift @@ -0,0 +1,28 @@ +import Foundation +import SwiftUI +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorCodeBlockAttributeTests { + @Test func preservesCodeBlockAttributesWhenReencodingNativeEdits() throws { + let original = ProseMirrorDocument(content: [ + ProseMirrorNode( + type: "codeBlock", + attrs: [ + "id": .string("code-block-1"), + "language": .string("swift") + ], + content: [ProseMirrorNode(type: "text", text: "let value = true")] + ) + ]) + var document = NativeEditorDocument(proseMirrorDocument: original) + + document.blocks[0].text = AttributedString("let value = false") + + let encodedBlock = try #require(document.proseMirrorDocument.content.first) + #expect(encodedBlock.attrs?["id"] == .string("code-block-1")) + #expect(encodedBlock.attrs?["language"] == .string("swift")) + #expect(encodedBlock.content?.first?.text == "let value = false") + } +} From 3566a97f1949033a001db3e124a3493b3432b8fb Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 15:03:05 +0100 Subject: [PATCH 178/201] fix: preserve blockquote container content --- .../NativeEditorDocument+Blockquotes.swift | 62 +++++++++++++++++++ .../NativeEditorDocument+Decoding.swift | 18 +++++- .../NativeEditorDocument+Encoding.swift | 2 +- .../NativeEditorBlockquoteFidelityTests.swift | 40 ++++++++++++ 4 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 docmostly/Features/Editor/NativeEditorDocument+Blockquotes.swift create mode 100644 docmostlyTests/Editor/NativeEditorBlockquoteFidelityTests.swift diff --git a/docmostly/Features/Editor/NativeEditorDocument+Blockquotes.swift b/docmostly/Features/Editor/NativeEditorDocument+Blockquotes.swift new file mode 100644 index 0000000..4d6ee70 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorDocument+Blockquotes.swift @@ -0,0 +1,62 @@ +import Foundation + +nonisolated extension NativeEditorDocument { + static func blockquoteNode(from block: NativeEditorBlock) -> ProseMirrorNode { + let textNode = blockquoteTextContainerNode(from: block) + guard block.rawNode?.type == "blockquote" else { + return ProseMirrorNode(type: "blockquote", content: [textNode]) + } + + var content = block.rawNode?.content ?? [] + if let textIndex = content.firstIndex(where: isBlockquoteTextContainer) { + content[textIndex] = textNode + } else { + content.insert(textNode, at: content.startIndex) + } + + let attrs = block.rawNode?.attrs ?? [:] + return ProseMirrorNode( + type: "blockquote", + attrs: attrs.isEmpty ? nil : attrs, + content: content + ) + } + + private static func blockquoteTextContainerNode(from block: NativeEditorBlock) -> ProseMirrorNode { + guard block.rawNode?.type == "blockquote" else { + return textContainerNode(type: "paragraph", block: block) + } + + let originalNode = block.rawNode?.content?.first(where: isBlockquoteTextContainer) + let nodeType = originalNode?.type ?? "paragraph" + var attrs = originalNode?.attrs ?? [:] + + if let alignment = block.alignment.proseMirrorValue { + attrs["textAlign"] = alignment + } else { + attrs.removeValue(forKey: "textAlign") + } + + return ProseMirrorNode( + type: nodeType, + attrs: attrs.isEmpty ? nil : attrs, + content: blockquoteTextContainerContent(type: nodeType, block: block) + ) + } + + private static func blockquoteTextContainerContent( + type: String, + block: NativeEditorBlock + ) -> [ProseMirrorNode] { + if type == "codeBlock" { + let text = String(block.text.characters) + return text.isEmpty ? [] : [ProseMirrorNode(type: "text", text: text)] + } + + return block.inlineContent.map(inlineNodes(from:)) ?? inlineNodes(from: block.text) + } + + private static func isBlockquoteTextContainer(_ node: ProseMirrorNode) -> Bool { + node.type == "paragraph" || node.type == "heading" || node.type == "codeBlock" + } +} diff --git a/docmostly/Features/Editor/NativeEditorDocument+Decoding.swift b/docmostly/Features/Editor/NativeEditorDocument+Decoding.swift index 8084890..03632ba 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+Decoding.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+Decoding.swift @@ -195,7 +195,7 @@ nonisolated extension NativeEditorDocument { case "heading": textBlock(kind: .heading(level: node.attrs?["level"]?.intValue ?? 1), node: node) case "blockquote": - textBlock(kind: .blockquote, node: firstTextContainer(in: node) ?? node) + blockquoteBlock(from: node) case "codeBlock": NativeEditorBlock( kind: .codeBlock(language: node.attrs?["language"]?.stringValue), @@ -208,6 +208,22 @@ nonisolated extension NativeEditorDocument { } } + private static func blockquoteBlock(from node: ProseMirrorNode) -> NativeEditorBlock { + var block = textBlock(kind: .blockquote, node: firstTextContainer(in: node) ?? node) + if blockquoteNeedsRawPreservation(node) { + block.rawNode = node + } + return block + } + + private static func blockquoteNeedsRawPreservation(_ node: ProseMirrorNode) -> Bool { + if let attrs = node.attrs, attrs.isEmpty == false { + return true + } + + return (node.content ?? []).count != 1 + } + private static func codeBlockAttrsNeedRawPreservation(_ node: ProseMirrorNode) -> Bool { guard let attrs = node.attrs, attrs.isEmpty == false else { return false } return attrs.keys.contains { $0 != "language" } diff --git a/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift b/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift index 54102d6..0f44465 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift @@ -184,7 +184,7 @@ nonisolated extension NativeEditorDocument { case .heading(let level): textContainerNode(type: "heading", block: block, attrs: ["level": .int(level)]) case .blockquote: - ProseMirrorNode(type: "blockquote", content: [textContainerNode(type: "paragraph", block: block)]) + blockquoteNode(from: block) case .codeBlock(let language): codeBlockNode(language: language, block: block) case .bulletListItem, .orderedListItem, .taskListItem: diff --git a/docmostlyTests/Editor/NativeEditorBlockquoteFidelityTests.swift b/docmostlyTests/Editor/NativeEditorBlockquoteFidelityTests.swift new file mode 100644 index 0000000..a31549d --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorBlockquoteFidelityTests.swift @@ -0,0 +1,40 @@ +import Foundation +import SwiftUI +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorBlockquoteFidelityTests { + @Test func preservesAdditionalBlockquoteContentWhenReencodingNativeEdits() throws { + let trailingParagraph = ProseMirrorNode( + type: "paragraph", + attrs: ["id": .string("quote-paragraph-2")], + content: [ProseMirrorNode(type: "text", text: "Follow-up quote")] + ) + let original = ProseMirrorDocument(content: [ + ProseMirrorNode( + type: "blockquote", + attrs: ["dataKind": .string("annotation")], + content: [ + ProseMirrorNode( + type: "paragraph", + attrs: ["id": .string("quote-paragraph-1")], + content: [ProseMirrorNode(type: "text", text: "Original quote")] + ), + trailingParagraph + ] + ) + ]) + var document = NativeEditorDocument(proseMirrorDocument: original) + + document.blocks[0].text = AttributedString("Edited quote") + + let encodedBlockquote = try #require(document.proseMirrorDocument.content.first) + let encodedContent = try #require(encodedBlockquote.content) + #expect(encodedBlockquote.attrs?["dataKind"] == .string("annotation")) + #expect(encodedContent.count == 2) + #expect(encodedContent[0].attrs?["id"] == .string("quote-paragraph-1")) + #expect(encodedContent[0].content?.first?.text == "Edited quote") + #expect(encodedContent[1] == trailingParagraph) + } +} From d782084053dbf1b4fd90a6fc912f6318b1574be9 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 15:28:41 +0100 Subject: [PATCH 179/201] fix: preserve multiline blockquote markdown --- ...tiveEditorMarkdownParser+Blockquotes.swift | 44 +++++++++++++++++++ .../Editor/NativeEditorMarkdownParser.swift | 10 ++++- .../NativeEditorBlockquoteMarkdownTests.swift | 25 +++++++++++ 3 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+Blockquotes.swift create mode 100644 docmostlyTests/Editor/NativeEditorBlockquoteMarkdownTests.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Blockquotes.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Blockquotes.swift new file mode 100644 index 0000000..0b95c55 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Blockquotes.swift @@ -0,0 +1,44 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func blockquoteBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + var quoteLines: [String] = [] + var currentIndex = index + + while currentIndex < lines.endIndex, + let text = blockquoteLineText(from: lines[currentIndex]) { + quoteLines.append(text) + currentIndex = lines.index(after: currentIndex) + } + + guard quoteLines.count > 1 else { return nil } + + return ( + NativeEditorBlock( + kind: .blockquote, + text: multilineParagraphText(from: quoteLines), + alignment: .left + ), + currentIndex + ) + } + + static func blockquoteMarkdown(from text: String) -> String { + text.split(separator: "\n", omittingEmptySubsequences: false) + .map { "> \($0)" } + .joined(separator: "\n") + } + + private static func blockquoteLineText(from line: String) -> String? { + let trimmedLine = line.trimmingCharacters(in: .whitespaces) + if trimmedLine == ">" { + return "" + } + + guard trimmedLine.hasPrefix("> ") else { return nil } + return String(trimmedLine.dropFirst(2)) + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift index 2a4cc34..cf2fd56 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift @@ -37,6 +37,12 @@ enum NativeEditorMarkdownParser { continue } + if let blockquote = blockquoteBlock(in: lines, startingAt: index) { + blocks.append(blockquote.block) + index = blockquote.endIndex + continue + } + if let paragraph = paragraphBlock(in: lines, startingAt: index) { blocks.append(paragraph.block) index = paragraph.endIndex @@ -96,7 +102,7 @@ enum NativeEditorMarkdownParser { ) } - private static func multilineParagraphText(from lines: [String]) -> AttributedString { + static func multilineParagraphText(from lines: [String]) -> AttributedString { lines.enumerated().reduce(into: AttributedString("")) { result, item in if item.offset > 0 { result += AttributedString("\n") @@ -376,7 +382,7 @@ enum NativeEditorMarkdownParser { case .taskListItem(let isChecked): return "\(indent)- [\(isChecked ? "x" : " ")] \(text)" case .blockquote: - return "> \(text)" + return blockquoteMarkdown(from: text) case .codeBlock(let language): return codeMarkdown(language: language, text: plainText) case .divider: diff --git a/docmostlyTests/Editor/NativeEditorBlockquoteMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorBlockquoteMarkdownTests.swift new file mode 100644 index 0000000..afa8472 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorBlockquoteMarkdownTests.swift @@ -0,0 +1,25 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorBlockquoteMarkdownTests { + @Test func blockquoteMarkdownExportPrefixesEveryLine() throws { + let block = NativeEditorBlock( + kind: .blockquote, + text: AttributedString("Quote line one\nQuote line two"), + alignment: .left + ) + + let markdown = NativeEditorMarkdownParser.markdown(from: [block]) + + #expect(markdown == """ + > Quote line one + > Quote line two + """) + + let importedBlock = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + #expect(importedBlock.kind == .blockquote) + #expect(String(importedBlock.text.characters) == "Quote line one\nQuote line two") + } +} From 2da0b001eae733a298d7cead52408d77fba6f8ca Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 15:54:08 +0100 Subject: [PATCH 180/201] fix: preserve multiline list markdown --- .../NativeEditorMarkdownParser+Lists.swift | 147 ++++++++++++++++++ .../Editor/NativeEditorMarkdownParser.swift | 16 +- .../NativeEditorListMarkdownTests.swift | 57 +++++++ 3 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+Lists.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Lists.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Lists.swift new file mode 100644 index 0000000..75c9134 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Lists.swift @@ -0,0 +1,147 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func listItemBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + let line = lines[index] + let trimmedLine = line.trimmingCharacters(in: .whitespaces) + guard + let rule = inputRule(from: trimmedLine), + isListItem(rule.kind), + let contentColumn = listContentColumn(from: line, trimmedLine: trimmedLine) + else { + return nil + } + + var text = inlineText(from: rule.text) + var currentIndex = lines.index(after: index) + + while currentIndex < lines.endIndex, + let continuationText = listContinuationText(from: lines[currentIndex], contentColumn: contentColumn) { + text += AttributedString("\n") + text += inlineText(from: continuationText) + currentIndex = lines.index(after: currentIndex) + } + + return ( + NativeEditorBlock( + kind: rule.kind, + text: text, + alignment: .left, + indentLevel: listIndentLevel(fromLeadingColumns: leadingColumns(in: line)) + ), + currentIndex + ) + } + + static func listItemMarkdown(prefix: String, continuationPrefix: String, text: String) -> String { + let lines = text.isEmpty ? [""] : text + .split(separator: "\n", omittingEmptySubsequences: false) + .map(String.init) + + return lines.enumerated().map { item in + "\(item.offset == 0 ? prefix : continuationPrefix)\(item.element)" + } + .joined(separator: "\n") + } + + private static func listContinuationText(from line: String, contentColumn: Int) -> String? { + guard leadingColumns(in: line) >= contentColumn else { return nil } + + let trimmedLine = line.trimmingCharacters(in: .whitespaces) + if let rule = inputRule(from: trimmedLine), isListItem(rule.kind) { + return nil + } + + return textAfterDroppingColumns(contentColumn, from: line) + } + + private static func listContentColumn(from line: String, trimmedLine: String) -> Int? { + guard let markerWidth = listMarkerWidth(from: trimmedLine) else { return nil } + return leadingColumns(in: line) + markerWidth + } + + private static func listMarkerWidth(from trimmedLine: String) -> Int? { + let taskPrefixes = [ + "- [ ] ", "* [ ] ", "+ [ ] ", + "- [x] ", "- [X] ", "* [x] ", "* [X] ", "+ [x] ", "+ [X] " + ] + if let prefix = taskPrefixes.first(where: { trimmedLine.hasPrefix($0) }) { + return prefix.count + } + + if let bulletPrefix = ["- ", "* ", "+ "].first(where: { trimmedLine.hasPrefix($0) }) { + return bulletPrefix.count + } + + guard + let dotIndex = trimmedLine.firstIndex(of: "."), + trimmedLine.distance(from: trimmedLine.startIndex, to: dotIndex) <= 4 + else { + return nil + } + + let bodyStart = trimmedLine.index(after: dotIndex) + guard + Int(trimmedLine[.. String { + var columns = 0 + var index = line.startIndex + + while index < line.endIndex, columns < columnCount { + switch line[index] { + case " ": + columns += 1 + index = line.index(after: index) + case "\t": + columns += 2 + index = line.index(after: index) + default: + return String(line[index...]) + } + } + + return String(line[index...]) + } + + private static func leadingColumns(in line: String) -> Int { + var columns = 0 + + for character in line { + switch character { + case " ": + columns += 1 + case "\t": + columns += 2 + default: + return columns + } + } + + return columns + } + + private static func listIndentLevel(fromLeadingColumns columns: Int) -> Int { + min(columns / 2, 8) + } + + private static func isListItem(_ kind: NativeEditorBlockKind) -> Bool { + switch kind { + case .bulletListItem, .orderedListItem, .taskListItem: + true + default: + false + } + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift index cf2fd56..338944e 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift @@ -43,6 +43,12 @@ enum NativeEditorMarkdownParser { continue } + if let listItem = listItemBlock(in: lines, startingAt: index) { + blocks.append(listItem.block) + index = listItem.endIndex + continue + } + if let paragraph = paragraphBlock(in: lines, startingAt: index) { blocks.append(paragraph.block) index = paragraph.endIndex @@ -376,11 +382,15 @@ enum NativeEditorMarkdownParser { case .heading(let level): return "\(String(repeating: "#", count: max(level, 1))) \(text)" case .bulletListItem: - return "\(indent)- \(text)" + return listItemMarkdown(prefix: "\(indent)- ", continuationPrefix: "\(indent) ", text: text) case .orderedListItem(let ordinal): - return "\(indent)\(ordinal). \(text)" + let prefix = "\(indent)\(ordinal). " + let continuationPrefix = indent + String(repeating: " ", count: "\(ordinal). ".count) + return listItemMarkdown(prefix: prefix, continuationPrefix: continuationPrefix, text: text) case .taskListItem(let isChecked): - return "\(indent)- [\(isChecked ? "x" : " ")] \(text)" + let prefix = "\(indent)- [\(isChecked ? "x" : " ")] " + let continuationPrefix = indent + String(repeating: " ", count: "- [ ] ".count) + return listItemMarkdown(prefix: prefix, continuationPrefix: continuationPrefix, text: text) case .blockquote: return blockquoteMarkdown(from: text) case .codeBlock(let language): diff --git a/docmostlyTests/Editor/NativeEditorListMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorListMarkdownTests.swift index 2617d58..7675963 100644 --- a/docmostlyTests/Editor/NativeEditorListMarkdownTests.swift +++ b/docmostlyTests/Editor/NativeEditorListMarkdownTests.swift @@ -4,6 +4,63 @@ import Testing @MainActor struct NativeEditorListMarkdownTests { + @Test func bulletListMarkdownExportPrefixesContinuationLines() throws { + let block = NativeEditorBlock( + kind: .bulletListItem, + text: AttributedString("First line\nSecond line"), + alignment: .left + ) + + let markdown = NativeEditorMarkdownParser.markdown(from: [block]) + + #expect(markdown == """ + - First line + Second line + """) + + let importedBlock = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + #expect(importedBlock.kind == .bulletListItem) + #expect(String(importedBlock.text.characters) == "First line\nSecond line") + } + + @Test func orderedListMarkdownExportPrefixesContinuationLines() throws { + let block = NativeEditorBlock( + kind: .orderedListItem(ordinal: 12), + text: AttributedString("First line\nSecond line"), + alignment: .left + ) + + let markdown = NativeEditorMarkdownParser.markdown(from: [block]) + + #expect(markdown == """ + 12. First line + Second line + """) + + let importedBlock = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + #expect(importedBlock.kind == .orderedListItem(ordinal: 12)) + #expect(String(importedBlock.text.characters) == "First line\nSecond line") + } + + @Test func taskListMarkdownExportPrefixesContinuationLines() throws { + let block = NativeEditorBlock( + kind: .taskListItem(isChecked: false), + text: AttributedString("First line\nSecond line"), + alignment: .left + ) + + let markdown = NativeEditorMarkdownParser.markdown(from: [block]) + + #expect(markdown == """ + - [ ] First line + Second line + """) + + let importedBlock = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + #expect(importedBlock.kind == .taskListItem(isChecked: false)) + #expect(String(importedBlock.text.characters) == "First line\nSecond line") + } + @Test func markdownImportSupportsPlusListMarkers() throws { let blocks = NativeEditorMarkdownParser.blocks(from: """ + Release notes From 497cefe9c23138b03cdfb97e91ad11a814083767 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 16:13:52 +0100 Subject: [PATCH 181/201] fix: export iconless callouts as markdown --- ...veEditorMarkdownParser+ContainerHTML.swift | 2 +- ...tiveEditorContainerHTMLFidelityTests.swift | 32 +++++++++++++++++-- .../NativeEditorRichMarkdownExportTests.swift | 4 +-- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+ContainerHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+ContainerHTML.swift index c22b55f..689c851 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+ContainerHTML.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+ContainerHTML.swift @@ -19,7 +19,7 @@ extension NativeEditorMarkdownParser { static func docmostContainerHTMLMarkdown(from block: NativeEditorBlock) -> String? { switch block.kind { case .callout(let callout): - calloutHTMLMarkdown(from: callout) + callout.icon == nil ? nil : calloutHTMLMarkdown(from: callout) case .details(let details): detailsHTMLMarkdown(from: details) default: diff --git a/docmostlyTests/Editor/NativeEditorContainerHTMLFidelityTests.swift b/docmostlyTests/Editor/NativeEditorContainerHTMLFidelityTests.swift index 22c8b85..7157898 100644 --- a/docmostlyTests/Editor/NativeEditorContainerHTMLFidelityTests.swift +++ b/docmostlyTests/Editor/NativeEditorContainerHTMLFidelityTests.swift @@ -96,7 +96,7 @@ struct NativeEditorContainerHTMLFidelityTests { """) } - @Test func exportsIconlessNativeCalloutAsDocmostHTML() { + @Test func exportsSingleLineIconlessNativeCalloutAsDocmostFenceMarkdown() { let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") viewModel.document = NativeEditorDocument(blocks: [ NativeEditorBlock( @@ -112,9 +112,35 @@ struct NativeEditorContainerHTMLFidelityTests { viewModel.resetEditingHistory() #expect(viewModel.markdownForDocument() == """ -
+ :::info Check migration plan -
+ ::: + """) + } + + @Test func exportsIconlessNativeCalloutAsDocmostFenceMarkdown() { + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: [ + NativeEditorBlock( + kind: .callout(NativeEditorCalloutBlock( + style: "warning", + icon: nil, + previewText: """ + Check migration plan + Confirm rollback owner + """ + )), + text: AttributedString("Check migration plan"), + alignment: .left + ) + ]) + viewModel.resetEditingHistory() + + #expect(viewModel.markdownForDocument() == """ + :::warning + Check migration plan + Confirm rollback owner + ::: """) } } diff --git a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift index 85d9801..df9698d 100644 --- a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift +++ b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift @@ -209,9 +209,9 @@ struct NativeEditorRichMarkdownExportTests { [Release audio.m4a](/files/audio.m4a) [Spec.pdf](/files/spec.pdf) [Archive.zip](/files/archive.zip) -
+ :::warning Check migration plan -
+ :::
Release checklist
From da1bda5be282fe96fc394e4d203571f8239ab709 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 16:36:36 +0100 Subject: [PATCH 182/201] fix: import docmost html tables --- ...NativeEditorMarkdownParser+TableHTML.swift | 227 ++++++++++++++++++ .../NativeEditorMarkdownParser+Tables.swift | 4 + .../NativeEditorTableHTMLImportTests.swift | 56 +++++ 3 files changed, 287 insertions(+) create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift create mode 100644 docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift new file mode 100644 index 0000000..54a4b3d --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift @@ -0,0 +1,227 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func htmlTableBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + guard isHTMLTableStartLine(lines[index]), + let htmlTable = htmlTableHTML(in: lines, startingAt: index) else { + return nil + } + + let rows = htmlTableRows(from: htmlTable.html) + guard rows.isEmpty == false else { return nil } + + let table = NativeEditorTable(rows: rows) + return ( + NativeEditorBlock( + kind: .table(table), + text: AttributedString(NativeEditorDocument.previewText(for: .table(table))), + alignment: .left, + rawNode: NativeEditorTableNodeFactory.node(from: table) + ), + htmlTable.endIndex + ) + } + + private static func isHTMLTableStartLine(_ line: String) -> Bool { + if htmlTagAttributes(from: line, tagName: "table") != nil { + return true + } + + guard let attrs = htmlTagAttributes(from: line, tagName: "div"), + let className = attrs["class"] else { + return false + } + + return className.split(whereSeparator: \.isWhitespace).contains("tableWrapper") + } + + private static func htmlTableHTML( + in lines: [String], + startingAt index: Array.Index + ) -> (html: String, endIndex: Array.Index)? { + let startsWithWrapper = htmlTagAttributes(from: lines[index], tagName: "div")?["class"]? + .split(whereSeparator: \.isWhitespace) + .contains("tableWrapper") ?? false + var tableLines: [String] = [] + var sawTable = false + var currentIndex = index + + while currentIndex < lines.endIndex { + let line = lines[currentIndex] + tableLines.append(line) + + if firstHTMLTagAttributes(in: line, tagName: "table") != nil { + sawTable = true + } + + if sawTable, containsHTMLClosingTag(in: line, tagName: "table") { + var endIndex = lines.index(after: currentIndex) + if startsWithWrapper, + endIndex < lines.endIndex, + containsHTMLClosingTag(in: lines[endIndex], tagName: "div") { + tableLines.append(lines[endIndex]) + endIndex = lines.index(after: endIndex) + } + return (tableLines.joined(separator: "\n"), endIndex) + } + + currentIndex = lines.index(after: currentIndex) + } + + return nil + } + + private static func htmlTableRows(from html: String) -> [NativeEditorTableRow] { + htmlTableRowBodies(from: html).compactMap { rowHTML in + let cells = htmlTableCells(from: rowHTML) + return cells.isEmpty ? nil : NativeEditorTableRow(cells: cells) + } + } + + private static func htmlTableRowBodies(from html: String) -> [String] { + htmlRegexMatches(pattern: #"]*>(.*?)"#, in: html).compactMap { + htmlRegexString(match: $0, captureIndex: 1, in: html) + } + } + + private static func htmlTableCells(from rowHTML: String) -> [NativeEditorTableCell] { + htmlRegexMatches(pattern: #"<(th|td)\b([^>]*)>(.*?)"#, in: rowHTML) + .prefix(NativeEditorTable.maximumColumnCount) + .compactMap { match in + guard let tagName = htmlRegexString(match: match, captureIndex: 1, in: rowHTML), + let attributeText = htmlRegexString(match: match, captureIndex: 2, in: rowHTML), + let body = htmlRegexString(match: match, captureIndex: 3, in: rowHTML) else { + return nil + } + + return htmlTableCell(tagName: tagName, attributeText: attributeText, body: body) + } + } + + private static func htmlTableCell(tagName: String, attributeText: String, body: String) -> NativeEditorTableCell { + let attrs = docmostInlineHTMLAttributes(from: "<\(tagName)\(attributeText)>") + let paragraphAttrs = htmlTableParagraphAttributes(in: body) + let plainText = htmlTablePlainText(from: body) + + return NativeEditorTableCell( + plainText: plainText, + isHeader: tagName.localizedCaseInsensitiveCompare("th") == .orderedSame, + textAlignment: htmlTableTextAlignment(from: paragraphAttrs), + backgroundColor: nonEmptyHTMLTableAttribute(attrs["data-background-color"]) ?? + htmlStyleValue(named: "background-color", in: attrs["style"]), + backgroundColorName: nonEmptyHTMLTableAttribute(attrs["data-background-color-name"]), + columnSpan: htmlTableSpan(from: attrs["colspan"]), + rowSpan: htmlTableSpan(from: attrs["rowspan"]), + columnWidths: htmlTableColumnWidths(from: attrs) + ) + } + + private static func htmlTableParagraphAttributes(in html: String) -> [String: String] { + guard let match = htmlRegexMatches(pattern: #"]*)>"#, in: html).first, + let attributeText = htmlRegexString(match: match, captureIndex: 1, in: html) else { + return [:] + } + + return docmostInlineHTMLAttributes(from: "") + } + + private static func htmlTableTextAlignment(from attrs: [String: String]) -> NativeEditorTextAlignment? { + let value = nonEmptyHTMLTableAttribute(attrs["align"]) ?? + htmlStyleValue(named: "text-align", in: attrs["style"]) + guard let value else { return nil } + return NativeEditorTextAlignment(rawValue: value.lowercased()) + } + + private static func htmlTablePlainText(from html: String) -> String { + let paragraphSeparated = htmlRegexReplacing( + pattern: #"

\s*]*>"#, + in: html, + with: "\n" + ) + let hardBreakSeparated = htmlRegexReplacing( + pattern: #""#, + in: paragraphSeparated, + with: "\n" + ) + let withoutTags = htmlRegexReplacing(pattern: #"<[^>]+>"#, in: hardBreakSeparated, with: "") + return unescapedInlineHTMLText(withoutTags).trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func htmlTableSpan(from value: String?) -> Int { + max(Int(value ?? "") ?? 1, 1) + } + + private static func htmlTableColumnWidths(from attrs: [String: String]) -> [Int] { + guard let value = nonEmptyHTMLTableAttribute(attrs["colwidth"] ?? attrs["data-colwidth"]) else { + return [] + } + + return value.split { character in + character == "," || character.isWhitespace + } + .compactMap { Int($0) } + } + + private static func htmlStyleValue(named name: String, in style: String?) -> String? { + guard let style else { return nil } + let normalizedName = name.lowercased() + + for declaration in style.split(separator: ";") { + let parts = declaration.split(separator: ":", maxSplits: 1).map { + String($0).trimmingCharacters(in: .whitespacesAndNewlines) + } + guard parts.count == 2, parts[0].lowercased() == normalizedName else { + continue + } + + return parts[1].isEmpty ? nil : parts[1] + } + + return nil + } + + private static func htmlRegexMatches(pattern: String, in text: String) -> [NSTextCheckingResult] { + guard let expression = try? NSRegularExpression( + pattern: pattern, + options: [.caseInsensitive, .dotMatchesLineSeparators] + ) else { + return [] + } + + return expression.matches(in: text, range: NSRange(text.startIndex.. String? { + let range = match.range(at: captureIndex) + guard range.location != NSNotFound, + let textRange = Range(range, in: text) else { + return nil + } + + return String(text[textRange]) + } + + private static func htmlRegexReplacing(pattern: String, in text: String, with replacement: String) -> String { + guard let expression = try? NSRegularExpression( + pattern: pattern, + options: [.caseInsensitive, .dotMatchesLineSeparators] + ) else { + return text + } + + let range = NSRange(text.startIndex.. String? { + guard let value, value.isEmpty == false else { return nil } + return value + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift index dec1083..2fa4bcc 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift @@ -5,6 +5,10 @@ extension NativeEditorMarkdownParser { in lines: [String], startingAt index: Array.Index ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + if let htmlTable = htmlTableBlock(in: lines, startingAt: index) { + return htmlTable + } + let separatorIndex = lines.index(after: index) guard separatorIndex < lines.endIndex, diff --git a/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift b/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift new file mode 100644 index 0000000..d2cc395 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift @@ -0,0 +1,56 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorTableHTMLImportTests { + @Test func docmostHTMLTableImportsAsNativeTableBlock() throws { + let markdown = """ +
+ + + + + + + + + + + + +
+

Phase

+

Build

Taylor

+
+ """ + + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + guard case .table(let table) = block.kind else { + Issue.record("Expected Docmost HTML table to import as a native table block.") + return + } + + #expect(table.rows.count == 2) + #expect(table.rows[0].cells.count == 1) + #expect(table.rows[0].cells[0].plainText == "Phase") + #expect(table.rows[0].cells[0].isHeader) + #expect(table.rows[0].cells[0].textAlignment == .center) + #expect(table.rows[0].cells[0].backgroundColor == "#DBEAFE") + #expect(table.rows[0].cells[0].backgroundColorName == "blue") + #expect(table.rows[0].cells[0].columnSpan == 2) + #expect(table.rows[0].cells[0].columnWidths == [120, 160]) + #expect(table.rows[1].cells.map(\.plainText) == ["Build", "Taylor"]) + #expect(table.rows[1].cells[0].backgroundColor == "#FEF3C7") + #expect(table.rows[1].cells[0].backgroundColorName == "yellow") + + let node = NativeEditorDocument.node(from: block) + #expect(node.type == "table") + let headerCell = try #require(node.content?.first?.content?.first) + #expect(headerCell.type == "tableHeader") + #expect(headerCell.attrs?["colspan"] == .int(2)) + #expect(headerCell.attrs?["colwidth"] == .array([.int(120), .int(160)])) + #expect(headerCell.attrs?["backgroundColor"] == .string("#DBEAFE")) + #expect(headerCell.attrs?["backgroundColorName"] == .string("blue")) + } +} From 8647e1b57b1f97eb3d49babcde0be1ad667f984d Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 16:56:39 +0100 Subject: [PATCH 183/201] fix: preserve inline content in html tables --- ...NativeEditorMarkdownParser+TableHTML.swift | 20 ++++++-- .../NativeEditorTableHTMLImportTests.swift | 50 +++++++++++++++++++ 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift index 54a4b3d..71e4d21 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift @@ -104,10 +104,12 @@ extension NativeEditorMarkdownParser { private static func htmlTableCell(tagName: String, attributeText: String, body: String) -> NativeEditorTableCell { let attrs = docmostInlineHTMLAttributes(from: "<\(tagName)\(attributeText)>") let paragraphAttrs = htmlTableParagraphAttributes(in: body) - let plainText = htmlTablePlainText(from: body) + let inlineContent = htmlTableInlineContent(from: body) + let plainText = inlineContent.plainText return NativeEditorTableCell( plainText: plainText, + inlineContent: inlineContent.preservedForTableCell, isHeader: tagName.localizedCaseInsensitiveCompare("th") == .orderedSame, textAlignment: htmlTableTextAlignment(from: paragraphAttrs), backgroundColor: nonEmptyHTMLTableAttribute(attrs["data-background-color"]) ?? @@ -135,7 +137,13 @@ extension NativeEditorMarkdownParser { return NativeEditorTextAlignment(rawValue: value.lowercased()) } - private static func htmlTablePlainText(from html: String) -> String { + private static func htmlTableInlineContent(from html: String) -> [NativeEditorInlineContent] { + let inlineMarkdown = htmlTableInlineMarkdown(from: html) + let attributedText = inlineText(from: inlineMarkdown) + return NativeEditorDocument.inlineContent(from: NativeEditorDocument.inlineNodes(from: attributedText)) + } + + private static func htmlTableInlineMarkdown(from html: String) -> String { let paragraphSeparated = htmlRegexReplacing( pattern: #"

\s*]*>"#, in: html, @@ -146,8 +154,12 @@ extension NativeEditorMarkdownParser { in: paragraphSeparated, with: "\n" ) - let withoutTags = htmlRegexReplacing(pattern: #"<[^>]+>"#, in: hardBreakSeparated, with: "") - return unescapedInlineHTMLText(withoutTags).trimmingCharacters(in: .whitespacesAndNewlines) + let withoutOpeningParagraphs = htmlRegexReplacing( + pattern: #"]*>"#, + in: hardBreakSeparated, + with: "" + ) + return htmlRegexReplacing(pattern: #"

"#, in: withoutOpeningParagraphs, with: "") } private static func htmlTableSpan(from value: String?) -> Int { diff --git a/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift b/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift index d2cc395..a4afa28 100644 --- a/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift +++ b/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift @@ -53,4 +53,54 @@ struct NativeEditorTableHTMLImportTests { #expect(headerCell.attrs?["backgroundColor"] == .string("#DBEAFE")) #expect(headerCell.attrs?["backgroundColorName"] == .string("blue")) } + + @Test func docmostHTMLTableCellPreservesInlineAtomsAndCommentMarks() throws { + let mentionHTML = #"@Taylor"# + let commentHTML = #"review spec"# + let markdown = """ + + + + + + +

Ask \(mentionHTML) to \(commentHTML)

+ """ + + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + guard case .table(let table) = block.kind else { + Issue.record("Expected Docmost HTML table to import as a native table block.") + return + } + + let cell = try #require(table.rows.first?.cells.first) + #expect(cell.plainText == "Ask @Taylor to review spec") + #expect(cell.inlineContent?.contains { + if case .mention(let mention, _) = $0 { + return mention.identifier == "mention-1" && + mention.label == "Taylor" && + mention.entityType == "user" && + mention.entityID == "user-1" + } + + return false + } == true) + #expect(cell.inlineContent?.contains { + if case .text("review spec", let marks) = $0 { + return marks.contains(.comment(commentID: "comment-1", isResolved: true)) + } + + return false + } == true) + + let node = NativeEditorDocument.node(from: block) + let paragraphContent = try #require(node.content?.first?.content?.first?.content?.first?.content) + #expect(paragraphContent.map(\.type) == ["text", "mention", "text", "text"]) + #expect(paragraphContent[1].attrs?["id"] == .string("mention-1")) + #expect(paragraphContent[3].marks == [ + ProseMirrorMark(type: "comment", attrs: ["commentId": .string("comment-1"), "resolved": .bool(true)]) + ]) + } } From e281ff311a33fa624929603b8f7fd32d2d780e30 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 17:11:51 +0100 Subject: [PATCH 184/201] fix: preserve block content in html tables --- ...NativeEditorMarkdownParser+TableHTML.swift | 67 ++++++++++++++++++- .../NativeEditorTableHTMLImportTests.swift | 34 ++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift index 71e4d21..c7a27a5 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift @@ -104,12 +104,15 @@ extension NativeEditorMarkdownParser { private static func htmlTableCell(tagName: String, attributeText: String, body: String) -> NativeEditorTableCell { let attrs = docmostInlineHTMLAttributes(from: "<\(tagName)\(attributeText)>") let paragraphAttrs = htmlTableParagraphAttributes(in: body) - let inlineContent = htmlTableInlineContent(from: body) + let preservedContent = htmlTablePreservedContent(from: body) + let inlineContent = preservedContent.map(NativeEditorDocument.inlineContent(from:)) ?? + htmlTableInlineContent(from: body) let plainText = inlineContent.plainText return NativeEditorTableCell( plainText: plainText, inlineContent: inlineContent.preservedForTableCell, + preservedContent: preservedContent, isHeader: tagName.localizedCaseInsensitiveCompare("th") == .orderedSame, textAlignment: htmlTableTextAlignment(from: paragraphAttrs), backgroundColor: nonEmptyHTMLTableAttribute(attrs["data-background-color"]) ?? @@ -137,6 +140,68 @@ extension NativeEditorMarkdownParser { return NativeEditorTextAlignment(rawValue: value.lowercased()) } + private static func htmlTablePreservedContent(from html: String) -> [ProseMirrorNode]? { + let nodes = htmlRegexMatches(pattern: #"<(p|h[1-6])\b([^>]*)>(.*?)"#, in: html) + .compactMap { match -> ProseMirrorNode? in + guard let tagName = htmlRegexString(match: match, captureIndex: 1, in: html), + let attributeText = htmlRegexString(match: match, captureIndex: 2, in: html), + let body = htmlRegexString(match: match, captureIndex: 3, in: html) else { + return nil + } + + return htmlTableContentNode(tagName: tagName, attributeText: attributeText, body: body) + } + + guard nodes.isEmpty == false else { return nil } + if nodes.count == 1, nodes.first?.type == "paragraph", nodes.first?.attrs?.isEmpty != false { + return nil + } + return nodes + } + + private static func htmlTableContentNode( + tagName: String, + attributeText: String, + body: String + ) -> ProseMirrorNode { + let attrs = docmostInlineHTMLAttributes(from: "<\(tagName)\(attributeText)>") + let content = NativeEditorDocument.inlineNodes(from: inlineText(from: htmlTableInlineMarkdown(from: body))) + + if let headingLevel = htmlTableHeadingLevel(from: tagName) { + return ProseMirrorNode( + type: "heading", + attrs: htmlTableContentAttrs(baseAttrs: ["level": .int(headingLevel)], htmlAttrs: attrs), + content: content + ) + } + + return ProseMirrorNode( + type: "paragraph", + attrs: htmlTableContentAttrs(baseAttrs: [:], htmlAttrs: attrs), + content: content + ) + } + + private static func htmlTableHeadingLevel(from tagName: String) -> Int? { + guard tagName.count == 2, + tagName.first?.lowercased() == "h", + let level = Int(tagName.suffix(1)) else { + return nil + } + return min(max(level, 1), 6) + } + + private static func htmlTableContentAttrs( + baseAttrs: [String: ProseMirrorJSONValue], + htmlAttrs: [String: String] + ) -> [String: ProseMirrorJSONValue]? { + var attrs = baseAttrs + if let textAlignment = htmlTableTextAlignment(from: htmlAttrs) { + attrs["textAlign"] = .string(textAlignment.rawValue) + } + return attrs.isEmpty ? nil : attrs + } + private static func htmlTableInlineContent(from html: String) -> [NativeEditorInlineContent] { let inlineMarkdown = htmlTableInlineMarkdown(from: html) let attributedText = inlineText(from: inlineMarkdown) diff --git a/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift b/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift index a4afa28..3ccd8ae 100644 --- a/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift +++ b/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift @@ -103,4 +103,38 @@ struct NativeEditorTableHTMLImportTests { ProseMirrorMark(type: "comment", attrs: ["commentId": .string("comment-1"), "resolved": .bool(true)]) ]) } + + @Test func docmostHTMLTableCellPreservesBlockContent() throws { + let markdown = """ + + + + + + +
+

Phase

+

Ship native tables

+
+ """ + + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + guard case .table(let table) = block.kind else { + Issue.record("Expected Docmost HTML table to import as a native table block.") + return + } + + let cell = try #require(table.rows.first?.cells.first) + let preservedContent = try #require(cell.preservedContent) + #expect(cell.plainText == "PhaseShip native tables") + #expect(preservedContent.map(\.type) == ["heading", "paragraph"]) + #expect(preservedContent[0].attrs?["level"] == .int(2)) + + let node = NativeEditorDocument.node(from: block) + let cellContent = try #require(node.content?.first?.content?.first?.content) + #expect(cellContent.map(\.type) == ["heading", "paragraph"]) + #expect(cellContent[0].attrs?["level"] == .int(2)) + #expect(cellContent[0].content?.first?.text == "Phase") + #expect(cellContent[1].content?.first?.text == "Ship native tables") + } } From b4bbb0fee9d29acdc9ee579eb5b3836dac56d972 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 17:26:53 +0100 Subject: [PATCH 185/201] fix: preserve code blocks in html tables --- ...NativeEditorMarkdownParser+TableHTML.swift | 86 ++++++++++++++++++- .../NativeEditorTableHTMLImportTests.swift | 39 +++++++++ 2 files changed, 122 insertions(+), 3 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift index c7a27a5..7378d58 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift @@ -141,17 +141,43 @@ extension NativeEditorMarkdownParser { } private static func htmlTablePreservedContent(from html: String) -> [ProseMirrorNode]? { - let nodes = htmlRegexMatches(pattern: #"<(p|h[1-6])\b([^>]*)>(.*?)"#, in: html) - .compactMap { match -> ProseMirrorNode? in + let textBlockMatches = htmlRegexMatches(pattern: #"<(p|h[1-6])\b([^>]*)>(.*?)"#, in: html) + .compactMap { match -> HTMLTableContentMatch? in guard let tagName = htmlRegexString(match: match, captureIndex: 1, in: html), let attributeText = htmlRegexString(match: match, captureIndex: 2, in: html), let body = htmlRegexString(match: match, captureIndex: 3, in: html) else { return nil } - return htmlTableContentNode(tagName: tagName, attributeText: attributeText, body: body) + return HTMLTableContentMatch( + location: match.range.location, + node: htmlTableContentNode(tagName: tagName, attributeText: attributeText, body: body) + ) + } + let codeBlockMatches = htmlRegexMatches( + pattern: #"]*)>\s*]*)>(.*?)\s*"#, + in: html + ) + .compactMap { match -> HTMLTableContentMatch? in + guard let preAttributeText = htmlRegexString(match: match, captureIndex: 1, in: html), + let codeAttributeText = htmlRegexString(match: match, captureIndex: 2, in: html), + let body = htmlRegexString(match: match, captureIndex: 3, in: html) else { + return nil } + return HTMLTableContentMatch( + location: match.range.location, + node: htmlTableCodeBlockNode( + preAttributeText: preAttributeText, + codeAttributeText: codeAttributeText, + body: body + ) + ) + } + let nodes = (textBlockMatches + codeBlockMatches) + .sorted { $0.location < $1.location } + .map(\.node) + guard nodes.isEmpty == false else { return nil } if nodes.count == 1, nodes.first?.type == "paragraph", nodes.first?.attrs?.isEmpty != false { return nil @@ -182,6 +208,55 @@ extension NativeEditorMarkdownParser { ) } + private static func htmlTableCodeBlockNode( + preAttributeText: String, + codeAttributeText: String, + body: String + ) -> ProseMirrorNode { + let preAttrs = docmostInlineHTMLAttributes(from: "") + let codeAttrs = docmostInlineHTMLAttributes(from: "") + var attrs = [String: ProseMirrorJSONValue]() + + if let language = htmlTableCodeBlockLanguage(codeAttrs: codeAttrs, preAttrs: preAttrs) { + attrs["language"] = .string(language) + } + + let text = unescapedInlineHTMLText(body) + return ProseMirrorNode( + type: "codeBlock", + attrs: attrs.isEmpty ? nil : attrs, + content: text.isEmpty ? [] : [ProseMirrorNode(type: "text", text: text)] + ) + } + + private static func htmlTableCodeBlockLanguage( + codeAttrs: [String: String], + preAttrs: [String: String] + ) -> String? { + nonEmptyHTMLTableAttribute(codeAttrs["data-language"]) ?? + nonEmptyHTMLTableAttribute(codeAttrs["language"]) ?? + htmlTableCodeBlockLanguage(fromClassName: codeAttrs["class"]) ?? + nonEmptyHTMLTableAttribute(preAttrs["data-language"]) ?? + nonEmptyHTMLTableAttribute(preAttrs["language"]) ?? + htmlTableCodeBlockLanguage(fromClassName: preAttrs["class"]) + } + + private static func htmlTableCodeBlockLanguage(fromClassName className: String?) -> String? { + guard let className else { return nil } + + for component in className.split(whereSeparator: \.isWhitespace) { + let lowercasedComponent = component.lowercased() + if lowercasedComponent.hasPrefix("language-") { + return nonEmptyHTMLTableAttribute(String(component.dropFirst("language-".count))) + } + if lowercasedComponent.hasPrefix("lang-") { + return nonEmptyHTMLTableAttribute(String(component.dropFirst("lang-".count))) + } + } + + return nil + } + private static func htmlTableHeadingLevel(from tagName: String) -> Int? { guard tagName.count == 2, tagName.first?.lowercased() == "h", @@ -302,3 +377,8 @@ extension NativeEditorMarkdownParser { return value } } + +private struct HTMLTableContentMatch { + var location: Int + var node: ProseMirrorNode +} diff --git a/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift b/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift index 3ccd8ae..63638d8 100644 --- a/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift +++ b/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift @@ -137,4 +137,43 @@ struct NativeEditorTableHTMLImportTests { #expect(cellContent[0].content?.first?.text == "Phase") #expect(cellContent[1].content?.first?.text == "Ship native tables") } + + @Test func docmostHTMLTableCellPreservesCodeBlockContent() throws { + let markdown = """ + + + + + + +
+
let value = <draft>
+        print(value)
+
+ """ + + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + guard case .table(let table) = block.kind else { + Issue.record("Expected Docmost HTML table to import as a native table block.") + return + } + + let cell = try #require(table.rows.first?.cells.first) + let preservedContent = try #require(cell.preservedContent) + #expect(cell.plainText == """ + let value = + print(value) + """) + #expect(preservedContent.map(\.type) == ["codeBlock"]) + #expect(preservedContent[0].attrs?["language"] == .string("swift")) + + let node = NativeEditorDocument.node(from: block) + let cellContent = try #require(node.content?.first?.content?.first?.content) + #expect(cellContent.map(\.type) == ["codeBlock"]) + #expect(cellContent[0].attrs?["language"] == .string("swift")) + #expect(cellContent[0].content?.first?.text == """ + let value = + print(value) + """) + } } From 1a5cd4eab37b99d2643eb3cc4dc7e53383deee06 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 17:41:00 +0100 Subject: [PATCH 186/201] fix: preserve list content in html tables --- ...NativeEditorMarkdownParser+TableHTML.swift | 132 +++++++++++++++++- .../NativeEditorTableHTMLImportTests.swift | 50 +++++++ 2 files changed, 175 insertions(+), 7 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift index 7378d58..7fdb033 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift @@ -140,9 +140,17 @@ extension NativeEditorMarkdownParser { return NativeEditorTextAlignment(rawValue: value.lowercased()) } - private static func htmlTablePreservedContent(from html: String) -> [ProseMirrorNode]? { + private static func htmlTablePreservedContent( + from html: String, + dropsSinglePlainParagraph: Bool = true + ) -> [ProseMirrorNode]? { + let listMatches = htmlTableListContentMatches(from: html) + let listRanges = listMatches.map(\.range) let textBlockMatches = htmlRegexMatches(pattern: #"<(p|h[1-6])\b([^>]*)>(.*?)"#, in: html) .compactMap { match -> HTMLTableContentMatch? in + guard htmlTableRange(match.range, isNestedIn: listRanges) == false else { + return nil + } guard let tagName = htmlRegexString(match: match, captureIndex: 1, in: html), let attributeText = htmlRegexString(match: match, captureIndex: 2, in: html), let body = htmlRegexString(match: match, captureIndex: 3, in: html) else { @@ -150,7 +158,7 @@ extension NativeEditorMarkdownParser { } return HTMLTableContentMatch( - location: match.range.location, + range: match.range, node: htmlTableContentNode(tagName: tagName, attributeText: attributeText, body: body) ) } @@ -159,6 +167,9 @@ extension NativeEditorMarkdownParser { in: html ) .compactMap { match -> HTMLTableContentMatch? in + guard htmlTableRange(match.range, isNestedIn: listRanges) == false else { + return nil + } guard let preAttributeText = htmlRegexString(match: match, captureIndex: 1, in: html), let codeAttributeText = htmlRegexString(match: match, captureIndex: 2, in: html), let body = htmlRegexString(match: match, captureIndex: 3, in: html) else { @@ -166,7 +177,7 @@ extension NativeEditorMarkdownParser { } return HTMLTableContentMatch( - location: match.range.location, + range: match.range, node: htmlTableCodeBlockNode( preAttributeText: preAttributeText, codeAttributeText: codeAttributeText, @@ -174,17 +185,42 @@ extension NativeEditorMarkdownParser { ) ) } - let nodes = (textBlockMatches + codeBlockMatches) - .sorted { $0.location < $1.location } + let nodes = (textBlockMatches + codeBlockMatches + listMatches) + .sorted { $0.range.location < $1.range.location } .map(\.node) guard nodes.isEmpty == false else { return nil } - if nodes.count == 1, nodes.first?.type == "paragraph", nodes.first?.attrs?.isEmpty != false { + if dropsSinglePlainParagraph, + nodes.count == 1, + nodes.first?.type == "paragraph", + nodes.first?.attrs?.isEmpty != false { return nil } return nodes } + private static func htmlTableListContentMatches(from html: String) -> [HTMLTableContentMatch] { + htmlRegexMatches(pattern: #"<(ul|ol)\b([^>]*)>(.*?)"#, in: html) + .compactMap { match -> HTMLTableContentMatch? in + guard let tagName = htmlRegexString(match: match, captureIndex: 1, in: html), + let attributeText = htmlRegexString(match: match, captureIndex: 2, in: html), + let body = htmlRegexString(match: match, captureIndex: 3, in: html) else { + return nil + } + + return HTMLTableContentMatch( + range: match.range, + node: htmlTableListNode(tagName: tagName, attributeText: attributeText, body: body) + ) + } + } + + private static func htmlTableRange(_ range: NSRange, isNestedIn ranges: [NSRange]) -> Bool { + ranges.contains { container in + range.location > container.location && NSMaxRange(range) <= NSMaxRange(container) + } + } + private static func htmlTableContentNode( tagName: String, attributeText: String, @@ -208,6 +244,88 @@ extension NativeEditorMarkdownParser { ) } + private static func htmlTableListNode( + tagName: String, + attributeText: String, + body: String + ) -> ProseMirrorNode { + let attrs = docmostInlineHTMLAttributes(from: "<\(tagName)\(attributeText)>") + let isOrderedList = htmlTagNameMatches(tagName, "ol") + let isTaskList = isOrderedList == false && + attrs["data-type"]?.compare("taskList", options: .caseInsensitive) == .orderedSame + let nodeType = isOrderedList ? "orderedList" : (isTaskList ? "taskList" : "bulletList") + var nodeAttrs = [String: ProseMirrorJSONValue]() + + if isOrderedList, let start = Int(attrs["start"] ?? ""), start != 1 { + nodeAttrs["start"] = .int(start) + } + + return ProseMirrorNode( + type: nodeType, + attrs: nodeAttrs.isEmpty ? nil : nodeAttrs, + content: htmlTableListItems(from: body, itemType: isTaskList ? "taskItem" : "listItem") + ) + } + + private static func htmlTableListItems(from html: String, itemType: String) -> [ProseMirrorNode] { + htmlRegexMatches(pattern: #"]*)>(.*?)"#, in: html).compactMap { match in + guard let attributeText = htmlRegexString(match: match, captureIndex: 1, in: html), + let body = htmlRegexString(match: match, captureIndex: 2, in: html) else { + return nil + } + + return htmlTableListItemNode(itemType: itemType, attributeText: attributeText, body: body) + } + } + + private static func htmlTableListItemNode( + itemType: String, + attributeText: String, + body: String + ) -> ProseMirrorNode { + var attrs = [String: ProseMirrorJSONValue]() + if itemType == "taskItem" { + attrs["checked"] = .bool(htmlTableTaskItemIsChecked(attributeText: attributeText, body: body)) + } + + return ProseMirrorNode( + type: itemType, + attrs: attrs.isEmpty ? nil : attrs, + content: htmlTableListItemContent(from: body) + ) + } + + private static func htmlTableListItemContent(from html: String) -> [ProseMirrorNode] { + if let preservedContent = htmlTablePreservedContent(from: html, dropsSinglePlainParagraph: false) { + return preservedContent + } + + return [ + ProseMirrorNode( + type: "paragraph", + content: NativeEditorDocument.inlineNodes(from: inlineText(from: htmlTableInlineMarkdown(from: html))) + ) + ] + } + + private static func htmlTableTaskItemIsChecked(attributeText: String, body: String) -> Bool { + let attrs = docmostInlineHTMLAttributes(from: "") + if let value = attrs["data-checked"] ?? attrs["checked"] ?? attrs["aria-checked"] { + return htmlTableBooleanAttributeIsTruthy(value) + } + + return htmlRegexMatches(pattern: #"]*\bchecked(?:\s*=\s*['"]?(?:checked|true|1)['"]?)?"#, in: body) + .isEmpty == false + } + + private static func htmlTableBooleanAttributeIsTruthy(_ value: String) -> Bool { + let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalizedValue == "" || + normalizedValue == "true" || + normalizedValue == "checked" || + normalizedValue == "1" + } + private static func htmlTableCodeBlockNode( preAttributeText: String, codeAttributeText: String, @@ -379,6 +497,6 @@ extension NativeEditorMarkdownParser { } private struct HTMLTableContentMatch { - var location: Int + var range: NSRange var node: ProseMirrorNode } diff --git a/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift b/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift index 63638d8..eeea28d 100644 --- a/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift +++ b/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift @@ -176,4 +176,54 @@ struct NativeEditorTableHTMLImportTests { print(value) """) } + + @Test func docmostHTMLTableCellPreservesListContent() throws { + let markdown = """ + + + + + + +
+
    +
  • Plan rollout

  • +
  • Measure feedback

  • +
+
    +
  1. Ship polish

  2. +
+
    +
  • Confirm docs

  • +
+
+ """ + + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + guard case .table(let table) = block.kind else { + Issue.record("Expected Docmost HTML table to import as a native table block.") + return + } + + let cell = try #require(table.rows.first?.cells.first) + let preservedContent = try #require(cell.preservedContent) + #expect(cell.plainText == "Plan rolloutMeasure feedbackShip polishConfirm docs") + #expect(preservedContent.map(\.type) == ["bulletList", "orderedList", "taskList"]) + + let bulletItems = try #require(preservedContent[0].content) + #expect(bulletItems.map(\.type) == ["listItem", "listItem"]) + #expect(bulletItems[0].content?.first?.content?.first?.text == "Plan rollout") + #expect(bulletItems[1].content?.first?.content?.first?.text == "Measure feedback") + #expect(preservedContent[1].attrs?["start"] == .int(3)) + #expect(preservedContent[1].content?.first?.content?.first?.content?.first?.text == "Ship polish") + #expect(preservedContent[2].content?.first?.type == "taskItem") + #expect(preservedContent[2].content?.first?.attrs?["checked"] == .bool(true)) + #expect(preservedContent[2].content?.first?.content?.first?.content?.first?.text == "Confirm docs") + + let node = NativeEditorDocument.node(from: block) + let cellContent = try #require(node.content?.first?.content?.first?.content) + #expect(cellContent.map(\.type) == ["bulletList", "orderedList", "taskList"]) + #expect(cellContent[1].attrs?["start"] == .int(3)) + #expect(cellContent[2].content?.first?.attrs?["checked"] == .bool(true)) + } } From fdf73bf2697817cf3c0c42d3f82540f8f57d85b6 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 17:55:25 +0100 Subject: [PATCH 187/201] fix: preserve callouts in html tables --- ...NativeEditorMarkdownParser+TableHTML.swift | 90 +++++++++++++++---- .../NativeEditorTableHTMLImportTests.swift | 38 ++++++++ 2 files changed, 112 insertions(+), 16 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift index 7fdb033..4a1b8f2 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift @@ -144,11 +144,13 @@ extension NativeEditorMarkdownParser { from html: String, dropsSinglePlainParagraph: Bool = true ) -> [ProseMirrorNode]? { - let listMatches = htmlTableListContentMatches(from: html) - let listRanges = listMatches.map(\.range) + let calloutMatches = htmlTableCalloutContentMatches(from: html) + let calloutRanges = calloutMatches.map(\.range) + let listMatches = htmlTableListContentMatches(from: html, excluding: calloutRanges) + let containerRanges = calloutRanges + listMatches.map(\.range) let textBlockMatches = htmlRegexMatches(pattern: #"<(p|h[1-6])\b([^>]*)>(.*?)"#, in: html) .compactMap { match -> HTMLTableContentMatch? in - guard htmlTableRange(match.range, isNestedIn: listRanges) == false else { + guard htmlTableRange(match.range, isNestedIn: containerRanges) == false else { return nil } guard let tagName = htmlRegexString(match: match, captureIndex: 1, in: html), @@ -167,7 +169,7 @@ extension NativeEditorMarkdownParser { in: html ) .compactMap { match -> HTMLTableContentMatch? in - guard htmlTableRange(match.range, isNestedIn: listRanges) == false else { + guard htmlTableRange(match.range, isNestedIn: containerRanges) == false else { return nil } guard let preAttributeText = htmlRegexString(match: match, captureIndex: 1, in: html), @@ -185,7 +187,7 @@ extension NativeEditorMarkdownParser { ) ) } - let nodes = (textBlockMatches + codeBlockMatches + listMatches) + let nodes = (textBlockMatches + codeBlockMatches + listMatches + calloutMatches) .sorted { $0.range.location < $1.range.location } .map(\.node) @@ -199,9 +201,35 @@ extension NativeEditorMarkdownParser { return nodes } - private static func htmlTableListContentMatches(from html: String) -> [HTMLTableContentMatch] { + private static func htmlTableCalloutContentMatches(from html: String) -> [HTMLTableContentMatch] { + htmlRegexMatches(pattern: #"]*)>(.*?)
"#, in: html) + .compactMap { match -> HTMLTableContentMatch? in + guard let attributeText = htmlRegexString(match: match, captureIndex: 1, in: html), + let body = htmlRegexString(match: match, captureIndex: 2, in: html) else { + return nil + } + + let attrs = docmostInlineHTMLAttributes(from: "") + guard attrs["data-type"]?.compare("callout", options: .caseInsensitive) == .orderedSame else { + return nil + } + + return HTMLTableContentMatch( + range: match.range, + node: htmlTableCalloutNode(attrs: attrs, body: body) + ) + } + } + + private static func htmlTableListContentMatches( + from html: String, + excluding excludedRanges: [NSRange] + ) -> [HTMLTableContentMatch] { htmlRegexMatches(pattern: #"<(ul|ol)\b([^>]*)>(.*?)"#, in: html) .compactMap { match -> HTMLTableContentMatch? in + guard htmlTableRange(match.range, isNestedIn: excludedRanges) == false else { + return nil + } guard let tagName = htmlRegexString(match: match, captureIndex: 1, in: html), let attributeText = htmlRegexString(match: match, captureIndex: 2, in: html), let body = htmlRegexString(match: match, captureIndex: 3, in: html) else { @@ -244,6 +272,45 @@ extension NativeEditorMarkdownParser { ) } + private static func htmlTableCalloutNode( + attrs: [String: String], + body: String + ) -> ProseMirrorNode { + var nodeAttrs: [String: ProseMirrorJSONValue] = [ + "type": .string(htmlTableSanitizedCalloutStyle(attrs["data-callout-type"] ?? "info")) + ] + if let icon = nonEmptyHTMLTableAttribute(attrs["data-callout-icon"]) { + nodeAttrs["icon"] = .string(icon) + } + + return ProseMirrorNode( + type: "callout", + attrs: nodeAttrs, + content: htmlTableContainerContent(from: body) + ) + } + + private static func htmlTableContainerContent(from html: String) -> [ProseMirrorNode] { + if let preservedContent = htmlTablePreservedContent(from: html, dropsSinglePlainParagraph: false) { + return preservedContent + } + + return [ + ProseMirrorNode( + type: "paragraph", + content: NativeEditorDocument.inlineNodes(from: inlineText(from: htmlTableInlineMarkdown(from: html))) + ) + ] + } + + private static func htmlTableSanitizedCalloutStyle(_ value: String) -> String { + let sanitizedScalars = value.lowercased().unicodeScalars.filter { + CharacterSet.alphanumerics.contains($0) + } + let sanitized = String(String.UnicodeScalarView(sanitizedScalars)) + return sanitized.isEmpty ? "info" : sanitized + } + private static func htmlTableListNode( tagName: String, attributeText: String, @@ -296,16 +363,7 @@ extension NativeEditorMarkdownParser { } private static func htmlTableListItemContent(from html: String) -> [ProseMirrorNode] { - if let preservedContent = htmlTablePreservedContent(from: html, dropsSinglePlainParagraph: false) { - return preservedContent - } - - return [ - ProseMirrorNode( - type: "paragraph", - content: NativeEditorDocument.inlineNodes(from: inlineText(from: htmlTableInlineMarkdown(from: html))) - ) - ] + htmlTableContainerContent(from: html) } private static func htmlTableTaskItemIsChecked(attributeText: String, body: String) -> Bool { diff --git a/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift b/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift index eeea28d..327496c 100644 --- a/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift +++ b/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift @@ -226,4 +226,42 @@ struct NativeEditorTableHTMLImportTests { #expect(cellContent[1].attrs?["start"] == .int(3)) #expect(cellContent[2].content?.first?.attrs?["checked"] == .bool(true)) } + + @Test func docmostHTMLTableCellPreservesCalloutContent() throws { + let markdown = """ + + + + + + +
+
+

Check launch notes

+
+
+ """ + + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + guard case .table(let table) = block.kind else { + Issue.record("Expected Docmost HTML table to import as a native table block.") + return + } + + let cell = try #require(table.rows.first?.cells.first) + let preservedContent = try #require(cell.preservedContent) + #expect(cell.plainText == "Check launch notes") + #expect(preservedContent.map(\.type) == ["callout"]) + #expect(preservedContent[0].attrs?["type"] == .string("warning")) + #expect(preservedContent[0].attrs?["icon"] == .string("rocket")) + #expect(preservedContent[0].content?.first?.type == "paragraph") + #expect(preservedContent[0].content?.first?.content?.first?.text == "Check launch notes") + + let node = NativeEditorDocument.node(from: block) + let cellContent = try #require(node.content?.first?.content?.first?.content) + #expect(cellContent.map(\.type) == ["callout"]) + #expect(cellContent[0].attrs?["type"] == .string("warning")) + #expect(cellContent[0].attrs?["icon"] == .string("rocket")) + #expect(cellContent[0].content?.first?.content?.first?.text == "Check launch notes") + } } From 0871ef48be456a0e5de97b8805e57c693b1b169a Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 18:11:35 +0100 Subject: [PATCH 188/201] fix: preserve images in html tables --- ...NativeEditorMarkdownParser+TableHTML.swift | 13 ++-- ...eEditorMarkdownParser+TableHTMLImage.swift | 69 +++++++++++++++++++ .../NativeEditorTableHTMLImportTests.swift | 42 +++++++++++ 3 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLImage.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift index 4a1b8f2..0aa065a 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift @@ -187,7 +187,8 @@ extension NativeEditorMarkdownParser { ) ) } - let nodes = (textBlockMatches + codeBlockMatches + listMatches + calloutMatches) + let imageMatches = htmlTableImageContentMatches(from: html, excluding: containerRanges) + let nodes = (textBlockMatches + codeBlockMatches + imageMatches + listMatches + calloutMatches) .sorted { $0.range.location < $1.range.location } .map(\.node) @@ -243,7 +244,7 @@ extension NativeEditorMarkdownParser { } } - private static func htmlTableRange(_ range: NSRange, isNestedIn ranges: [NSRange]) -> Bool { + static func htmlTableRange(_ range: NSRange, isNestedIn ranges: [NSRange]) -> Bool { ranges.contains { container in range.location > container.location && NSMaxRange(range) <= NSMaxRange(container) } @@ -511,7 +512,7 @@ extension NativeEditorMarkdownParser { return nil } - private static func htmlRegexMatches(pattern: String, in text: String) -> [NSTextCheckingResult] { + static func htmlRegexMatches(pattern: String, in text: String) -> [NSTextCheckingResult] { guard let expression = try? NSRegularExpression( pattern: pattern, options: [.caseInsensitive, .dotMatchesLineSeparators] @@ -522,7 +523,7 @@ extension NativeEditorMarkdownParser { return expression.matches(in: text, range: NSRange(text.startIndex.. String? { + static func nonEmptyHTMLTableAttribute(_ value: String?) -> String? { guard let value, value.isEmpty == false else { return nil } return value } } -private struct HTMLTableContentMatch { +struct HTMLTableContentMatch { var range: NSRange var node: ProseMirrorNode } diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLImage.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLImage.swift new file mode 100644 index 0000000..17395e0 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLImage.swift @@ -0,0 +1,69 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func htmlTableImageContentMatches( + from html: String, + excluding excludedRanges: [NSRange] + ) -> [HTMLTableContentMatch] { + htmlRegexMatches(pattern: #"]*)/?>"#, in: html) + .compactMap { match -> HTMLTableContentMatch? in + guard htmlTableRange(match.range, isNestedIn: excludedRanges) == false, + let attributeText = htmlRegexString(match: match, captureIndex: 1, in: html) else { + return nil + } + + return HTMLTableContentMatch( + range: match.range, + node: htmlTableImageNode(attrs: docmostInlineHTMLAttributes(from: "")) + ) + } + } + + private static func htmlTableImageNode(attrs: [String: String]) -> ProseMirrorNode { + var nodeAttrs = [String: ProseMirrorJSONValue]() + let source = nonEmptyHTMLTableAttribute(attrs["src"]) + + if let source { + nodeAttrs["src"] = .string(source) + } + if let alternativeText = nonEmptyHTMLTableAttribute(attrs["alt"]) { + nodeAttrs["alt"] = .string(alternativeText) + } + if let title = nonEmptyHTMLTableAttribute(attrs["title"]) { + nodeAttrs["title"] = .string(title) + } + if let attachmentID = nonEmptyHTMLTableAttribute(attrs["data-attachment-id"]) ?? + source.flatMap(docmostAttachmentID) { + nodeAttrs["attachmentId"] = .string(attachmentID) + } + if let sizeInBytes = nonEmptyHTMLTableAttribute(attrs["data-size"]).flatMap(Int.init) { + nodeAttrs["size"] = .int(sizeInBytes) + } + if let width = nonEmptyHTMLTableAttribute(attrs["width"]).flatMap(htmlTableProseMirrorNumberOrString) { + nodeAttrs["width"] = width + } + if let height = nonEmptyHTMLTableAttribute(attrs["height"]).flatMap(htmlTableProseMirrorNumberOrString) { + nodeAttrs["height"] = height + } + if let aspectRatio = nonEmptyHTMLTableAttribute(attrs["data-aspect-ratio"]).flatMap(Double.init) { + nodeAttrs["aspectRatio"] = .double(aspectRatio) + } + if let alignment = nonEmptyHTMLTableAttribute(attrs["data-align"]) { + nodeAttrs["align"] = .string(alignment) + } + + return ProseMirrorNode(type: "image", attrs: nodeAttrs.isEmpty ? nil : nodeAttrs) + } + + private static func htmlTableProseMirrorNumberOrString(from value: String) -> ProseMirrorJSONValue? { + let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedValue.isEmpty == false else { return nil } + if let intValue = Int(trimmedValue) { + return .int(intValue) + } + if let doubleValue = Double(trimmedValue) { + return .double(doubleValue) + } + return .string(trimmedValue) + } +} diff --git a/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift b/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift index 327496c..7262931 100644 --- a/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift +++ b/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift @@ -264,4 +264,46 @@ struct NativeEditorTableHTMLImportTests { #expect(cellContent[0].attrs?["icon"] == .string("rocket")) #expect(cellContent[0].content?.first?.content?.first?.text == "Check launch notes") } + + @Test func docmostHTMLTableCellPreservesImageContent() throws { + let markdown = """ + + + + + + +
+ Architecture +
+ """ + + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + guard case .table(let table) = block.kind else { + Issue.record("Expected Docmost HTML table to import as a native table block.") + return + } + + let cell = try #require(table.rows.first?.cells.first) + let preservedContent = try #require(cell.preservedContent) + #expect(cell.plainText.isEmpty) + #expect(preservedContent.map(\.type) == ["image"]) + #expect(preservedContent[0].attrs?["src"] == .string("/api/attachments/img/image-1.png")) + #expect(preservedContent[0].attrs?["alt"] == .string("Architecture")) + #expect(preservedContent[0].attrs?["title"] == .string("System diagram")) + #expect(preservedContent[0].attrs?["width"] == .int(640)) + #expect(preservedContent[0].attrs?["height"] == .int(360)) + #expect(preservedContent[0].attrs?["align"] == .string("center")) + #expect(preservedContent[0].attrs?["attachmentId"] == .string("image-1")) + #expect(preservedContent[0].attrs?["size"] == .int(2048)) + #expect(preservedContent[0].attrs?["aspectRatio"] == .double(1.7777778)) + + let node = NativeEditorDocument.node(from: block) + let cellContent = try #require(node.content?.first?.content?.first?.content) + #expect(cellContent.map(\.type) == ["image"]) + #expect(cellContent[0].attrs?["attachmentId"] == .string("image-1")) + #expect(cellContent[0].attrs?["aspectRatio"] == .double(1.7777778)) + } } From 1170f56d1383c2990406f7c1add4b5a9d6527331 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 18:26:38 +0100 Subject: [PATCH 189/201] fix: preserve media in html tables --- ...NativeEditorMarkdownParser+TableHTML.swift | 4 +- ...eEditorMarkdownParser+TableHTMLImage.swift | 69 --------- ...eEditorMarkdownParser+TableHTMLMedia.swift | 139 ++++++++++++++++++ .../NativeEditorTableHTMLImportTests.swift | 50 +++++++ 4 files changed, 191 insertions(+), 71 deletions(-) delete mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLImage.swift create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLMedia.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift index 0aa065a..2a1b041 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift @@ -187,8 +187,8 @@ extension NativeEditorMarkdownParser { ) ) } - let imageMatches = htmlTableImageContentMatches(from: html, excluding: containerRanges) - let nodes = (textBlockMatches + codeBlockMatches + imageMatches + listMatches + calloutMatches) + let mediaMatches = htmlTableMediaContentMatches(from: html, excluding: containerRanges) + let nodes = (textBlockMatches + codeBlockMatches + mediaMatches + listMatches + calloutMatches) .sorted { $0.range.location < $1.range.location } .map(\.node) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLImage.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLImage.swift deleted file mode 100644 index 17395e0..0000000 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLImage.swift +++ /dev/null @@ -1,69 +0,0 @@ -import Foundation - -extension NativeEditorMarkdownParser { - static func htmlTableImageContentMatches( - from html: String, - excluding excludedRanges: [NSRange] - ) -> [HTMLTableContentMatch] { - htmlRegexMatches(pattern: #"]*)/?>"#, in: html) - .compactMap { match -> HTMLTableContentMatch? in - guard htmlTableRange(match.range, isNestedIn: excludedRanges) == false, - let attributeText = htmlRegexString(match: match, captureIndex: 1, in: html) else { - return nil - } - - return HTMLTableContentMatch( - range: match.range, - node: htmlTableImageNode(attrs: docmostInlineHTMLAttributes(from: "")) - ) - } - } - - private static func htmlTableImageNode(attrs: [String: String]) -> ProseMirrorNode { - var nodeAttrs = [String: ProseMirrorJSONValue]() - let source = nonEmptyHTMLTableAttribute(attrs["src"]) - - if let source { - nodeAttrs["src"] = .string(source) - } - if let alternativeText = nonEmptyHTMLTableAttribute(attrs["alt"]) { - nodeAttrs["alt"] = .string(alternativeText) - } - if let title = nonEmptyHTMLTableAttribute(attrs["title"]) { - nodeAttrs["title"] = .string(title) - } - if let attachmentID = nonEmptyHTMLTableAttribute(attrs["data-attachment-id"]) ?? - source.flatMap(docmostAttachmentID) { - nodeAttrs["attachmentId"] = .string(attachmentID) - } - if let sizeInBytes = nonEmptyHTMLTableAttribute(attrs["data-size"]).flatMap(Int.init) { - nodeAttrs["size"] = .int(sizeInBytes) - } - if let width = nonEmptyHTMLTableAttribute(attrs["width"]).flatMap(htmlTableProseMirrorNumberOrString) { - nodeAttrs["width"] = width - } - if let height = nonEmptyHTMLTableAttribute(attrs["height"]).flatMap(htmlTableProseMirrorNumberOrString) { - nodeAttrs["height"] = height - } - if let aspectRatio = nonEmptyHTMLTableAttribute(attrs["data-aspect-ratio"]).flatMap(Double.init) { - nodeAttrs["aspectRatio"] = .double(aspectRatio) - } - if let alignment = nonEmptyHTMLTableAttribute(attrs["data-align"]) { - nodeAttrs["align"] = .string(alignment) - } - - return ProseMirrorNode(type: "image", attrs: nodeAttrs.isEmpty ? nil : nodeAttrs) - } - - private static func htmlTableProseMirrorNumberOrString(from value: String) -> ProseMirrorJSONValue? { - let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmedValue.isEmpty == false else { return nil } - if let intValue = Int(trimmedValue) { - return .int(intValue) - } - if let doubleValue = Double(trimmedValue) { - return .double(doubleValue) - } - return .string(trimmedValue) - } -} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLMedia.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLMedia.swift new file mode 100644 index 0000000..fc8f2de --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLMedia.swift @@ -0,0 +1,139 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func htmlTableMediaContentMatches( + from html: String, + excluding excludedRanges: [NSRange] + ) -> [HTMLTableContentMatch] { + htmlTableImageContentMatches(from: html, excluding: excludedRanges) + + htmlTableMediaElementContentMatches(from: html, excluding: excludedRanges, type: "video") + + htmlTableMediaElementContentMatches(from: html, excluding: excludedRanges, type: "audio") + } + + private static func htmlTableImageContentMatches( + from html: String, + excluding excludedRanges: [NSRange] + ) -> [HTMLTableContentMatch] { + htmlRegexMatches(pattern: #"]*)/?>"#, in: html) + .compactMap { match -> HTMLTableContentMatch? in + guard htmlTableRange(match.range, isNestedIn: excludedRanges) == false, + let attributeText = htmlRegexString(match: match, captureIndex: 1, in: html) else { + return nil + } + + return HTMLTableContentMatch( + range: match.range, + node: htmlTableImageNode(attrs: docmostInlineHTMLAttributes(from: "")) + ) + } + } + + private static func htmlTableMediaElementContentMatches( + from html: String, + excluding excludedRanges: [NSRange], + type: String + ) -> [HTMLTableContentMatch] { + htmlRegexMatches(pattern: #"<\#(type)\b([^>]*)>(.*?)"#, in: html) + .compactMap { match -> HTMLTableContentMatch? in + guard htmlTableRange(match.range, isNestedIn: excludedRanges) == false, + let attributeText = htmlRegexString(match: match, captureIndex: 1, in: html), + let body = htmlRegexString(match: match, captureIndex: 2, in: html) else { + return nil + } + + let attrs = docmostInlineHTMLAttributes(from: "<\(type)\(attributeText)>") + let sourceAttrs = firstHTMLTagAttributes(in: body, tagName: "source") ?? [:] + return HTMLTableContentMatch( + range: match.range, + node: htmlTableMediaElementNode(type: type, attrs: attrs, sourceAttrs: sourceAttrs) + ) + } + } + + private static func htmlTableImageNode(attrs: [String: String]) -> ProseMirrorNode { + var nodeAttrs = [String: ProseMirrorJSONValue]() + let source = nonEmptyHTMLTableAttribute(attrs["src"]) + + if let source { + nodeAttrs["src"] = .string(source) + } + if let alternativeText = nonEmptyHTMLTableAttribute(attrs["alt"]) { + nodeAttrs["alt"] = .string(alternativeText) + } + if let title = nonEmptyHTMLTableAttribute(attrs["title"]) { + nodeAttrs["title"] = .string(title) + } + if let attachmentID = nonEmptyHTMLTableAttribute(attrs["data-attachment-id"]) ?? + source.flatMap(docmostAttachmentID) { + nodeAttrs["attachmentId"] = .string(attachmentID) + } + if let sizeInBytes = nonEmptyHTMLTableAttribute(attrs["data-size"]).flatMap(Int.init) { + nodeAttrs["size"] = .int(sizeInBytes) + } + if let width = nonEmptyHTMLTableAttribute(attrs["width"]).flatMap(htmlTableProseMirrorNumberOrString) { + nodeAttrs["width"] = width + } + if let height = nonEmptyHTMLTableAttribute(attrs["height"]).flatMap(htmlTableProseMirrorNumberOrString) { + nodeAttrs["height"] = height + } + if let aspectRatio = nonEmptyHTMLTableAttribute(attrs["data-aspect-ratio"]).flatMap(Double.init) { + nodeAttrs["aspectRatio"] = .double(aspectRatio) + } + if let alignment = nonEmptyHTMLTableAttribute(attrs["data-align"]) { + nodeAttrs["align"] = .string(alignment) + } + + return ProseMirrorNode(type: "image", attrs: nodeAttrs.isEmpty ? nil : nodeAttrs) + } + + private static func htmlTableMediaElementNode( + type: String, + attrs: [String: String], + sourceAttrs: [String: String] + ) -> ProseMirrorNode { + var nodeAttrs = [String: ProseMirrorJSONValue]() + let source = nonEmptyHTMLTableAttribute(attrs["src"]) ?? nonEmptyHTMLTableAttribute(sourceAttrs["src"]) + + if let source { + nodeAttrs["src"] = .string(source) + } + if type == "video", + let alternativeText = nonEmptyHTMLTableAttribute(attrs["aria-label"]) ?? + nonEmptyHTMLTableAttribute(attrs["alt"]) { + nodeAttrs["alt"] = .string(alternativeText) + } + if let attachmentID = nonEmptyHTMLTableAttribute(attrs["data-attachment-id"]) ?? + source.flatMap(docmostAttachmentID) { + nodeAttrs["attachmentId"] = .string(attachmentID) + } + if let sizeInBytes = nonEmptyHTMLTableAttribute(attrs["data-size"]).flatMap(Int.init) { + nodeAttrs["size"] = .int(sizeInBytes) + } + if let width = nonEmptyHTMLTableAttribute(attrs["width"]).flatMap(htmlTableProseMirrorNumberOrString) { + nodeAttrs["width"] = width + } + if let height = nonEmptyHTMLTableAttribute(attrs["height"]).flatMap(htmlTableProseMirrorNumberOrString) { + nodeAttrs["height"] = height + } + if let aspectRatio = nonEmptyHTMLTableAttribute(attrs["data-aspect-ratio"]).flatMap(Double.init) { + nodeAttrs["aspectRatio"] = .double(aspectRatio) + } + if let alignment = nonEmptyHTMLTableAttribute(attrs["data-align"]) { + nodeAttrs["align"] = .string(alignment) + } + + return ProseMirrorNode(type: type, attrs: nodeAttrs.isEmpty ? nil : nodeAttrs) + } + + private static func htmlTableProseMirrorNumberOrString(from value: String) -> ProseMirrorJSONValue? { + let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedValue.isEmpty == false else { return nil } + if let intValue = Int(trimmedValue) { + return .int(intValue) + } + if let doubleValue = Double(trimmedValue) { + return .double(doubleValue) + } + return .string(trimmedValue) + } +} diff --git a/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift b/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift index 7262931..7422ea2 100644 --- a/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift +++ b/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift @@ -306,4 +306,54 @@ struct NativeEditorTableHTMLImportTests { #expect(cellContent[0].attrs?["attachmentId"] == .string("image-1")) #expect(cellContent[0].attrs?["aspectRatio"] == .double(1.7777778)) } + + @Test func docmostHTMLTableCellPreservesVideoAndAudioContent() throws { + let markdown = """ + + + + + + +
+ + +
+ """ + + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + guard case .table(let table) = block.kind else { + Issue.record("Expected Docmost HTML table to import as a native table block.") + return + } + + let cell = try #require(table.rows.first?.cells.first) + let preservedContent = try #require(cell.preservedContent) + #expect(cell.plainText.isEmpty) + #expect(preservedContent.map(\.type) == ["video", "audio"]) + #expect(preservedContent[0].attrs?["src"] == .string("/api/files/video-1/Launch.mp4")) + #expect(preservedContent[0].attrs?["alt"] == .string("Launch demo")) + #expect(preservedContent[0].attrs?["width"] == .string("75%")) + #expect(preservedContent[0].attrs?["height"] == .int(360)) + #expect(preservedContent[0].attrs?["align"] == .string("right")) + #expect(preservedContent[0].attrs?["attachmentId"] == .string("video-1")) + #expect(preservedContent[0].attrs?["size"] == .int(4096)) + #expect(preservedContent[0].attrs?["aspectRatio"] == .double(1.7777778)) + #expect(preservedContent[1].attrs?["src"] == .string("/api/files/audio-1/Briefing.m4a")) + #expect(preservedContent[1].attrs?["attachmentId"] == .string("audio-1")) + #expect(preservedContent[1].attrs?["size"] == .int(2048)) + + let node = NativeEditorDocument.node(from: block) + let cellContent = try #require(node.content?.first?.content?.first?.content) + #expect(cellContent.map(\.type) == ["video", "audio"]) + #expect(cellContent[0].attrs?["attachmentId"] == .string("video-1")) + #expect(cellContent[1].attrs?["attachmentId"] == .string("audio-1")) + } } From 18cf31e9c759608de861e6aa334eb115b35bf40b Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 18:43:02 +0100 Subject: [PATCH 190/201] fix: preserve attachments in html tables --- ...eEditorMarkdownParser+TableHTMLMedia.swift | 111 +++++++++++++++++- .../NativeEditorTableHTMLImportTests.swift | 50 ++++++++ 2 files changed, 160 insertions(+), 1 deletion(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLMedia.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLMedia.swift index fc8f2de..b1009ac 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLMedia.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLMedia.swift @@ -7,7 +7,8 @@ extension NativeEditorMarkdownParser { ) -> [HTMLTableContentMatch] { htmlTableImageContentMatches(from: html, excluding: excludedRanges) + htmlTableMediaElementContentMatches(from: html, excluding: excludedRanges, type: "video") + - htmlTableMediaElementContentMatches(from: html, excluding: excludedRanges, type: "audio") + htmlTableMediaElementContentMatches(from: html, excluding: excludedRanges, type: "audio") + + htmlTableTypedMediaDivContentMatches(from: html, excluding: excludedRanges) } private static func htmlTableImageContentMatches( @@ -50,6 +51,28 @@ extension NativeEditorMarkdownParser { } } + private static func htmlTableTypedMediaDivContentMatches( + from html: String, + excluding excludedRanges: [NSRange] + ) -> [HTMLTableContentMatch] { + htmlRegexMatches(pattern: #"]*)>(.*?)"#, in: html) + .compactMap { match -> HTMLTableContentMatch? in + guard htmlTableRange(match.range, isNestedIn: excludedRanges) == false, + let attributeText = htmlRegexString(match: match, captureIndex: 1, in: html), + let body = htmlRegexString(match: match, captureIndex: 2, in: html) else { + return nil + } + + let attrs = docmostInlineHTMLAttributes(from: "") + guard let type = htmlTableTypedMediaDivType(from: attrs["data-type"]) else { return nil } + + return HTMLTableContentMatch( + range: match.range, + node: htmlTableTypedMediaDivNode(type: type, attrs: attrs, body: body) + ) + } + } + private static func htmlTableImageNode(attrs: [String: String]) -> ProseMirrorNode { var nodeAttrs = [String: ProseMirrorJSONValue]() let source = nonEmptyHTMLTableAttribute(attrs["src"]) @@ -125,6 +148,92 @@ extension NativeEditorMarkdownParser { return ProseMirrorNode(type: type, attrs: nodeAttrs.isEmpty ? nil : nodeAttrs) } + private static func htmlTableTypedMediaDivNode( + type: String, + attrs: [String: String], + body: String + ) -> ProseMirrorNode { + if type == "pdf" { + let iframeAttrs = firstHTMLTagAttributes(in: body, tagName: "iframe") ?? [:] + return htmlTablePDFNode(attrs: attrs, iframeAttrs: iframeAttrs) + } + + let linkAttrs = firstHTMLTagAttributes(in: body, tagName: "a") ?? [:] + return htmlTableAttachmentNode(attrs: attrs, linkAttrs: linkAttrs) + } + + private static func htmlTablePDFNode( + attrs: [String: String], + iframeAttrs: [String: String] + ) -> ProseMirrorNode { + var nodeAttrs = [String: ProseMirrorJSONValue]() + let source = nonEmptyHTMLTableAttribute(attrs["src"]) ?? nonEmptyHTMLTableAttribute(iframeAttrs["src"]) + + if let source { + nodeAttrs["src"] = .string(source) + } + if let name = nonEmptyHTMLTableAttribute(attrs["data-name"]) { + nodeAttrs["name"] = .string(name) + } + if let attachmentID = nonEmptyHTMLTableAttribute(attrs["data-attachment-id"]) ?? + source.flatMap(docmostAttachmentID) { + nodeAttrs["attachmentId"] = .string(attachmentID) + } + if let sizeInBytes = nonEmptyHTMLTableAttribute(attrs["data-size"]).flatMap(Int.init) { + nodeAttrs["size"] = .int(sizeInBytes) + } + let widthValue = nonEmptyHTMLTableAttribute(attrs["width"]) ?? nonEmptyHTMLTableAttribute(iframeAttrs["width"]) + let heightValue = nonEmptyHTMLTableAttribute(attrs["height"]) ?? + nonEmptyHTMLTableAttribute(iframeAttrs["height"]) + if let width = widthValue.flatMap(htmlTableProseMirrorNumberOrString) { + nodeAttrs["width"] = width + } + if let height = heightValue.flatMap(htmlTableProseMirrorNumberOrString) { + nodeAttrs["height"] = height + } + + return ProseMirrorNode(type: "pdf", attrs: nodeAttrs.isEmpty ? nil : nodeAttrs) + } + + private static func htmlTableAttachmentNode( + attrs: [String: String], + linkAttrs: [String: String] + ) -> ProseMirrorNode { + var nodeAttrs = [String: ProseMirrorJSONValue]() + let source = nonEmptyHTMLTableAttribute(attrs["data-attachment-url"]) ?? + nonEmptyHTMLTableAttribute(linkAttrs["href"]) + + if let source { + nodeAttrs["url"] = .string(source) + } + if let name = nonEmptyHTMLTableAttribute(attrs["data-attachment-name"]) { + nodeAttrs["name"] = .string(name) + } + if let mimeType = nonEmptyHTMLTableAttribute(attrs["data-attachment-mime"]) { + nodeAttrs["mime"] = .string(mimeType) + } + if let sizeInBytes = nonEmptyHTMLTableAttribute(attrs["data-attachment-size"]).flatMap(Int.init) { + nodeAttrs["size"] = .int(sizeInBytes) + } + if let attachmentID = nonEmptyHTMLTableAttribute(attrs["data-attachment-id"]) ?? + source.flatMap(docmostAttachmentID) { + nodeAttrs["attachmentId"] = .string(attachmentID) + } + + return ProseMirrorNode(type: "attachment", attrs: nodeAttrs.isEmpty ? nil : nodeAttrs) + } + + private static func htmlTableTypedMediaDivType(from dataType: String?) -> String? { + guard let dataType else { return nil } + if dataType.localizedCaseInsensitiveCompare("pdf") == .orderedSame { + return "pdf" + } + if dataType.localizedCaseInsensitiveCompare("attachment") == .orderedSame { + return "attachment" + } + return nil + } + private static func htmlTableProseMirrorNumberOrString(from value: String) -> ProseMirrorJSONValue? { let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmedValue.isEmpty == false else { return nil } diff --git a/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift b/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift index 7422ea2..ccd0faa 100644 --- a/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift +++ b/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift @@ -356,4 +356,54 @@ struct NativeEditorTableHTMLImportTests { #expect(cellContent[0].attrs?["attachmentId"] == .string("video-1")) #expect(cellContent[1].attrs?["attachmentId"] == .string("audio-1")) } + + @Test func docmostHTMLTableCellPreservesPDFAndAttachmentContent() throws { + let markdown = """ + + + + + + +
+
+ +
+ +
+ """ + + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + guard case .table(let table) = block.kind else { + Issue.record("Expected Docmost HTML table to import as a native table block.") + return + } + + let cell = try #require(table.rows.first?.cells.first) + let preservedContent = try #require(cell.preservedContent) + #expect(cell.plainText.isEmpty) + #expect(preservedContent.map(\.type) == ["pdf", "attachment"]) + #expect(preservedContent[0].attrs?["src"] == .string("/api/files/pdf-1/Spec.pdf")) + #expect(preservedContent[0].attrs?["name"] == .string("Spec.pdf")) + #expect(preservedContent[0].attrs?["attachmentId"] == .string("pdf-1")) + #expect(preservedContent[0].attrs?["size"] == .int(16_384)) + #expect(preservedContent[0].attrs?["width"] == .int(800)) + #expect(preservedContent[0].attrs?["height"] == .int(600)) + #expect(preservedContent[1].attrs?["url"] == .string("/api/files/file-1/Archive.zip")) + #expect(preservedContent[1].attrs?["name"] == .string("Archive.zip")) + #expect(preservedContent[1].attrs?["mime"] == .string("application/zip")) + #expect(preservedContent[1].attrs?["size"] == .int(1_024)) + #expect(preservedContent[1].attrs?["attachmentId"] == .string("file-1")) + + let node = NativeEditorDocument.node(from: block) + let cellContent = try #require(node.content?.first?.content?.first?.content) + #expect(cellContent.map(\.type) == ["pdf", "attachment"]) + #expect(cellContent[0].attrs?["attachmentId"] == .string("pdf-1")) + #expect(cellContent[1].attrs?["attachmentId"] == .string("file-1")) + } } From f49e93e0ddee971aba24a69cb4df6441a6b8dde6 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 19:02:50 +0100 Subject: [PATCH 191/201] fix: preserve rich blocks in html tables --- ...eEditorMarkdownParser+TableHTMLMedia.swift | 102 +++++++++++++++--- ...eEditorTableHTMLRichBlockImportTests.swift | 97 +++++++++++++++++ 2 files changed, 186 insertions(+), 13 deletions(-) create mode 100644 docmostlyTests/Editor/NativeEditorTableHTMLRichBlockImportTests.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLMedia.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLMedia.swift index b1009ac..bf8efa0 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLMedia.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLMedia.swift @@ -5,10 +5,13 @@ extension NativeEditorMarkdownParser { from html: String, excluding excludedRanges: [NSRange] ) -> [HTMLTableContentMatch] { - htmlTableImageContentMatches(from: html, excluding: excludedRanges) + - htmlTableMediaElementContentMatches(from: html, excluding: excludedRanges, type: "video") + - htmlTableMediaElementContentMatches(from: html, excluding: excludedRanges, type: "audio") + - htmlTableTypedMediaDivContentMatches(from: html, excluding: excludedRanges) + let typedDivMatches = htmlTableTypedMediaDivContentMatches(from: html, excluding: excludedRanges) + let typedDivRanges = excludedRanges + typedDivMatches.map(\.range) + + return htmlTableImageContentMatches(from: html, excluding: typedDivRanges) + + htmlTableMediaElementContentMatches(from: html, excluding: typedDivRanges, type: "video") + + htmlTableMediaElementContentMatches(from: html, excluding: typedDivRanges, type: "audio") + + typedDivMatches } private static func htmlTableImageContentMatches( @@ -153,13 +156,20 @@ extension NativeEditorMarkdownParser { attrs: [String: String], body: String ) -> ProseMirrorNode { - if type == "pdf" { + switch type { + case "pdf": let iframeAttrs = firstHTMLTagAttributes(in: body, tagName: "iframe") ?? [:] return htmlTablePDFNode(attrs: attrs, iframeAttrs: iframeAttrs) + case "attachment": + let linkAttrs = firstHTMLTagAttributes(in: body, tagName: "a") ?? [:] + return htmlTableAttachmentNode(attrs: attrs, linkAttrs: linkAttrs) + case "embed": + let linkAttrs = firstHTMLTagAttributes(in: body, tagName: "a") ?? [:] + return htmlTableEmbedNode(attrs: attrs, linkAttrs: linkAttrs) + default: + let imageAttrs = firstHTMLTagAttributes(in: body, tagName: "img") ?? [:] + return htmlTableDiagramNode(type: type, attrs: attrs, imageAttrs: imageAttrs) } - - let linkAttrs = firstHTMLTagAttributes(in: body, tagName: "a") ?? [:] - return htmlTableAttachmentNode(attrs: attrs, linkAttrs: linkAttrs) } private static func htmlTablePDFNode( @@ -223,13 +233,79 @@ extension NativeEditorMarkdownParser { return ProseMirrorNode(type: "attachment", attrs: nodeAttrs.isEmpty ? nil : nodeAttrs) } + private static func htmlTableEmbedNode( + attrs: [String: String], + linkAttrs: [String: String] + ) -> ProseMirrorNode { + var nodeAttrs = [String: ProseMirrorJSONValue]() + let source = nonEmptyHTMLTableAttribute(attrs["data-src"]) ?? nonEmptyHTMLTableAttribute(linkAttrs["href"]) + + if let source { + nodeAttrs["src"] = .string(source) + } + if let provider = nonEmptyHTMLTableAttribute(attrs["data-provider"]) { + nodeAttrs["provider"] = .string(provider) + } + if let alignment = nonEmptyHTMLTableAttribute(attrs["data-align"]) { + nodeAttrs["align"] = .string(alignment) + } + if let width = nonEmptyHTMLTableAttribute(attrs["data-width"]).flatMap(Int.init) { + nodeAttrs["width"] = .int(width) + } + if let height = nonEmptyHTMLTableAttribute(attrs["data-height"]).flatMap(Int.init) { + nodeAttrs["height"] = .int(height) + } + + return ProseMirrorNode(type: "embed", attrs: nodeAttrs.isEmpty ? nil : nodeAttrs) + } + + private static func htmlTableDiagramNode( + type: String, + attrs: [String: String], + imageAttrs: [String: String] + ) -> ProseMirrorNode { + var nodeAttrs = [String: ProseMirrorJSONValue]() + let source = nonEmptyHTMLTableAttribute(attrs["data-src"]) ?? nonEmptyHTMLTableAttribute(imageAttrs["src"]) + + if let source { + nodeAttrs["src"] = .string(source) + } + if let title = nonEmptyHTMLTableAttribute(attrs["data-title"]) { + nodeAttrs["title"] = .string(title) + } + if let alternativeText = nonEmptyHTMLTableAttribute(attrs["data-alt"]) { + nodeAttrs["alt"] = .string(alternativeText) + } + if let attachmentID = nonEmptyHTMLTableAttribute(attrs["data-attachment-id"]) ?? + source.flatMap(docmostAttachmentID) { + nodeAttrs["attachmentId"] = .string(attachmentID) + } + if let sizeInBytes = nonEmptyHTMLTableAttribute(attrs["data-size"]).flatMap(Int.init) { + nodeAttrs["size"] = .int(sizeInBytes) + } + let widthValue = nonEmptyHTMLTableAttribute(attrs["data-width"]) ?? + nonEmptyHTMLTableAttribute(imageAttrs["width"]) + if let width = widthValue.flatMap(htmlTableProseMirrorNumberOrString) { + nodeAttrs["width"] = width + } + if let height = nonEmptyHTMLTableAttribute(attrs["data-height"]).flatMap(htmlTableProseMirrorNumberOrString) { + nodeAttrs["height"] = height + } + if let aspectRatio = nonEmptyHTMLTableAttribute(attrs["data-aspect-ratio"]).flatMap(Double.init) { + nodeAttrs["aspectRatio"] = .double(aspectRatio) + } + if let alignment = nonEmptyHTMLTableAttribute(attrs["data-align"]) { + nodeAttrs["align"] = .string(alignment) + } + + return ProseMirrorNode(type: type, attrs: nodeAttrs.isEmpty ? nil : nodeAttrs) + } + private static func htmlTableTypedMediaDivType(from dataType: String?) -> String? { guard let dataType else { return nil } - if dataType.localizedCaseInsensitiveCompare("pdf") == .orderedSame { - return "pdf" - } - if dataType.localizedCaseInsensitiveCompare("attachment") == .orderedSame { - return "attachment" + for supportedType in ["pdf", "attachment", "embed", "drawio", "excalidraw"] + where dataType.localizedCaseInsensitiveCompare(supportedType) == .orderedSame { + return supportedType } return nil } diff --git a/docmostlyTests/Editor/NativeEditorTableHTMLRichBlockImportTests.swift b/docmostlyTests/Editor/NativeEditorTableHTMLRichBlockImportTests.swift new file mode 100644 index 0000000..12bd7f0 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorTableHTMLRichBlockImportTests.swift @@ -0,0 +1,97 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct TableHTMLRichBlockImportTests { + @Test func docmostHTMLTableCellPreservesEmbedAndDiagramContent() throws { + let markdown = """ + + + + + + +
+
+ Figma +
+
+ Architecture map +
+
+ Wireframe +
+
+ """ + + let imported = try importedSingleTableCell(from: markdown) + let preservedContent = try #require(imported.cell.preservedContent) + + #expect(imported.cell.plainText.isEmpty) + #expect(preservedContent.map(\.type) == ["embed", "drawio", "excalidraw"]) + expectEmbedNode(preservedContent[0]) + expectDrawioNode(preservedContent[1]) + expectExcalidrawNode(preservedContent[2]) + + #expect(imported.nodeContent.map(\.type) == ["embed", "drawio", "excalidraw"]) + #expect(imported.nodeContent[0].attrs?["provider"] == .string("figma")) + #expect(imported.nodeContent[1].attrs?["attachmentId"] == .string("drawio-1")) + #expect(imported.nodeContent[2].attrs?["attachmentId"] == .string("excalidraw-1")) + } +} + +@MainActor +private func importedSingleTableCell( + from markdown: String +) throws -> (cell: NativeEditorTableCell, nodeContent: [ProseMirrorNode]) { + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + guard case .table(let table) = block.kind else { + Issue.record("Expected Docmost HTML table to import as a native table block.") + return (NativeEditorTableCell(plainText: "", isHeader: false, backgroundColorName: nil), []) + } + + let cell = try #require(table.rows.first?.cells.first) + let node = NativeEditorDocument.node(from: block) + let cellContent = try #require(node.content?.first?.content?.first?.content) + return (cell, cellContent) +} + +private func expectEmbedNode(_ node: ProseMirrorNode) { + #expect(node.attrs?["src"] == .string("https://www.figma.com/file/design")) + #expect(node.attrs?["provider"] == .string("figma")) + #expect(node.attrs?["align"] == .string("center")) + #expect(node.attrs?["width"] == .int(640)) + #expect(node.attrs?["height"] == .int(360)) +} + +private func expectDrawioNode(_ node: ProseMirrorNode) { + #expect(node.attrs?["src"] == .string("/api/files/drawio-1/diagram.drawio.svg")) + #expect(node.attrs?["title"] == .string("System map")) + #expect(node.attrs?["alt"] == .string("Architecture map")) + #expect(node.attrs?["width"] == .string("100%")) + #expect(node.attrs?["height"] == .int(480)) + #expect(node.attrs?["size"] == .int(2_048)) + #expect(node.attrs?["aspectRatio"] == .double(1.7777778)) + #expect(node.attrs?["align"] == .string("right")) + #expect(node.attrs?["attachmentId"] == .string("drawio-1")) +} + +private func expectExcalidrawNode(_ node: ProseMirrorNode) { + #expect(node.attrs?["src"] == .string("/api/files/excalidraw-1/diagram.excalidraw.svg")) + #expect(node.attrs?["title"] == .string("Sketch")) + #expect(node.attrs?["alt"] == .string("Wireframe")) + #expect(node.attrs?["width"] == .int(720)) + #expect(node.attrs?["height"] == .int(405)) + #expect(node.attrs?["size"] == .int(4_096)) + #expect(node.attrs?["aspectRatio"] == .double(1.7777778)) + #expect(node.attrs?["align"] == .string("center")) + #expect(node.attrs?["attachmentId"] == .string("excalidraw-1")) +} From 84575e0c912e5924adfb8fe56be58d0b539d5f0a Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 19:17:10 +0100 Subject: [PATCH 192/201] fix: preserve structural blocks in html tables --- ...NativeEditorMarkdownParser+TableHTML.swift | 20 ++- ...orMarkdownParser+TableHTMLStructural.swift | 131 ++++++++++++++++++ ...rTableHTMLStructuralBlockImportTests.swift | 84 +++++++++++ 3 files changed, 229 insertions(+), 6 deletions(-) create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLStructural.swift create mode 100644 docmostlyTests/Editor/NativeEditorTableHTMLStructuralBlockImportTests.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift index 2a1b041..8d177b2 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift @@ -144,10 +144,12 @@ extension NativeEditorMarkdownParser { from html: String, dropsSinglePlainParagraph: Bool = true ) -> [ProseMirrorNode]? { - let calloutMatches = htmlTableCalloutContentMatches(from: html) + let structuralMatches = htmlTableStructuralContentMatches(from: html, excluding: []) + let structuralRanges = structuralMatches.map(\.range) + let calloutMatches = htmlTableCalloutContentMatches(from: html, excluding: structuralRanges) let calloutRanges = calloutMatches.map(\.range) - let listMatches = htmlTableListContentMatches(from: html, excluding: calloutRanges) - let containerRanges = calloutRanges + listMatches.map(\.range) + let listMatches = htmlTableListContentMatches(from: html, excluding: structuralRanges + calloutRanges) + let containerRanges = structuralRanges + calloutRanges + listMatches.map(\.range) let textBlockMatches = htmlRegexMatches(pattern: #"<(p|h[1-6])\b([^>]*)>(.*?)"#, in: html) .compactMap { match -> HTMLTableContentMatch? in guard htmlTableRange(match.range, isNestedIn: containerRanges) == false else { @@ -188,7 +190,9 @@ extension NativeEditorMarkdownParser { ) } let mediaMatches = htmlTableMediaContentMatches(from: html, excluding: containerRanges) - let nodes = (textBlockMatches + codeBlockMatches + mediaMatches + listMatches + calloutMatches) + let nodes = ( + textBlockMatches + codeBlockMatches + mediaMatches + listMatches + calloutMatches + structuralMatches + ) .sorted { $0.range.location < $1.range.location } .map(\.node) @@ -202,10 +206,14 @@ extension NativeEditorMarkdownParser { return nodes } - private static func htmlTableCalloutContentMatches(from html: String) -> [HTMLTableContentMatch] { + private static func htmlTableCalloutContentMatches( + from html: String, + excluding excludedRanges: [NSRange] + ) -> [HTMLTableContentMatch] { htmlRegexMatches(pattern: #"]*)>(.*?)"#, in: html) .compactMap { match -> HTMLTableContentMatch? in - guard let attributeText = htmlRegexString(match: match, captureIndex: 1, in: html), + guard htmlTableRange(match.range, isNestedIn: excludedRanges) == false, + let attributeText = htmlRegexString(match: match, captureIndex: 1, in: html), let body = htmlRegexString(match: match, captureIndex: 2, in: html) else { return nil } diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLStructural.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLStructural.swift new file mode 100644 index 0000000..4419165 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLStructural.swift @@ -0,0 +1,131 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func htmlTableStructuralContentMatches( + from html: String, + excluding excludedRanges: [NSRange] + ) -> [HTMLTableContentMatch] { + htmlRegexMatches(pattern: #"]*)>(.*?)"#, in: html) + .compactMap { match -> HTMLTableContentMatch? in + guard htmlTableRange(match.range, isNestedIn: excludedRanges) == false, + let attributeText = htmlRegexString(match: match, captureIndex: 1, in: html), + let body = htmlRegexString(match: match, captureIndex: 2, in: html) else { + return nil + } + + let attrs = docmostInlineHTMLAttributes(from: "") + guard let type = htmlTableStructuralDivType(from: attrs["data-type"]) else { return nil } + + return HTMLTableContentMatch( + range: match.range, + node: htmlTableStructuralNode(type: type, attrs: attrs, body: body) + ) + } + } + + private static func htmlTableStructuralNode( + type: String, + attrs: [String: String], + body: String + ) -> ProseMirrorNode { + switch type { + case "mathBlock": + return ProseMirrorNode( + type: "mathBlock", + attrs: ["text": .string(htmlTableStructuralText(from: body))] + ) + case "base-embed": + return ProseMirrorNode(type: "base", attrs: [ + "pageId": nonEmptyHTMLTableAttribute(attrs["data-page-id"]).map(ProseMirrorJSONValue.string) ?? .null + ]) + case "transclusionReference": + return htmlTableTransclusionReferenceNode(attrs: attrs) + case "transclusionSource": + return htmlTableTransclusionSourceNode(attrs: attrs, body: body) + default: + return ProseMirrorNode(type: "subpages") + } + } + + private static func htmlTableTransclusionReferenceNode(attrs: [String: String]) -> ProseMirrorNode { + var nodeAttrs = [String: ProseMirrorJSONValue]() + + if let sourcePageID = nonEmptyHTMLTableAttribute(attrs["data-source-page-id"]) { + nodeAttrs["sourcePageId"] = .string(sourcePageID) + } + if let transclusionID = nonEmptyHTMLTableAttribute(attrs["data-transclusion-id"]) { + nodeAttrs["transclusionId"] = .string(transclusionID) + } + + return ProseMirrorNode( + type: "transclusionReference", + attrs: nodeAttrs.isEmpty ? nil : nodeAttrs + ) + } + + private static func htmlTableTransclusionSourceNode( + attrs: [String: String], + body: String + ) -> ProseMirrorNode { + var nodeAttrs = [String: ProseMirrorJSONValue]() + let text = htmlTableStructuralText(from: body) + + if let identifier = nonEmptyHTMLTableAttribute(attrs["data-id"]) { + nodeAttrs["id"] = .string(identifier) + } + + return ProseMirrorNode( + type: "transclusionSource", + attrs: nodeAttrs.isEmpty ? nil : nodeAttrs, + content: [ + ProseMirrorNode( + type: "paragraph", + content: NativeEditorDocument.inlineNodes(from: inlineText(from: text)) + ) + ] + ) + } + + private static func htmlTableStructuralDivType(from dataType: String?) -> String? { + guard let dataType else { return nil } + for supportedType in ["mathBlock", "base-embed", "transclusionReference", "transclusionSource", "subpages"] + where dataType.localizedCaseInsensitiveCompare(supportedType) == .orderedSame { + return supportedType + } + return nil + } + + private static func htmlTableStructuralText(from html: String) -> String { + let lineBreaks = htmlTableStructuralHTMLReplacing(pattern: #""#, in: html, with: "\n") + let withoutOpeningParagraphs = htmlTableStructuralHTMLReplacing( + pattern: #"]*>"#, + in: lineBreaks, + with: "" + ) + let withoutClosingParagraphs = htmlTableStructuralHTMLReplacing( + pattern: #"

"#, + in: withoutOpeningParagraphs, + with: "\n" + ) + let withoutTags = htmlTableStructuralHTMLReplacing( + pattern: #"<[^>]+>"#, + in: withoutClosingParagraphs, + with: "" + ) + + return unescapedInlineHTMLText(withoutTags).trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func htmlTableStructuralHTMLReplacing(pattern: String, in text: String, with replacement: String) + -> String { + guard let expression = try? NSRegularExpression( + pattern: pattern, + options: [.caseInsensitive, .dotMatchesLineSeparators] + ) else { + return text + } + + let range = NSRange(text.startIndex.. + + + +
E = mc^2
+
+
+
+ Shared requirement +
+
+ + + + + """ + + let imported = try importedStructuralTableCell(from: markdown) + let preservedContent = try #require(imported.cell.preservedContent) + + #expect(preservedContent.map(\.type) == [ + "mathBlock", + "base", + "transclusionReference", + "transclusionSource", + "subpages" + ]) + expectMathBlock(preservedContent[0]) + expectBaseBlock(preservedContent[1]) + expectTransclusionReference(preservedContent[2]) + expectTransclusionSource(preservedContent[3]) + #expect(preservedContent[4].attrs == nil) + + #expect(imported.nodeContent.map(\.type) == preservedContent.map(\.type)) + #expect(imported.nodeContent[0].attrs?["text"] == .string("E = mc^2")) + #expect(imported.nodeContent[1].attrs?["pageId"] == .string("base-page-1")) + #expect(imported.nodeContent[2].attrs?["transclusionId"] == .string("transclusion-1")) + #expect(imported.nodeContent[3].attrs?["id"] == .string("source-1")) + } +} + +@MainActor +private func importedStructuralTableCell( + from markdown: String +) throws -> (cell: NativeEditorTableCell, nodeContent: [ProseMirrorNode]) { + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + guard case .table(let table) = block.kind else { + Issue.record("Expected Docmost HTML table to import as a native table block.") + return (NativeEditorTableCell(plainText: "", isHeader: false, backgroundColorName: nil), []) + } + + let cell = try #require(table.rows.first?.cells.first) + let node = NativeEditorDocument.node(from: block) + let cellContent = try #require(node.content?.first?.content?.first?.content) + return (cell, cellContent) +} + +private func expectMathBlock(_ node: ProseMirrorNode) { + #expect(node.attrs?["text"] == .string("E = mc^2")) +} + +private func expectBaseBlock(_ node: ProseMirrorNode) { + #expect(node.attrs?["pageId"] == .string("base-page-1")) +} + +private func expectTransclusionReference(_ node: ProseMirrorNode) { + #expect(node.attrs?["sourcePageId"] == .string("source-page-1")) + #expect(node.attrs?["transclusionId"] == .string("transclusion-1")) +} + +private func expectTransclusionSource(_ node: ProseMirrorNode) { + #expect(node.attrs?["id"] == .string("source-1")) + #expect(node.content?.first?.type == "paragraph") + #expect(node.content?.first?.content?.first?.text == "Shared requirement") +} From 5338a42919bee591cfa424fbda529ecd46838c5e Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 19:34:11 +0100 Subject: [PATCH 193/201] fix: preserve container blocks in html tables --- ...orMarkdownParser+TableHTMLStructural.swift | 242 +++++++++++++++++- ...rTableHTMLStructuralBlockImportTests.swift | 56 ++++ 2 files changed, 296 insertions(+), 2 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLStructural.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLStructural.swift index 4419165..d990198 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLStructural.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLStructural.swift @@ -5,9 +5,11 @@ extension NativeEditorMarkdownParser { from html: String, excluding excludedRanges: [NSRange] ) -> [HTMLTableContentMatch] { - htmlRegexMatches(pattern: #"]*)>(.*?)"#, in: html) + let containerMatches = htmlTableContainerStructuralContentMatches(from: html, excluding: excludedRanges) + let containerRanges = excludedRanges + containerMatches.map(\.range) + let divMatches = htmlRegexMatches(pattern: #"]*)>(.*?)"#, in: html) .compactMap { match -> HTMLTableContentMatch? in - guard htmlTableRange(match.range, isNestedIn: excludedRanges) == false, + guard htmlTableRange(match.range, isNestedIn: containerRanges) == false, let attributeText = htmlRegexString(match: match, captureIndex: 1, in: html), let body = htmlRegexString(match: match, captureIndex: 2, in: html) else { return nil @@ -21,6 +23,53 @@ extension NativeEditorMarkdownParser { node: htmlTableStructuralNode(type: type, attrs: attrs, body: body) ) } + + return containerMatches + divMatches + } + + private static func htmlTableContainerStructuralContentMatches( + from html: String, + excluding excludedRanges: [NSRange] + ) -> [HTMLTableContentMatch] { + htmlTableDetailsContentMatches(from: html, excluding: excludedRanges) + + htmlTableColumnsContentMatches(from: html, excluding: excludedRanges) + } + + private static func htmlTableDetailsContentMatches( + from html: String, + excluding excludedRanges: [NSRange] + ) -> [HTMLTableContentMatch] { + htmlRegexMatches(pattern: #"]*)>(.*?)
"#, in: html) + .compactMap { match -> HTMLTableContentMatch? in + guard htmlTableRange(match.range, isNestedIn: excludedRanges) == false, + let attributeText = htmlRegexString(match: match, captureIndex: 1, in: html), + let body = htmlRegexString(match: match, captureIndex: 2, in: html) else { + return nil + } + + let attrs = docmostInlineHTMLAttributes(from: "") + return HTMLTableContentMatch( + range: match.range, + node: htmlTableDetailsNode(attrs: attrs, body: body) + ) + } + } + + private static func htmlTableColumnsContentMatches( + from html: String, + excluding excludedRanges: [NSRange] + ) -> [HTMLTableContentMatch] { + htmlTableTypedDivContainers(from: html, dataType: "columns") + .compactMap { container -> HTMLTableContentMatch? in + guard htmlTableRange(container.range, isNestedIn: excludedRanges) == false else { + return nil + } + + return HTMLTableContentMatch( + range: container.range, + node: htmlTableColumnsNode(attrs: container.attrs, body: container.body) + ) + } } private static func htmlTableStructuralNode( @@ -47,6 +96,62 @@ extension NativeEditorMarkdownParser { } } + private static func htmlTableDetailsNode(attrs: [String: String], body: String) -> ProseMirrorNode { + let summary = htmlTableDetailsSummary(from: body) + let detailsContent = htmlTableDetailsContent(from: body) + + return ProseMirrorNode( + type: "details", + attrs: ["open": .bool(htmlTableDetailsIsOpen(attrs: attrs))], + content: [ + ProseMirrorNode( + type: "detailsSummary", + content: NativeEditorDocument.inlineNodes(from: inlineText(from: summary)) + ), + ProseMirrorNode( + type: "detailsContent", + content: [ + ProseMirrorNode( + type: "paragraph", + content: NativeEditorDocument.inlineNodes(from: inlineText(from: detailsContent)) + ) + ] + ) + ] + ) + } + + private static func htmlTableColumnsNode(attrs: [String: String], body: String) -> ProseMirrorNode { + let columns = htmlTableTypedDivContainers(from: body, dataType: "column") + return ProseMirrorNode( + type: "columns", + attrs: [ + "layout": .string(nonEmptyHTMLTableAttribute(attrs["data-layout"]) ?? "two_equal"), + "widthMode": .string(nonEmptyHTMLTableAttribute(attrs["data-width-mode"]) ?? "normal") + ], + content: columns.map(htmlTableColumnNode(from:)) + ) + } + + private static func htmlTableColumnNode(from container: HTMLTableDivContainer) -> ProseMirrorNode { + ProseMirrorNode( + type: "column", + attrs: [ + "width": htmlTableStructuralNumber( + from: nonEmptyHTMLTableAttribute(container.attrs["data-width"]) ?? "1" + ) + ], + content: [ + ProseMirrorNode( + type: "paragraph", + content: NativeEditorDocument.inlineNodes( + from: inlineText(from: htmlTableStructuralText(from: container.body)) + ) + ) + ] + ) + } + private static func htmlTableTransclusionReferenceNode(attrs: [String: String]) -> ProseMirrorNode { var nodeAttrs = [String: ProseMirrorJSONValue]() @@ -95,6 +200,41 @@ extension NativeEditorMarkdownParser { return nil } + private static func htmlTableDetailsSummary(from html: String) -> String { + guard let match = htmlRegexMatches(pattern: #"]*>(.*?)"#, in: html).first, + let body = htmlRegexString(match: match, captureIndex: 1, in: html) else { + return "Details" + } + + let summary = htmlTableStructuralText(from: body) + return summary.isEmpty ? "Details" : summary + } + + private static func htmlTableDetailsContent(from html: String) -> String { + guard let detailsContent = htmlTableTypedDivContainers(from: html, dataType: "detailsContent").first else { + return "" + } + + return htmlTableStructuralText(from: detailsContent.body) + } + + private static func htmlTableDetailsIsOpen(attrs: [String: String]) -> Bool { + guard let value = attrs["open"] else { return false } + let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalizedValue == "" || normalizedValue == "open" || normalizedValue == "true" || normalizedValue == "1" + } + + private static func htmlTableStructuralNumber(from value: String) -> ProseMirrorJSONValue { + let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let intValue = Int(trimmedValue) { + return .int(intValue) + } + if let doubleValue = Double(trimmedValue) { + return .double(doubleValue) + } + return .int(1) + } + private static func htmlTableStructuralText(from html: String) -> String { let lineBreaks = htmlTableStructuralHTMLReplacing(pattern: #""#, in: html, with: "\n") let withoutOpeningParagraphs = htmlTableStructuralHTMLReplacing( @@ -116,6 +256,98 @@ extension NativeEditorMarkdownParser { return unescapedInlineHTMLText(withoutTags).trimmingCharacters(in: .whitespacesAndNewlines) } + private static func htmlTableTypedDivContainers(from html: String, dataType: String) -> [HTMLTableDivContainer] { + let tags = htmlRegexMatches(pattern: #"]*>"#, in: html) + var containers: [HTMLTableDivContainer] = [] + + for (index, tagMatch) in tags.enumerated() { + guard let tagText = htmlRegexString(match: tagMatch, captureIndex: 0, in: html), + htmlTableDivTagIsClosing(tagText) == false, + htmlTableDivTagIsSelfClosing(tagText) == false else { + continue + } + + let attrs = docmostInlineHTMLAttributes(from: tagText) + guard attrs["data-type"]?.localizedCaseInsensitiveCompare(dataType) == .orderedSame, + let container = htmlTableDivContainer( + from: html, + tags: tags, + openingTagIndex: index, + attrs: attrs + ) else { + continue + } + + containers.append(container) + } + + return containers + } + + private static func htmlTableDivContainer( + from html: String, + tags: [NSTextCheckingResult], + openingTagIndex: Int, + attrs: [String: String] + ) -> HTMLTableDivContainer? { + let openingTag = tags[openingTagIndex] + var depth = 0 + + for currentTag in tags[openingTagIndex...] { + guard let tagText = htmlRegexString(match: currentTag, captureIndex: 0, in: html) else { + continue + } + + if htmlTableDivTagIsClosing(tagText) { + depth -= 1 + if depth == 0 { + guard let body = htmlTableHTMLBody( + from: html, + openingTag: openingTag, + closingTag: currentTag + ) else { + return nil + } + + return HTMLTableDivContainer( + range: NSRange( + location: openingTag.range.location, + length: NSMaxRange(currentTag.range) - openingTag.range.location + ), + attrs: attrs, + body: body + ) + } + } else if htmlTableDivTagIsSelfClosing(tagText) == false { + depth += 1 + } + } + + return nil + } + + private static func htmlTableHTMLBody( + from html: String, + openingTag: NSTextCheckingResult, + closingTag: NSTextCheckingResult + ) -> String? { + guard let bodyStart = Range(openingTag.range, in: html)?.upperBound, + let bodyEnd = Range(closingTag.range, in: html)?.lowerBound, + bodyStart <= bodyEnd else { + return nil + } + + return String(html[bodyStart.. Bool { + tagText.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix(" Bool { + tagText.trimmingCharacters(in: .whitespacesAndNewlines).hasSuffix("/>") + } + private static func htmlTableStructuralHTMLReplacing(pattern: String, in text: String, with replacement: String) -> String { guard let expression = try? NSRegularExpression( @@ -129,3 +361,9 @@ extension NativeEditorMarkdownParser { return expression.stringByReplacingMatches(in: text, range: range, withTemplate: replacement) } } + +private struct HTMLTableDivContainer { + var range: NSRange + var attrs: [String: String] + var body: String +} diff --git a/docmostlyTests/Editor/NativeEditorTableHTMLStructuralBlockImportTests.swift b/docmostlyTests/Editor/NativeEditorTableHTMLStructuralBlockImportTests.swift index 53111aa..6487dcf 100644 --- a/docmostlyTests/Editor/NativeEditorTableHTMLStructuralBlockImportTests.swift +++ b/docmostlyTests/Editor/NativeEditorTableHTMLStructuralBlockImportTests.swift @@ -46,6 +46,44 @@ struct TableHTMLStructuralImportTests { #expect(imported.nodeContent[2].attrs?["transclusionId"] == .string("transclusion-1")) #expect(imported.nodeContent[3].attrs?["id"] == .string("source-1")) } + + @Test func docmostHTMLTableCellPreservesContainerStructuralBlocks() throws { + let markdown = """ + + + + + + +
+
+ Release notes +
+ Ship native table support +
+
+
+
+ First column +
+
+ Second column +
+
+
+ """ + + let imported = try importedStructuralTableCell(from: markdown) + let preservedContent = try #require(imported.cell.preservedContent) + + #expect(preservedContent.map(\.type) == ["details", "columns"]) + expectDetailsBlock(preservedContent[0]) + expectColumnsBlock(preservedContent[1]) + + #expect(imported.nodeContent.map(\.type) == preservedContent.map(\.type)) + #expect(imported.nodeContent[0].content?.first?.type == "detailsSummary") + #expect(imported.nodeContent[1].content?.map(\.type) == ["column", "column"]) + } } @MainActor @@ -82,3 +120,21 @@ private func expectTransclusionSource(_ node: ProseMirrorNode) { #expect(node.content?.first?.type == "paragraph") #expect(node.content?.first?.content?.first?.text == "Shared requirement") } + +private func expectDetailsBlock(_ node: ProseMirrorNode) { + #expect(node.attrs?["open"] == .bool(true)) + #expect(node.content?.map(\.type) == ["detailsSummary", "detailsContent"]) + #expect(node.content?.first?.content?.first?.text == "Release notes") + #expect(node.content?[1].content?.first?.type == "paragraph") + #expect(node.content?[1].content?.first?.content?.first?.text == "Ship native table support") +} + +private func expectColumnsBlock(_ node: ProseMirrorNode) { + #expect(node.attrs?["layout"] == .string("two_equal")) + #expect(node.attrs?["widthMode"] == .string("fixed")) + #expect(node.content?.count == 2) + #expect(node.content?.first?.attrs?["width"] == .int(1)) + #expect(node.content?.first?.content?.first?.content?.first?.text == "First column") + #expect(node.content?[1].attrs?["width"] == .double(2.5)) + #expect(node.content?[1].content?.first?.content?.first?.text == "Second column") +} From ab8b32ec109d92c7621f367202e09c11037f1388 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 19:46:26 +0100 Subject: [PATCH 194/201] fix: preserve page breaks in html tables --- ...orMarkdownParser+TableHTMLStructural.swift | 11 +++++++++- ...rTableHTMLStructuralBlockImportTests.swift | 21 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLStructural.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLStructural.swift index d990198..f500f44 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLStructural.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLStructural.swift @@ -91,6 +91,8 @@ extension NativeEditorMarkdownParser { return htmlTableTransclusionReferenceNode(attrs: attrs) case "transclusionSource": return htmlTableTransclusionSourceNode(attrs: attrs, body: body) + case "pageBreak": + return ProseMirrorNode(type: "pageBreak") default: return ProseMirrorNode(type: "subpages") } @@ -193,7 +195,14 @@ extension NativeEditorMarkdownParser { private static func htmlTableStructuralDivType(from dataType: String?) -> String? { guard let dataType else { return nil } - for supportedType in ["mathBlock", "base-embed", "transclusionReference", "transclusionSource", "subpages"] + for supportedType in [ + "mathBlock", + "base-embed", + "transclusionReference", + "transclusionSource", + "subpages", + "pageBreak" + ] where dataType.localizedCaseInsensitiveCompare(supportedType) == .orderedSame { return supportedType } diff --git a/docmostlyTests/Editor/NativeEditorTableHTMLStructuralBlockImportTests.swift b/docmostlyTests/Editor/NativeEditorTableHTMLStructuralBlockImportTests.swift index 6487dcf..7f27260 100644 --- a/docmostlyTests/Editor/NativeEditorTableHTMLStructuralBlockImportTests.swift +++ b/docmostlyTests/Editor/NativeEditorTableHTMLStructuralBlockImportTests.swift @@ -84,6 +84,27 @@ struct TableHTMLStructuralImportTests { #expect(imported.nodeContent[0].content?.first?.type == "detailsSummary") #expect(imported.nodeContent[1].content?.map(\.type) == ["column", "column"]) } + + @Test func docmostHTMLTableCellPreservesPageBreakBlocks() throws { + let markdown = """ + + + + + + +
+
+
+ """ + + let imported = try importedStructuralTableCell(from: markdown) + let preservedContent = try #require(imported.cell.preservedContent) + + #expect(preservedContent.map(\.type) == ["pageBreak"]) + #expect(preservedContent.first?.attrs == nil) + #expect(imported.nodeContent.map(\.type) == ["pageBreak"]) + } } @MainActor From f9e78c6d5077ef6f4498b8422fd1388ccbc74bf4 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 20:05:30 +0100 Subject: [PATCH 195/201] fix: preserve structured container html --- ...veEditorMarkdownParser+ContainerHTML.swift | 215 ++++++++++++++---- ...NativeEditorMarkdownParser+TableHTML.swift | 2 +- ...tiveEditorContainerHTMLFidelityTests.swift | 49 ++++ 3 files changed, 218 insertions(+), 48 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+ContainerHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+ContainerHTML.swift index 689c851..d71f7af 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+ContainerHTML.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+ContainerHTML.swift @@ -41,15 +41,16 @@ extension NativeEditorMarkdownParser { let body = htmlContainerBody(in: lines, startingAt: index, tagName: "div") guard let body else { return nil } + let contentNodes = containerContentNodes(from: body.lines) let callout = NativeEditorCalloutBlock( style: sanitizedContainerCalloutStyle(attributes["data-callout-type"] ?? "info"), icon: nonEmptyContainerHTMLAttribute(attributes["data-callout-icon"]), - previewText: containerBodyText(from: body.lines) + previewText: containerPreviewText(from: contentNodes) ) return ( containerBlock( kind: .callout(callout), - rawNode: NativeEditorRichBlockNodeFactory.calloutNode(from: callout) + rawNode: calloutHTMLNode(from: callout, content: contentNodes) ), body.endIndex ) @@ -65,44 +66,28 @@ extension NativeEditorMarkdownParser { return nil } - var summary = "Details" - var contentLines: [String] = [] - var isInDetailsContent = false - var currentIndex = lines.index(after: index) - - while currentIndex < lines.endIndex { - let currentLine = lines[currentIndex] - let trimmedLine = currentLine.trimmingCharacters(in: .whitespacesAndNewlines) - - if containsHTMLClosingTag(in: trimmedLine, tagName: "details") { - let details = NativeEditorDetailsBlock( - summary: summary, - previewText: containerBodyText(from: contentLines), - isOpen: attributes.keys.contains("open") - ) - return ( - containerBlock( - kind: .details(details), - rawNode: NativeEditorRichBlockNodeFactory.detailsNode(from: details) - ), - lines.index(after: currentIndex) - ) - } - - if let parsedSummary = containerSummaryText(from: trimmedLine) { - summary = parsedSummary - } else if isDetailsContentOpeningLine(trimmedLine) { - isInDetailsContent = true - } else if isInDetailsContent, containsHTMLClosingTag(in: trimmedLine, tagName: "div") { - isInDetailsContent = false - } else if isInDetailsContent || trimmedLine.isEmpty == false { - contentLines.append(currentLine) - } - - currentIndex = lines.index(after: currentIndex) + guard let body = htmlContainerBody(in: lines, startingAt: index, tagName: "details") else { + return nil } - return nil + let detailsHTML = body.lines.joined(separator: "\n") + let contentLines = containerDivBody(in: detailsHTML, dataType: "detailsContent") + .map(containerBodyLines(from:)) ?? [] + let contentNodes = containerContentNodes(from: contentLines) + let summary = containerSummaryText(in: body.lines) ?? "Details" + let details = NativeEditorDetailsBlock( + summary: summary, + previewText: containerPreviewText(from: contentNodes), + isOpen: attributes.keys.contains("open") + ) + + return ( + containerBlock( + kind: .details(details), + rawNode: detailsHTMLNode(from: details, content: contentNodes) + ), + body.endIndex + ) } private static func mathBlockHTMLBlock( @@ -169,10 +154,12 @@ extension NativeEditorMarkdownParser { } var bodyLines: [String] = [] + var depth = 1 var currentIndex = lines.index(after: index) while currentIndex < lines.endIndex { let currentLine = lines[currentIndex] - if containsHTMLClosingTag(in: currentLine, tagName: tagName) { + let nextDepth = depth + htmlTagDepthDelta(in: currentLine, tagName: tagName) + if nextDepth <= 0 { if let bodyPrefix = htmlLinePrefixBeforeClosingTag(in: currentLine, tagName: tagName) { bodyLines.append(bodyPrefix) } @@ -180,6 +167,7 @@ extension NativeEditorMarkdownParser { } bodyLines.append(currentLine) + depth = nextDepth currentIndex = lines.index(after: currentIndex) } @@ -224,14 +212,6 @@ extension NativeEditorMarkdownParser { return unescapedInlineHTMLText(String(line[contentStart.. Bool { - guard let attributes = htmlTagAttributes(from: line, tagName: "div") else { - return false - } - - return attributes["data-type"]?.localizedCaseInsensitiveCompare("detailsContent") == .orderedSame - } - private static func containerBodyText(from lines: [String]) -> String { lines.compactMap(containerBodyLineText(from:)) .joined(separator: "\n") @@ -269,6 +249,147 @@ extension NativeEditorMarkdownParser { return unescapedInlineHTMLText(String(line[contentStart.. String? { + lines.lazy + .compactMap { line in + containerSummaryText(from: line.trimmingCharacters(in: .whitespacesAndNewlines)) + } + .first + } + + private static func containerContentNodes(from lines: [String]) -> [ProseMirrorNode] { + let html = lines.joined(separator: "\n") + if let nodes = htmlTablePreservedContent(from: html, dropsSinglePlainParagraph: false) { + return nodes + } + + let text = containerBodyText(from: lines) + guard text.isEmpty == false else { return [] } + return [containerParagraphNode(text)] + } + + private static func containerPreviewText(from nodes: [ProseMirrorNode]) -> String { + nodes.map { NativeEditorDocument.plainText(in: [$0]) } + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { $0.isEmpty == false } + .joined(separator: "\n") + } + + private static func calloutHTMLNode( + from callout: NativeEditorCalloutBlock, + content: [ProseMirrorNode] + ) -> ProseMirrorNode { + var attrs: [String: ProseMirrorJSONValue] = ["type": .string(callout.style)] + if let icon = callout.icon { + attrs["icon"] = .string(icon) + } + + return ProseMirrorNode( + type: "callout", + attrs: attrs, + content: content.isEmpty ? [containerParagraphNode(callout.previewText)] : content + ) + } + + private static func detailsHTMLNode( + from details: NativeEditorDetailsBlock, + content: [ProseMirrorNode] + ) -> ProseMirrorNode { + ProseMirrorNode( + type: "details", + attrs: ["open": .bool(details.isOpen)], + content: [ + ProseMirrorNode( + type: "detailsSummary", + content: NativeEditorDocument.inlineNodes(from: inlineText(from: details.summary)) + ), + ProseMirrorNode( + type: "detailsContent", + content: content.isEmpty ? [containerParagraphNode(details.previewText)] : content + ) + ] + ) + } + + private static func containerParagraphNode(_ text: String) -> ProseMirrorNode { + ProseMirrorNode( + type: "paragraph", + content: NativeEditorDocument.inlineNodes(from: inlineText(from: text)) + ) + } + + private static func containerDivBody(in html: String, dataType: String) -> String? { + let tags = htmlRegexMatches(pattern: #"]*>"#, in: html) + for (index, tag) in tags.enumerated() { + guard let tagText = htmlRegexString(match: tag, captureIndex: 0, in: html), + containerDivTagIsClosing(tagText) == false, + containerDivTagIsSelfClosing(tagText) == false else { + continue + } + + let attrs = docmostInlineHTMLAttributes(from: tagText) + guard attrs["data-type"]?.localizedCaseInsensitiveCompare(dataType) == .orderedSame else { + continue + } + + return containerDivBody(in: html, tags: tags, openingTagIndex: index) + } + + return nil + } + + private static func containerDivBody( + in html: String, + tags: [NSTextCheckingResult], + openingTagIndex: Int + ) -> String? { + let openingTag = tags[openingTagIndex] + var depth = 0 + + for currentTag in tags[openingTagIndex...] { + guard let tagText = htmlRegexString(match: currentTag, captureIndex: 0, in: html) else { + continue + } + + if containerDivTagIsClosing(tagText) { + depth -= 1 + if depth == 0 { + return containerHTMLBody(in: html, openingTag: openingTag, closingTag: currentTag) + } + } else if containerDivTagIsSelfClosing(tagText) == false { + depth += 1 + } + } + + return nil + } + + private static func containerHTMLBody( + in html: String, + openingTag: NSTextCheckingResult, + closingTag: NSTextCheckingResult + ) -> String? { + guard let bodyStart = Range(openingTag.range, in: html)?.upperBound, + let bodyEnd = Range(closingTag.range, in: html)?.lowerBound, + bodyStart <= bodyEnd else { + return nil + } + + return String(html[bodyStart.. [String] { + html.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + } + + private static func containerDivTagIsClosing(_ tagText: String) -> Bool { + tagText.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix(" Bool { + tagText.trimmingCharacters(in: .whitespacesAndNewlines).hasSuffix("/>") + } + private static func containerBlock(kind: NativeEditorBlockKind, rawNode: ProseMirrorNode) -> NativeEditorBlock { NativeEditorBlock( kind: kind, diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift index 8d177b2..0e1badf 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift @@ -140,7 +140,7 @@ extension NativeEditorMarkdownParser { return NativeEditorTextAlignment(rawValue: value.lowercased()) } - private static func htmlTablePreservedContent( + static func htmlTablePreservedContent( from html: String, dropsSinglePlainParagraph: Bool = true ) -> [ProseMirrorNode]? { diff --git a/docmostlyTests/Editor/NativeEditorContainerHTMLFidelityTests.swift b/docmostlyTests/Editor/NativeEditorContainerHTMLFidelityTests.swift index 7157898..30dfbf3 100644 --- a/docmostlyTests/Editor/NativeEditorContainerHTMLFidelityTests.swift +++ b/docmostlyTests/Editor/NativeEditorContainerHTMLFidelityTests.swift @@ -51,6 +51,55 @@ struct NativeEditorContainerHTMLFidelityTests { #expect(blocks[2].rawNode?.attrs?["text"] == .string("E = mc^2")) } + @Test func importedDocmostDetailsPreservesStructuredContentBlocks() throws { + let markdown = """ +
+ Release notes +
+

Ship build

+
+

Confirm smoke test

+
+
+ """ + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + + guard case .details(let details) = block.kind else { + Issue.record("Expected Docmost details HTML to import as a native details block.") + return + } + + let contentNode = try #require(block.rawNode?.content?.first { $0.type == "detailsContent" }) + let childTypes = contentNode.content?.map(\.type) + + #expect(details.summary == "Release notes") + #expect(details.previewText == "Ship build\nConfirm smoke test") + #expect(childTypes == ["paragraph", "pageBreak", "paragraph"]) + } + + @Test func importedDocmostCalloutPreservesStructuredContentBlocks() throws { + let markdown = """ +
+

Check migration plan

+
+

Confirm rollback owner

+
+ """ + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + + guard case .callout(let callout) = block.kind else { + Issue.record("Expected Docmost callout HTML to import as a native callout block.") + return + } + + let childTypes = block.rawNode?.content?.map(\.type) + + #expect(callout.style == "warning") + #expect(callout.icon == "rocket") + #expect(callout.previewText == "Check migration plan\nConfirm rollback owner") + #expect(childTypes == ["paragraph", "pageBreak", "paragraph"]) + } + @Test func exportsNativeCalloutDetailsAsDocmostHTMLAndMathAsFenceMarkdown() { let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") viewModel.document = NativeEditorDocument(blocks: [ From a421079bd4624a6440fd4234b51a4a781b866f7a Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 20:33:38 +0100 Subject: [PATCH 196/201] fix: preserve structured column html --- .../NativeEditorMarkdownParser+Columns.swift | 115 ++++++++++++------ ...veEditorMarkdownParser+ContainerHTML.swift | 8 +- ...orMarkdownParser+TableHTMLStructural.swift | 26 ++-- ...NativeEditorColumnsHTMLFidelityTests.swift | 40 ++++++ ...rTableHTMLStructuralBlockImportTests.swift | 6 +- 5 files changed, 143 insertions(+), 52 deletions(-) create mode 100644 docmostlyTests/Editor/NativeEditorColumnsHTMLFidelityTests.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Columns.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Columns.swift index 9332f26..7f02951 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Columns.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Columns.swift @@ -9,33 +9,38 @@ extension NativeEditorMarkdownParser { return nil } + guard let body = htmlContainerBody(in: lines, startingAt: index, tagName: "div") else { + return nil + } + var columns: [ParsedColumnHTML] = [] - var currentIndex = lines.index(after: index) - - while currentIndex < lines.endIndex { - let line = lines[currentIndex].trimmingCharacters(in: .whitespacesAndNewlines) - if line.localizedCaseInsensitiveCompare("") == .orderedSame { - guard columns.isEmpty == false else { return nil } - let columnsBlock = nativeColumnsBlock(from: columnsAttributes, columns: columns) - return ( - NativeEditorBlock( - kind: .columns(columnsBlock), - text: AttributedString(columnsBlock.previewText), - alignment: .left, - rawNode: NativeEditorRichBlockNodeFactory.columnsNode(from: columnsBlock) - ), - lines.index(after: currentIndex) - ) + var currentIndex = body.lines.startIndex + + while currentIndex < body.lines.endIndex { + let line = body.lines[currentIndex].trimmingCharacters(in: .whitespacesAndNewlines) + if line.isEmpty { + currentIndex = body.lines.index(after: currentIndex) + continue } - guard let column = parsedColumnHTML(in: lines, startingAt: currentIndex) else { + guard let column = parsedColumnHTML(in: body.lines, startingAt: currentIndex) else { return nil } columns.append(column.column) currentIndex = column.endIndex } - return nil + guard columns.isEmpty == false else { return nil } + let columnsBlock = nativeColumnsBlock(from: columnsAttributes, columns: columns) + return ( + NativeEditorBlock( + kind: .columns(columnsBlock), + text: AttributedString(columnsBlock.previewText), + alignment: .left, + rawNode: columnsHTMLNode(from: columnsAttributes, columns: columns) + ), + body.endIndex + ) } static func columnsMarkdown(from columns: NativeEditorColumnsBlock) -> String { @@ -61,6 +66,7 @@ extension NativeEditorMarkdownParser { private struct ParsedColumnHTML { var text: String var width: Double? + var content: [ProseMirrorNode] } private static func parsedColumnHTML( @@ -71,26 +77,19 @@ extension NativeEditorMarkdownParser { return nil } - var bodyLines: [String] = [] - var currentIndex = lines.index(after: index) - - while currentIndex < lines.endIndex { - let line = lines[currentIndex].trimmingCharacters(in: .whitespacesAndNewlines) - if line.localizedCaseInsensitiveCompare("") == .orderedSame { - return ( - ParsedColumnHTML( - text: columnText(from: bodyLines), - width: columnAttributes["data-width"].flatMap(Double.init) - ), - lines.index(after: currentIndex) - ) - } - - bodyLines.append(lines[currentIndex]) - currentIndex = lines.index(after: currentIndex) + guard let body = htmlContainerBody(in: lines, startingAt: index, tagName: "div") else { + return nil } - return nil + let content = containerContentNodes(from: body.lines) + return ( + ParsedColumnHTML( + text: columnText(from: body.lines, content: content), + width: columnAttributes["data-width"].flatMap(Double.init), + content: content + ), + body.endIndex + ) } private static func nativeColumnsBlock( @@ -125,8 +124,13 @@ extension NativeEditorMarkdownParser { return attributes } - private static func columnText(from lines: [String]) -> String { - lines.compactMap(columnTextLine(from:)) + private static func columnText(from lines: [String], content: [ProseMirrorNode]) -> String { + let preservedText = containerPreviewText(from: content) + if preservedText.isEmpty == false { + return preservedText + } + + return lines.compactMap(columnTextLine(from:)) .joined(separator: "\n") .trimmingCharacters(in: .whitespacesAndNewlines) } @@ -155,6 +159,41 @@ extension NativeEditorMarkdownParser { """ } + private static func columnsHTMLNode( + from attributes: [String: String], + columns: [ParsedColumnHTML] + ) -> ProseMirrorNode { + ProseMirrorNode( + type: "columns", + attrs: [ + "layout": .string(nonEmptyAttribute(attributes["data-layout"]) ?? "two_equal"), + "widthMode": .string(nonEmptyAttribute(attributes["data-width-mode"]) ?? "normal") + ], + content: columns.map(columnHTMLNode(from:)) + ) + } + + private static func columnHTMLNode(from column: ParsedColumnHTML) -> ProseMirrorNode { + ProseMirrorNode( + type: "column", + attrs: ["width": proseMirrorNumber(from: column.width ?? 1)], + content: column.content.isEmpty ? [ + ProseMirrorNode( + type: "paragraph", + content: NativeEditorDocument.inlineNodes(from: inlineText(from: column.text)) + ) + ] : column.content + ) + } + + private static func proseMirrorNumber(from value: Double) -> ProseMirrorJSONValue { + if value.rounded() == value, let intValue = Int(exactly: value) { + return .int(intValue) + } + + return .double(value) + } + private static func htmlNumber(_ value: Double) -> String { let text = String(value) return text.hasSuffix(".0") ? String(text.dropLast(2)) : text diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+ContainerHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+ContainerHTML.swift index d71f7af..e2352e2 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+ContainerHTML.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+ContainerHTML.swift @@ -143,7 +143,7 @@ extension NativeEditorMarkdownParser { """ } - private static func htmlContainerBody( + static func htmlContainerBody( in lines: [String], startingAt index: Array.Index, tagName: String @@ -257,7 +257,7 @@ extension NativeEditorMarkdownParser { .first } - private static func containerContentNodes(from lines: [String]) -> [ProseMirrorNode] { + static func containerContentNodes(from lines: [String]) -> [ProseMirrorNode] { let html = lines.joined(separator: "\n") if let nodes = htmlTablePreservedContent(from: html, dropsSinglePlainParagraph: false) { return nodes @@ -268,7 +268,7 @@ extension NativeEditorMarkdownParser { return [containerParagraphNode(text)] } - private static func containerPreviewText(from nodes: [ProseMirrorNode]) -> String { + static func containerPreviewText(from nodes: [ProseMirrorNode]) -> String { nodes.map { NativeEditorDocument.plainText(in: [$0]) } .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { $0.isEmpty == false } @@ -378,7 +378,7 @@ extension NativeEditorMarkdownParser { return String(html[bodyStart.. [String] { + static func containerBodyLines(from html: String) -> [String] { html.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) } diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLStructural.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLStructural.swift index f500f44..0d532c3 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLStructural.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLStructural.swift @@ -136,24 +136,32 @@ extension NativeEditorMarkdownParser { } private static func htmlTableColumnNode(from container: HTMLTableDivContainer) -> ProseMirrorNode { - ProseMirrorNode( + let content = htmlTableColumnContent(from: container.body) + return ProseMirrorNode( type: "column", attrs: [ "width": htmlTableStructuralNumber( from: nonEmptyHTMLTableAttribute(container.attrs["data-width"]) ?? "1" ) ], - content: [ - ProseMirrorNode( - type: "paragraph", - content: NativeEditorDocument.inlineNodes( - from: inlineText(from: htmlTableStructuralText(from: container.body)) - ) - ) - ] + content: content ) } + private static func htmlTableColumnContent(from body: String) -> [ProseMirrorNode] { + let content = containerContentNodes(from: containerBodyLines(from: body)) + if content.isEmpty == false { + return content + } + + return [ + ProseMirrorNode( + type: "paragraph", + content: NativeEditorDocument.inlineNodes(from: inlineText(from: htmlTableStructuralText(from: body))) + ) + ] + } + private static func htmlTableTransclusionReferenceNode(attrs: [String: String]) -> ProseMirrorNode { var nodeAttrs = [String: ProseMirrorJSONValue]() diff --git a/docmostlyTests/Editor/NativeEditorColumnsHTMLFidelityTests.swift b/docmostlyTests/Editor/NativeEditorColumnsHTMLFidelityTests.swift new file mode 100644 index 0000000..33b990d --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorColumnsHTMLFidelityTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorColumnsHTMLFidelityTests { + @Test func importedDocmostColumnsPreserveStructuredColumnContent() throws { + let markdown = """ +
+
+

Plan rollout

+
+

Confirm metrics

+
+
+

Ship notes

+
+
+ """ + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + + guard case .columns(let columns) = block.kind else { + Issue.record("Expected Docmost columns HTML to import as a native columns block.") + return + } + + let columnNodes = try #require(block.rawNode?.content) + + #expect(columns.layout == "two_equal") + #expect(columns.widthMode == "wide") + #expect(columns.previewText == "Plan rollout\nConfirm metrics Ship notes") + #expect(columns.columnTexts == ["Plan rollout\nConfirm metrics", "Ship notes"]) + #expect(columns.columnWidths == [2, 1]) + #expect(columnNodes.map(\.type) == ["column", "column"]) + #expect(columnNodes[0].attrs?["width"] == .int(2)) + #expect(columnNodes[1].attrs?["width"] == .int(1)) + #expect(columnNodes[0].content?.map(\.type) == ["paragraph", "pageBreak", "paragraph"]) + #expect(columnNodes[1].content?.map(\.type) == ["paragraph"]) + } +} diff --git a/docmostlyTests/Editor/NativeEditorTableHTMLStructuralBlockImportTests.swift b/docmostlyTests/Editor/NativeEditorTableHTMLStructuralBlockImportTests.swift index 7f27260..06c20f4 100644 --- a/docmostlyTests/Editor/NativeEditorTableHTMLStructuralBlockImportTests.swift +++ b/docmostlyTests/Editor/NativeEditorTableHTMLStructuralBlockImportTests.swift @@ -61,7 +61,9 @@ struct TableHTMLStructuralImportTests {
- First column +

First column

+
+

First follow-up

Second column @@ -156,6 +158,8 @@ private func expectColumnsBlock(_ node: ProseMirrorNode) { #expect(node.content?.count == 2) #expect(node.content?.first?.attrs?["width"] == .int(1)) #expect(node.content?.first?.content?.first?.content?.first?.text == "First column") + #expect(node.content?.first?.content?.map(\.type) == ["paragraph", "pageBreak", "paragraph"]) + #expect(node.content?.first?.content?[2].content?.first?.text == "First follow-up") #expect(node.content?[1].attrs?["width"] == .double(2.5)) #expect(node.content?[1].content?.first?.content?.first?.text == "Second column") } From 311c9f7143626a585fb2505e5dfe54502def96c5 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 20:48:35 +0100 Subject: [PATCH 197/201] fix: preserve structured column export --- .../NativeEditorMarkdownParser+Columns.swift | 112 ++++++++++++++++++ ...ativeEditorMarkdownParser+RichBlocks.swift | 2 +- ...NativeEditorColumnsHTMLFidelityTests.swift | 18 +++ 3 files changed, 131 insertions(+), 1 deletion(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Columns.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Columns.swift index 7f02951..14d53e2 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Columns.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Columns.swift @@ -63,6 +63,33 @@ extension NativeEditorMarkdownParser { """ } + static func rawColumnsMarkdown(from node: ProseMirrorNode?) -> String? { + guard + let node, + node.type == "columns", + rawColumnsNeedsStructuredMarkdown(node) + else { + return nil + } + + let layout = escapedInlineHTMLAttribute(node.attrs?["layout"]?.stringValue ?? "two_equal") + let widthMode = node.attrs?["widthMode"]?.stringValue ?? "normal" + let widthModeAttribute = widthMode == "normal" + ? "" + : #" data-width-mode="\#(escapedInlineHTMLAttribute(widthMode))""# + let columnMarkup = (node.content ?? []) + .filter { $0.type == "column" } + .map(rawColumnMarkdown(from:)) + .joined(separator: "\n") + guard columnMarkup.isEmpty == false else { return nil } + + return """ +
+ \(columnMarkup) +
+ """ + } + private struct ParsedColumnHTML { var text: String var width: Double? @@ -159,6 +186,78 @@ extension NativeEditorMarkdownParser { """ } + private static func rawColumnsNeedsStructuredMarkdown(_ node: ProseMirrorNode) -> Bool { + (node.content ?? []) + .filter { $0.type == "column" } + .contains { column in + rawColumnNeedsStructuredMarkdown(column) + } + } + + private static func rawColumnNeedsStructuredMarkdown(_ node: ProseMirrorNode) -> Bool { + let content = node.content ?? [] + guard content.count == 1, let paragraph = content.first, paragraph.type == "paragraph" else { + return true + } + + return paragraph.attrs?.isEmpty == false + } + + private static func rawColumnMarkdown(from node: ProseMirrorNode) -> String { + let widthText = htmlNumber(from: node.attrs?["width"]) + let body = (node.content ?? []) + .map(rawColumnContentMarkdown(from:)) + .joined(separator: "\n") + + return """ +
+ \(body) +
+ """ + } + + private static func rawColumnContentMarkdown(from node: ProseMirrorNode) -> String { + switch node.type { + case "paragraph": + "

\(rawInlineHTMLMarkdown(from: node.content ?? []))

" + case "heading": + rawHeadingMarkdown(from: node) + case "pageBreak": + #"
"# + case "horizontalRule": + "
" + case "codeBlock": + rawCodeBlockMarkdown(from: node) + default: + escapedInlineHTMLText(NativeEditorDocument.plainText(in: [node])) + } + } + + private static func rawHeadingMarkdown(from node: ProseMirrorNode) -> String { + let level = min(max(node.attrs?["level"]?.intValue ?? 1, 1), 6) + return "\(rawInlineHTMLMarkdown(from: node.content ?? []))" + } + + private static func rawCodeBlockMarkdown(from node: ProseMirrorNode) -> String { + let language = node.attrs?["language"]?.stringValue + .map { #" class="language-\#(escapedInlineHTMLAttribute($0))""# } ?? "" + return "
\(escapedInlineHTMLText(NativeEditorDocument.plainText(in: [node])))
" + } + + private static func rawInlineHTMLMarkdown(from nodes: [ProseMirrorNode]) -> String { + nodes.map { node in + switch node.type { + case "text": + escapedInlineHTMLText(node.text ?? "") + case "hardBreak": + "
" + default: + escapedInlineHTMLText(NativeEditorDocument.plainText(in: [node])) + } + } + .joined() + } + private static func columnsHTMLNode( from attributes: [String: String], columns: [ParsedColumnHTML] @@ -199,6 +298,19 @@ extension NativeEditorMarkdownParser { return text.hasSuffix(".0") ? String(text.dropLast(2)) : text } + private static func htmlNumber(from value: ProseMirrorJSONValue?) -> String { + switch value { + case .int(let width): + String(width) + case .double(let width): + htmlNumber(width) + case .string(let width): + width.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "1" : width + case .bool, .object, .array, .null, nil: + "1" + } + } + private static func nonEmptyAttribute(_ value: String?) -> String? { guard let value, value.isEmpty == false else { return nil diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift index 20b8493..15c831f 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift @@ -93,7 +93,7 @@ extension NativeEditorMarkdownParser { case .pageBreak: #"
"# case .columns(let columns): - columnsMarkdown(from: columns) + rawColumnsMarkdown(from: block.rawNode) ?? columnsMarkdown(from: columns) default: nil } diff --git a/docmostlyTests/Editor/NativeEditorColumnsHTMLFidelityTests.swift b/docmostlyTests/Editor/NativeEditorColumnsHTMLFidelityTests.swift index 33b990d..bc76899 100644 --- a/docmostlyTests/Editor/NativeEditorColumnsHTMLFidelityTests.swift +++ b/docmostlyTests/Editor/NativeEditorColumnsHTMLFidelityTests.swift @@ -37,4 +37,22 @@ struct NativeEditorColumnsHTMLFidelityTests { #expect(columnNodes[0].content?.map(\.type) == ["paragraph", "pageBreak", "paragraph"]) #expect(columnNodes[1].content?.map(\.type) == ["paragraph"]) } + + @Test func importedDocmostColumnsExportStructuredColumnContent() throws { + let markdown = """ +
+
+

Plan rollout

+
+

Confirm metrics

+
+
+

Ship notes

+
+
+ """ + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + + #expect(NativeEditorMarkdownParser.markdown(from: [block]) == markdown) + } } From a4b5169f6e3a9a58d9454ff65a691ec22619dcad Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 21:09:30 +0100 Subject: [PATCH 198/201] fix: preserve synced block html content --- ...eEditorMarkdownParser+StructuralHTML.swift | 134 +++++++++++++++--- ...orMarkdownParser+TableHTMLStructural.swift | 30 +++- ...iveEditorStructuralHTMLFidelityTests.swift | 21 +++ ...rTableHTMLStructuralBlockImportTests.swift | 29 ++++ 4 files changed, 186 insertions(+), 28 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+StructuralHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+StructuralHTML.swift index 9e05449..73991c4 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+StructuralHTML.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+StructuralHTML.swift @@ -52,7 +52,7 @@ extension NativeEditorMarkdownParser { case .subpages: #"
"# case .transclusionSource(let source): - transclusionSourceHTMLMarkdown(from: source) + rawTransclusionSourceHTMLMarkdown(from: block.rawNode) ?? transclusionSourceHTMLMarkdown(from: source) case .transclusionReference(let reference): transclusionReferenceHTMLMarkdown(from: reference) case .base(let base): @@ -67,30 +67,22 @@ extension NativeEditorMarkdownParser { startingAt index: Array.Index, attributes: [String: String] ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { - var bodyLines: [String] = [] - var currentIndex = lines.index(after: index) - - while currentIndex < lines.endIndex { - let line = lines[currentIndex] - if containsHTMLClosingTag(in: line, tagName: "div") { - let source = NativeEditorTransclusionSourceBlock( - identifier: nonEmptyStructuralHTMLAttribute(attributes["data-id"]), - previewText: structuralHTMLText(from: bodyLines) - ) - return ( - docmostStructuralBlock( - kind: .transclusionSource(source), - rawNode: NativeEditorRichBlockNodeFactory.transclusionSourceNode(from: source) - ), - lines.index(after: currentIndex) - ) - } - - bodyLines.append(line) - currentIndex = lines.index(after: currentIndex) + guard let body = htmlContainerBody(in: lines, startingAt: index, tagName: "div") else { + return nil } - return nil + let contentNodes = containerContentNodes(from: body.lines) + let source = NativeEditorTransclusionSourceBlock( + identifier: nonEmptyStructuralHTMLAttribute(attributes["data-id"]), + previewText: containerPreviewText(from: contentNodes) + ) + return ( + docmostStructuralBlock( + kind: .transclusionSource(source), + rawNode: transclusionSourceHTMLNode(from: source, content: contentNodes) + ), + body.endIndex + ) } private static func transclusionSourceHTMLMarkdown(from source: NativeEditorTransclusionSourceBlock) -> String { @@ -107,6 +99,60 @@ extension NativeEditorMarkdownParser { """ } + private static func rawTransclusionSourceHTMLMarkdown(from node: ProseMirrorNode?) -> String? { + guard + let node, + node.type == "transclusionSource", + rawTransclusionSourceNeedsStructuredMarkdown(node) + else { + return nil + } + + let openingTag = structuralHTMLTag("div", attributes: [ + ("data-type", "transclusionSource"), + ("data-id", node.attrs?["id"]?.stringValue) + ]) + let body = (node.content ?? []) + .map(structuralContentHTMLMarkdown(from:)) + .joined(separator: "\n") + + return """ + \(openingTag) + \(body) +
+ """ + } + + private static func rawTransclusionSourceNeedsStructuredMarkdown(_ node: ProseMirrorNode) -> Bool { + let content = node.content ?? [] + guard content.count == 1, content.first?.type == "paragraph" else { + return content.isEmpty == false + } + + return false + } + + private static func transclusionSourceHTMLNode( + from source: NativeEditorTransclusionSourceBlock, + content: [ProseMirrorNode] + ) -> ProseMirrorNode { + var attrs = [String: ProseMirrorJSONValue]() + if let identifier = source.identifier, identifier.isEmpty == false { + attrs["id"] = .string(identifier) + } + + return ProseMirrorNode( + type: "transclusionSource", + attrs: attrs.isEmpty ? nil : attrs, + content: content.isEmpty ? [ + ProseMirrorNode( + type: "paragraph", + content: NativeEditorDocument.inlineNodes(from: inlineText(from: source.previewText)) + ) + ] : content + ) + } + private static func transclusionReferenceHTMLMarkdown( from reference: NativeEditorTransclusionReferenceBlock ) -> String { @@ -157,6 +203,48 @@ extension NativeEditorMarkdownParser { .trimmingCharacters(in: .whitespacesAndNewlines) } + private static func structuralContentHTMLMarkdown(from node: ProseMirrorNode) -> String { + switch node.type { + case "paragraph": + "

\(structuralInlineHTMLMarkdown(from: node.content ?? []))

" + case "heading": + structuralHeadingHTMLMarkdown(from: node) + case "pageBreak": + #"
"# + case "horizontalRule": + "
" + case "codeBlock": + structuralCodeBlockHTMLMarkdown(from: node) + default: + escapedInlineHTMLText(NativeEditorDocument.plainText(in: [node])) + } + } + + private static func structuralHeadingHTMLMarkdown(from node: ProseMirrorNode) -> String { + let level = min(max(node.attrs?["level"]?.intValue ?? 1, 1), 6) + return "\(structuralInlineHTMLMarkdown(from: node.content ?? []))" + } + + private static func structuralCodeBlockHTMLMarkdown(from node: ProseMirrorNode) -> String { + let language = node.attrs?["language"]?.stringValue + .map { #" class="language-\#(escapedInlineHTMLAttribute($0))""# } ?? "" + return "
\(escapedInlineHTMLText(NativeEditorDocument.plainText(in: [node])))
" + } + + private static func structuralInlineHTMLMarkdown(from nodes: [ProseMirrorNode]) -> String { + nodes.map { node in + switch node.type { + case "text": + escapedInlineHTMLText(node.text ?? "") + case "hardBreak": + "
" + default: + escapedInlineHTMLText(NativeEditorDocument.plainText(in: [node])) + } + } + .joined() + } + private static func structuralHTMLTag(_ name: String, attributes: [(String, String?)]) -> String { let attributeText = attributes.compactMap { key, value -> String? in guard let value = nonEmptyStructuralHTMLAttribute(value) else { return nil } diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLStructural.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLStructural.swift index 0d532c3..e4350b5 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLStructural.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLStructural.swift @@ -32,7 +32,8 @@ extension NativeEditorMarkdownParser { excluding excludedRanges: [NSRange] ) -> [HTMLTableContentMatch] { htmlTableDetailsContentMatches(from: html, excluding: excludedRanges) + - htmlTableColumnsContentMatches(from: html, excluding: excludedRanges) + htmlTableColumnsContentMatches(from: html, excluding: excludedRanges) + + htmlTableTransclusionSourceContentMatches(from: html, excluding: excludedRanges) } private static func htmlTableDetailsContentMatches( @@ -72,6 +73,23 @@ extension NativeEditorMarkdownParser { } } + private static func htmlTableTransclusionSourceContentMatches( + from html: String, + excluding excludedRanges: [NSRange] + ) -> [HTMLTableContentMatch] { + htmlTableTypedDivContainers(from: html, dataType: "transclusionSource") + .compactMap { container -> HTMLTableContentMatch? in + guard htmlTableRange(container.range, isNestedIn: excludedRanges) == false else { + return nil + } + + return HTMLTableContentMatch( + range: container.range, + node: htmlTableTransclusionSourceNode(attrs: container.attrs, body: container.body) + ) + } + } + private static func htmlTableStructuralNode( type: String, attrs: [String: String], @@ -183,7 +201,7 @@ extension NativeEditorMarkdownParser { body: String ) -> ProseMirrorNode { var nodeAttrs = [String: ProseMirrorJSONValue]() - let text = htmlTableStructuralText(from: body) + let content = containerContentNodes(from: containerBodyLines(from: body)) if let identifier = nonEmptyHTMLTableAttribute(attrs["data-id"]) { nodeAttrs["id"] = .string(identifier) @@ -192,12 +210,14 @@ extension NativeEditorMarkdownParser { return ProseMirrorNode( type: "transclusionSource", attrs: nodeAttrs.isEmpty ? nil : nodeAttrs, - content: [ + content: content.isEmpty ? [ ProseMirrorNode( type: "paragraph", - content: NativeEditorDocument.inlineNodes(from: inlineText(from: text)) + content: NativeEditorDocument.inlineNodes( + from: inlineText(from: htmlTableStructuralText(from: body)) + ) ) - ] + ] : content ) } diff --git a/docmostlyTests/Editor/NativeEditorStructuralHTMLFidelityTests.swift b/docmostlyTests/Editor/NativeEditorStructuralHTMLFidelityTests.swift index 3244b28..eea181b 100644 --- a/docmostlyTests/Editor/NativeEditorStructuralHTMLFidelityTests.swift +++ b/docmostlyTests/Editor/NativeEditorStructuralHTMLFidelityTests.swift @@ -45,4 +45,25 @@ struct NativeEditorStructuralHTMLFidelityTests {
""") } + + @Test func importedDocmostTransclusionSourceExportsStructuredContentBlocks() throws { + let markdown = """ +
+

Reusable launch checklist

+
+

Confirm smoke test

+
+ """ + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + + guard case .transclusionSource(let source) = block.kind else { + Issue.record("Expected Docmost transclusion source HTML to import as a native synced block.") + return + } + + #expect(source.identifier == "sync-1") + #expect(source.previewText == "Reusable launch checklist\nConfirm smoke test") + #expect(block.rawNode?.content?.map(\.type) == ["paragraph", "pageBreak", "paragraph"]) + #expect(NativeEditorMarkdownParser.markdown(from: [block]) == markdown) + } } diff --git a/docmostlyTests/Editor/NativeEditorTableHTMLStructuralBlockImportTests.swift b/docmostlyTests/Editor/NativeEditorTableHTMLStructuralBlockImportTests.swift index 06c20f4..d964c08 100644 --- a/docmostlyTests/Editor/NativeEditorTableHTMLStructuralBlockImportTests.swift +++ b/docmostlyTests/Editor/NativeEditorTableHTMLStructuralBlockImportTests.swift @@ -107,6 +107,35 @@ struct TableHTMLStructuralImportTests { #expect(preservedContent.first?.attrs == nil) #expect(imported.nodeContent.map(\.type) == ["pageBreak"]) } + + @Test func docmostHTMLTableCellPreservesTransclusionSourceStructuredContent() throws { + let markdown = """ + + + + + + +
+
+

Shared requirement

+
+

Shared follow-up

+
+
+ """ + + let imported = try importedStructuralTableCell(from: markdown) + let preservedContent = try #require(imported.cell.preservedContent) + let transclusionSource = try #require(preservedContent.first) + + #expect(preservedContent.map(\.type) == ["transclusionSource"]) + #expect(transclusionSource.attrs?["id"] == .string("source-1")) + #expect(transclusionSource.content?.map(\.type) == ["paragraph", "pageBreak", "paragraph"]) + #expect(transclusionSource.content?.first?.content?.first?.text == "Shared requirement") + #expect(transclusionSource.content?[2].content?.first?.text == "Shared follow-up") + #expect(imported.nodeContent.first?.content?.map(\.type) == ["paragraph", "pageBreak", "paragraph"]) + } } @MainActor From d0c6a5f154f5402700a84391ca0e24b42c384466 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 21:24:38 +0100 Subject: [PATCH 199/201] fix: avoid duplicate synced table imports --- .../NativeEditorMarkdownParser+TableHTMLStructural.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLStructural.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLStructural.swift index e4350b5..43ff514 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLStructural.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTMLStructural.swift @@ -9,7 +9,7 @@ extension NativeEditorMarkdownParser { let containerRanges = excludedRanges + containerMatches.map(\.range) let divMatches = htmlRegexMatches(pattern: #"]*)>(.*?)
"#, in: html) .compactMap { match -> HTMLTableContentMatch? in - guard htmlTableRange(match.range, isNestedIn: containerRanges) == false, + guard htmlTableRange(match.range, isContainedIn: containerRanges) == false, let attributeText = htmlRegexString(match: match, captureIndex: 1, in: html), let body = htmlRegexString(match: match, captureIndex: 2, in: html) else { return nil @@ -221,6 +221,12 @@ extension NativeEditorMarkdownParser { ) } + private static func htmlTableRange(_ range: NSRange, isContainedIn ranges: [NSRange]) -> Bool { + ranges.contains { container in + range.location >= container.location && NSMaxRange(range) <= NSMaxRange(container) + } + } + private static func htmlTableStructuralDivType(from dataType: String?) -> String? { guard let dataType else { return nil } for supportedType in [ From c50c87681f31f40181ab8a1f98840755a8529e82 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 21:48:46 +0100 Subject: [PATCH 200/201] fix: preserve youtube embed html --- .../NativeEditorMarkdownParser+Embeds.swift | 4 + ...ativeEditorMarkdownParser+RichBlocks.swift | 2 +- ...veEditorMarkdownParser+YoutubeEmbeds.swift | 178 ++++++++++++++++++ ...NativeEditorYoutubeHTMLFidelityTests.swift | 38 ++++ 4 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+YoutubeEmbeds.swift create mode 100644 docmostlyTests/Editor/NativeEditorYoutubeHTMLFidelityTests.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift index de34203..0784152 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift @@ -7,6 +7,10 @@ extension NativeEditorMarkdownParser { ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { let line = lines[index] + if let youtube = youtubeHTMLBlock(in: lines, startingAt: index) { + return youtube + } + if let iframe = iframeHTMLBlock(in: lines, startingAt: index) { return iframe } diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift index 15c831f..feabdad 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift @@ -102,7 +102,7 @@ extension NativeEditorMarkdownParser { private static func embeddedMarkdownLine(from block: NativeEditorBlock) -> String? { switch block.kind { case .embed(let embed): - embedHTMLMarkdown(from: embed) ?? embedMarkdown(from: embed) + youtubeHTMLMarkdown(from: block.rawNode) ?? embedHTMLMarkdown(from: embed) ?? embedMarkdown(from: embed) case .drawio(let diagram): diagramMarkdown(from: diagram, type: "drawio") case .excalidraw(let diagram): diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+YoutubeEmbeds.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+YoutubeEmbeds.swift new file mode 100644 index 0000000..b545dac --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+YoutubeEmbeds.swift @@ -0,0 +1,178 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func youtubeHTMLBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + guard + let attributes = htmlTagAttributes(from: lines[index], tagName: "div"), + attributes.keys.contains("data-youtube-video") + else { + return nil + } + + var currentIndex = index + var containerDepth = 0 + var iframeLines: [String] = [] + var iframeAttributes: [String: String]? + + while currentIndex < lines.endIndex { + let line = lines[currentIndex].trimmingCharacters(in: .whitespacesAndNewlines) + + if iframeAttributes == nil { + if iframeLines.isEmpty == false { + iframeLines.append(line) + iframeAttributes = youtubeIframeAttributes(from: iframeLines) + } else if let attributes = firstHTMLTagAttributes(in: line, tagName: "iframe") { + iframeAttributes = attributes + } else if line.localizedCaseInsensitiveContains(" String? { + guard + let node, + node.type == "youtube", + let source = node.attrs?["src"]?.stringValue, + let embedSource = youtubeEmbedSource(from: source, start: node.attrs?["start"]?.intValue) + else { + return nil + } + + let frameTag = youtubeHTMLTag("iframe", attributes: [ + ("src", embedSource), + ("width", node.attrs?["width"]?.displayString), + ("height", node.attrs?["height"]?.displayString) + ]) + + return """ +
+ \(frameTag) +
+ """ + } + + private static func youtubeIframeAttributes(from lines: [String]) -> [String: String]? { + let html = lines.joined(separator: " ") + guard html.contains(">") else { return nil } + return firstHTMLTagAttributes(in: html, tagName: "iframe") + } + + private static func youtubeBlock(from iframeAttributes: [String: String]) -> NativeEditorBlock? { + guard + let source = youtubeNonEmptyHTMLAttribute(iframeAttributes["src"]), + let youtube = youtubeSourceAttributes(from: source) + else { + return nil + } + + let width = youtubeNonEmptyHTMLAttribute(iframeAttributes["width"]).flatMap(Int.init) + let height = youtubeNonEmptyHTMLAttribute(iframeAttributes["height"]).flatMap(Int.init) + let embed = NativeEditorEmbedBlock( + source: youtube.source, + provider: "YouTube", + alignment: nil, + width: width.map(String.init), + height: height.map(String.init) + ) + var attrs: [String: ProseMirrorJSONValue] = ["src": .string(youtube.source)] + if let start = youtube.start { + attrs["start"] = .int(start) + } + if let width { + attrs["width"] = .int(width) + } + if let height { + attrs["height"] = .int(height) + } + + return NativeEditorBlock( + kind: .embed(embed), + text: AttributedString(NativeEditorDocument.previewText(for: .embed(embed))), + alignment: .left, + rawNode: ProseMirrorNode(type: "youtube", attrs: attrs) + ) + } + + private static func youtubeSourceAttributes(from source: String) -> (source: String, start: Int?)? { + guard let components = URLComponents(string: source), + let host = components.host?.lowercased() else { + return nil + } + + let normalizedHost = host.hasPrefix("www.") ? String(host.dropFirst("www.".count)) : host + let videoID: String? + if normalizedHost == "youtu.be" { + videoID = youtubeNonEmptyHTMLAttribute(String(components.percentEncodedPath.dropFirst())) + } else if normalizedHost == "youtube.com" || normalizedHost == "youtube-nocookie.com" { + videoID = youtubeVideoID(from: components) + } else { + videoID = nil + } + + guard let videoID else { return nil } + let start = components.queryItems?.first { $0.name == "start" }?.value.flatMap(Int.init) + return ("https://www.youtube.com/watch?v=\(videoID)", start) + } + + private static func youtubeVideoID(from components: URLComponents) -> String? { + if components.percentEncodedPath.hasPrefix("/embed/") { + return youtubeNonEmptyHTMLAttribute(String(components.percentEncodedPath.dropFirst("/embed/".count))) + } + + if components.percentEncodedPath == "/watch" { + return components.queryItems?.first { $0.name == "v" }?.value.flatMap(youtubeNonEmptyHTMLAttribute) + } + + return nil + } + + private static func youtubeEmbedSource(from source: String, start: Int?) -> String? { + guard let youtube = youtubeSourceAttributes(from: source), + let components = URLComponents(string: youtube.source), + let videoID = components.queryItems?.first(where: { $0.name == "v" })?.value, + videoID.isEmpty == false else { + return nil + } + + var embedSource = "https://www.youtube-nocookie.com/embed/\(videoID)" + if let start, start > 0 { + embedSource += "?start=\(start)" + } + return embedSource + } + + private static func youtubeHTMLTag(_ name: String, attributes: [(String, String?)]) -> String { + let attributeText = attributes.compactMap { key, value -> String? in + guard let value = youtubeNonEmptyHTMLAttribute(value) else { return nil } + return #"\#(key)="\#(escapedInlineHTMLAttribute(value))""# + }.joined(separator: " ") + + return attributeText.isEmpty ? "<\(name)>" : "<\(name) \(attributeText)>" + } + + private static func youtubeNonEmptyHTMLAttribute(_ value: String?) -> String? { + guard let value, value.isEmpty == false else { + return nil + } + return value + } +} diff --git a/docmostlyTests/Editor/NativeEditorYoutubeHTMLFidelityTests.swift b/docmostlyTests/Editor/NativeEditorYoutubeHTMLFidelityTests.swift new file mode 100644 index 0000000..36a5553 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorYoutubeHTMLFidelityTests.swift @@ -0,0 +1,38 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorYoutubeHTMLFidelityTests { + @Test func importsDocmostYoutubeHTMLAsNativeEmbedBlock() throws { + let markdown = """ +
+ +
+ """ + let expectedMarkdown = """ +
+ +
+ """ + let blocks = NativeEditorMarkdownParser.blocks(from: markdown) + + try #require(blocks.count == 1) + guard case .embed(let embed) = blocks[0].kind else { + Issue.record("Expected Docmost YouTube HTML to import as a native embed block.") + return + } + + #expect(embed.source == "https://www.youtube.com/watch?v=dQw4w9WgXcQ") + #expect(embed.provider == "YouTube") + #expect(embed.width == "640") + #expect(embed.height == "360") + #expect(blocks[0].rawNode?.type == "youtube") + #expect(blocks[0].rawNode?.attrs?["src"] == .string("https://www.youtube.com/watch?v=dQw4w9WgXcQ")) + #expect(blocks[0].rawNode?.attrs?["start"] == .int(42)) + #expect(blocks[0].rawNode?.attrs?["width"] == .int(640)) + #expect(blocks[0].rawNode?.attrs?["height"] == .int(360)) + #expect(NativeEditorMarkdownParser.markdown(from: blocks) == expectedMarkdown) + } +} From f1cbb8792cd82b7744745317812373793270d977 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Mon, 29 Jun 2026 22:18:08 +0100 Subject: [PATCH 201/201] fix: preserve docmost html links --- ...eEditorMarkdownParser+DocmostAnchors.swift | 110 ++++++++++++++++++ ...iveEditorMarkdownParser+DocmostLinks.swift | 2 + .../NativeEditorLinkFidelityTests.swift | 19 +++ 3 files changed, 131 insertions(+) create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostAnchors.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostAnchors.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostAnchors.swift new file mode 100644 index 0000000..93c99aa --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostAnchors.swift @@ -0,0 +1,110 @@ +import Foundation + +extension NativeEditorMarkdownParser { + private struct DocmostAnchorHTML { + var range: Range + var link: NativeEditorLink + var bodyMarkdown: String + } + + static func consumeDocmostAnchorHTML( + in markdown: inout Substring, + appendingTo result: inout AttributedString + ) -> Bool { + var didConsumeAnchor = false + + while let htmlAnchor = nextDocmostAnchorHTML(in: markdown) { + appendMarkdownText( + String(markdown[.. DocmostAnchorHTML? { + var searchStart = markdown.startIndex + + while searchStart < markdown.endIndex, + let openRange = markdown[searchStart...].range(of: "") else { + return nil + } + + let openingTag = String(markdown[openRange.lowerBound...openTagEnd]) + let attrs = docmostInlineHTMLAttributes(from: openingTag) + guard + let href = nonEmptyHTMLAttribute(attrs["href"]), + let link = NativeEditorDocument.preservedLink( + href: href, + isInternal: docmostBooleanAttribute(attrs["data-internal"]) + ) + else { + searchStart = markdown.index(after: openRange.lowerBound) + continue + } + + let contentStart = markdown.index(after: openTagEnd) + guard let closeRange = markdown[contentStart...].range(of: "", options: .caseInsensitive) else { + return nil + } + + return DocmostAnchorHTML( + range: openRange.lowerBound.. Bool { + index == markdown.endIndex || markdown[index].isWhitespace || markdown[index] == ">" + } + + private static func nonEmptyHTMLAttribute(_ value: String?) -> String? { + let value = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return value.isEmpty ? nil : value + } + + private static func docmostBooleanAttribute(_ value: String?) -> Bool { + guard let value else { return false } + + let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard normalizedValue.isEmpty == false else { return true } + return normalizedValue != "false" && normalizedValue != "0" + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift index 889beeb..2183049 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostLinks.swift @@ -100,6 +100,8 @@ extension NativeEditorMarkdownParser { remaining = remaining[htmlMention.range.upperBound...] } + if consumeDocmostAnchorHTML(in: &remaining, appendingTo: &result) { didAppendAtom = true } + while let link = nextDocmostPageMarkdownLink(in: remaining) { appendMarkdownTextWithBareDocmostPageLinks( String(remaining[..Roadmap today."# + ) + let document = NativeEditorDocument(blocks: blocks) + + let inlineNodes = document.proseMirrorDocument.content.first?.content ?? [] + #expect(inlineNodes.map(\.text) == ["Review ", "Roadmap", " today."]) + + let linkMark = try #require(inlineNodes[1].marks?.first) + #expect(linkMark == ProseMirrorMark( + type: "link", + attrs: [ + "href": .string("/p/roadmap-abc123#shipping"), + "internal": .bool(true) + ] + )) + } + @Test func preservesDocmostRelativeInternalLinksFromProseMirror() throws { let document = try NativeEditorDocument(proseMirrorJSONData: Data(""" {