composer: inline basics only — blocks conversion removed, _underscores_ italicize, opt-out escapes (#890) (#934)
Paul's call after the stage-3 device pass: composing sticks to the basics on every platform. The web composer drops its list/quote/fence input rules and ⌘⇧7/8/9 (edit-loaded structure still behaves; the FEED keeps rendering block markdown from bots, history and hand-typing). iOS blocks died unmerged with #929; the Mac twin was never built. _underscores_ now italicize as you type (never intraword — snake_case stays whole) on web and native, drift-gated like the other rules. And opting out of format-as-you-type now means what it says: typed markers ESCAPE at send (\*x\* renders as literal *x* everywhere) and only the WYSIWYG shortcuts format — pinned byte-for-byte on both serializer twins, including the line-leading '- '/'N.'/'>' guards.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
14 files changed,
+158
−72
changelog/2026-07-14-composer-basics.md+7 −0ios/LChat/Views/ChatView.swift+3 −1ios/LChat/Views/ComposerField.swift+2 −2ios/LChat/Views/NativeChatScreen.swift+3 −1ios/LChatCore/Sources/LChatCore/ComposerAttributes.swift+2 −2ios/LChatCore/Sources/LChatCore/ComposerRules.swift+4 −23ios/LChatCore/Sources/LChatCore/ComposerSerialize.swift+37 −4ios/LChatCore/Tests/LChatCoreTests/ComposerAttributesTests.swift+22 −0ios/LChatCore/Tests/LChatCoreTests/ComposerRulesTests.swift+8 −17ios/LChatMac/MacComposerField.swift+2 −2ios/LChatMac/NativeFeedView.swift+3 −1test/composer.test.mjs+23 −0web/static/composer-md.js+28 −3web/static/composer-pm.js+14 −16
diff --git a/changelog/2026-07-14-composer-basics.md b/changelog/2026-07-14-composer-basics.md| new file mode 100644 |
| index 0000000..a4bbc0d |
| --- /dev/null |
| +++ b/changelog/2026-07-14-composer-basics.md |
| @@ -0,0 +1,7 @@ |
| +🤖 Formatting as you type now sticks to the basics — **bold**, *italics*, |
| +`code` and ~strikethrough~ — and `_underscores_` make italics too. Typing |
| +"- " or "> " no longer converts into lists and quotes while you compose |
| +(those still render beautifully when a bot sends them, and in older |
| +messages). And turning "Format as you type" off now means exactly what it |
| +says: your asterisks and underscores stay ordinary characters for everyone |
| +reading, and only the formatting shortcuts (⌘B, ⌘I and friends) style text. |
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift| index 198b639..7ce5b39 100644 |
| --- a/ios/LChat/Views/ChatView.swift |
| +++ b/ios/LChat/Views/ChatView.swift |
| @@ -555,7 +555,9 @@ struct ChatView: View { |
| private func send() { |
| // The wire carries serialized markdown (== the display text for |
| // unstyled drafts); the bridge stays one plain string either way. |
| - let wire = composerController.serializedMarkdown() |
| + // Opted out of format-as-you-type: typed markers escape, so they |
| + // render as literal characters — only the WYSIWYG formats. |
| + let wire = composerController.serializedMarkdown(escapeLiterals: !formatAsYouType) |
| bridge.sendText(wire.isEmpty ? text : wire) { sent in |
| // Only clear on success: if the socket wasn't ready, keep the text so |
| // nothing is lost and the user can retry. |
diff --git a/ios/LChat/Views/ComposerField.swift b/ios/LChat/Views/ComposerField.swift| index 0f6092a..5d56d45 100644 |
| --- a/ios/LChat/Views/ComposerField.swift |
| +++ b/ios/LChat/Views/ComposerField.swift |
| @@ -190,9 +190,9 @@ final class ComposerFieldController { |
| tv.insertText(text) |
| } |
| - func serializedMarkdown() -> String { |
| + func serializedMarkdown(escapeLiterals: Bool = false) -> String { |
| guard let tv = view else { return "" } |
| - return composerMarkdown(of: tv.attributedText ?? NSAttributedString()) |
| + return composerMarkdown(of: tv.attributedText ?? NSAttributedString(), escapeLiterals: escapeLiterals) |
| } |
| func focus() { |
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift| index 68e013a..98e19d9 100644 |
| --- a/ios/LChat/Views/NativeChatScreen.swift |
| +++ b/ios/LChat/Views/NativeChatScreen.swift |
| @@ -1459,7 +1459,9 @@ struct NativeChatScreen: View { |
| // draft otherwise. Commands and routing above always read `draft` (the |
| // display text), mirroring the web's plain/markdown split. |
| private var wireDraft: String { |
| - let wire = composerController.serializedMarkdown() |
| + // Opted out of format-as-you-type = typed markers are literal and |
| + // must render literally: escape them; only the WYSIWYG formats. |
| + let wire = composerController.serializedMarkdown(escapeLiterals: !formatAsYouType) |
| return wire.isEmpty ? draft : wire |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/ComposerAttributes.swift b/ios/LChatCore/Sources/LChatCore/ComposerAttributes.swift| index 8d05da7..1b657bd 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ComposerAttributes.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ComposerAttributes.swift |
| @@ -61,6 +61,6 @@ public func composerInlineNodes(of a: NSAttributedString) -> [InlineNode] { |
| // The editor's wire form: what sends when the draft was composed rich. |
| // For an unstyled draft this equals the display string byte-for-byte (the |
| // zero-escape guarantee, fuzz-pinned in ComposerSerializeTests). |
| -public func composerMarkdown(of a: NSAttributedString) -> String { |
| - serializeComposer([.paragraph(composerInlineNodes(of: a))]) |
| +public func composerMarkdown(of a: NSAttributedString, escapeLiterals: Bool = false) -> String { |
| + serializeComposer([.paragraph(composerInlineNodes(of: a))], escapeLiterals: escapeLiterals) |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/ComposerRules.swift b/ios/LChatCore/Sources/LChatCore/ComposerRules.swift| index 27467c8..7e1432f 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ComposerRules.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ComposerRules.swift |
| @@ -20,18 +20,14 @@ public struct InlineCompletion: Equatable { |
| public let mark: RuleMark |
| } |
| -public enum BlockTrigger: Equatable { |
| - case bullet |
| - case ordered(start: Int) |
| - case quote |
| - case fence |
| -} |
| - |
| // Order matters: strong before em, so the ** closer never half-fires * (the |
| -// composer-pm.js comment, kept). |
| +// composer-pm.js comment, kept). Underscores italicize too — never intraword |
| +// (the lookbehind keeps snake_case whole). Block conversion was deliberately |
| +// removed (composers stick to the inline basics; blocks stay render-only). |
| private let inlineRules: [(NSRegularExpression, RuleMark)] = [ |
| (try! NSRegularExpression(pattern: "\\*\\*([^*\\s](?:[^*]*[^*\\s])?)\\*\\*$"), .bold), |
| (try! NSRegularExpression(pattern: "(?<!\\*)\\*([^*\\s](?:[^*]*[^*\\s])?)\\*$"), .italic), |
| + (try! NSRegularExpression(pattern: "(?<![\\w_])_([^_\\s](?:[^_]*[^_\\s])?)_$"), .italic), |
| (try! NSRegularExpression(pattern: "`([^`\\s](?:[^`]*[^`\\s])?)`$"), .code), |
| (try! NSRegularExpression(pattern: "(?<!~)~([^~\\s](?:[^~]*[^~\\s])?)~$"), .strike), |
| ] |
| @@ -49,19 +45,4 @@ public func inlineCompletion(lineUpToCaret line: String) -> InlineCompletion? { |
| return nil |
| } |
| -private let bulletRE = try! NSRegularExpression(pattern: "^\\s*([-*])\\s$") |
| -private let orderedRE = try! NSRegularExpression(pattern: "^(\\d+)\\.\\s$") |
| -private let quoteRE = try! NSRegularExpression(pattern: "^\\s*>\\s$") |
| -private let fenceRE = try! NSRegularExpression(pattern: "^```$") |
| -public func blockTrigger(lineUpToCaret line: String) -> BlockTrigger? { |
| - let ns = line as NSString |
| - let full = NSRange(location: 0, length: ns.length) |
| - if bulletRE.firstMatch(in: line, range: full) != nil { return .bullet } |
| - if let m = orderedRE.firstMatch(in: line, range: full) { |
| - return .ordered(start: Int(ns.substring(with: m.range(at: 1))) ?? 1) |
| - } |
| - if quoteRE.firstMatch(in: line, range: full) != nil { return .quote } |
| - if fenceRE.firstMatch(in: line, range: full) != nil { return .fence } |
| - return nil |
| -} |
diff --git a/ios/LChatCore/Sources/LChatCore/ComposerSerialize.swift b/ios/LChatCore/Sources/LChatCore/ComposerSerialize.swift| index 149e6ef..1e9bb1f 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ComposerSerialize.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ComposerSerialize.swift |
| @@ -54,7 +54,36 @@ private func isWhitespace(_ s: String) -> Bool { |
| // MARK: - Inline state machine |
| -private func renderInlines(_ nodes: [InlineNode]) -> String { |
| +// Literal-mode escaping for a plain run (format-as-you-type OFF): marker |
| +// characters everywhere, block-forming syntax at line starts — the member's |
| +// typed characters must RENDER as those characters in every feed. |
| +private func escapeLiteralRun(_ text: String, atLineStart: Bool) -> String { |
| + var out = "" |
| + out.reserveCapacity(text.count) |
| + for ch in text { |
| + if ch == "\\" || ch == "`" || ch == "*" || ch == "_" || ch == "~" { out.append("\\") } |
| + out.append(ch) |
| + } |
| + guard atLineStart else { return out } |
| + // ^(\s*)([->+])(\s) and ^(\s*\d+)\.(\s) — the web's guardLine, hand-rolled. |
| + var chars = Array(out) |
| + var i = 0 |
| + while i < chars.count, chars[i] == " " || chars[i] == "\t" { i += 1 } |
| + if i < chars.count { |
| + if "->+".contains(chars[i]), i + 1 < chars.count, chars[i + 1] == " " || chars[i + 1] == "\t" { |
| + chars.insert("\\", at: i) |
| + } else { |
| + var j = i |
| + while j < chars.count, chars[j].isNumber { j += 1 } |
| + if j > i, j < chars.count, chars[j] == ".", j + 1 < chars.count, chars[j + 1] == " " || chars[j + 1] == "\t" { |
| + chars.insert("\\", at: j) |
| + } |
| + } |
| + } |
| + return String(chars) |
| +} |
| + |
| +private func renderInlines(_ nodes: [InlineNode], escapeLiterals: Bool = false, openedAtLineStart: Bool = true) -> String { |
| // Pass 1 — break-mark filter: a break keeps a mark only if the mark |
| // continues on the next node and that node is non-whitespace text. |
| var filtered: [InlineNode] = [] |
| @@ -144,7 +173,11 @@ private func renderInlines(_ nodes: [InlineNode]) -> String { |
| let desired = marks(of: run.style) |
| closeDownTo(desired) |
| open(desired) |
| - out += escaped(run.text, style: run.style) |
| + if escapeLiterals, run.style.isPlain { |
| + out += escapeLiteralRun(run.text, atLineStart: out.isEmpty ? openedAtLineStart : out.hasSuffix("\n")) |
| + } else { |
| + out += escaped(run.text, style: run.style) |
| + } |
| case .brk(let style): |
| closeDownTo(marks(of: style)) |
| out += "\n" |
| @@ -156,12 +189,12 @@ private func renderInlines(_ nodes: [InlineNode]) -> String { |
| // MARK: - Blocks |
| -public func serializeComposer(_ blocks: [ComposerBlock]) -> String { |
| +public func serializeComposer(_ blocks: [ComposerBlock], escapeLiterals: Bool = false) -> String { |
| var parts: [String] = [] |
| for block in blocks { |
| switch block { |
| case .paragraph(let nodes): |
| - parts.append(renderInlines(nodes)) |
| + parts.append(renderInlines(nodes, escapeLiterals: escapeLiterals)) |
| case .quote(let nodes): |
| // "> " before every emitted line — including empty ones, which |
| // keep the trailing space ("> a\n> \n> b"), matching the web. |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ComposerAttributesTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ComposerAttributesTests.swift| index ea89d34..79c5c29 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/ComposerAttributesTests.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/ComposerAttributesTests.swift |
| @@ -65,3 +65,25 @@ final class ComposerAttributesTests: XCTestCase { |
| XCTAssertNotEqual(ComposerStyleAttr(bold), ComposerStyleAttr(.plain)) |
| } |
| } |
| + |
| +// Opt-out escaping (format-as-you-type OFF): typed markers are literal text |
| +// and must RENDER literally; only WYSIWYG-applied marks keep their markers. |
| +// The cases mirror test/composer.test.mjs's escaped-mode block byte-for-byte. |
| +extension ComposerAttributesTests { |
| + func testEscapedModeEscapesPlainMarkers() { |
| + let a = NSMutableAttributedString(string: "lit *x* _y_ ") |
| + a.append(NSAttributedString(string: "bold", attributes: [.composerStyle: ComposerStyleAttr(InlineStyle(bold: true))])) |
| + XCTAssertEqual(composerMarkdown(of: a), "lit *x* _y_ **bold**") |
| + XCTAssertEqual(composerMarkdown(of: a, escapeLiterals: true), "lit \\*x\\* \\_y\\_ **bold**") |
| + } |
| + |
| + func testEscapedModeGuardsLineLeadingBlocks() { |
| + let a = NSAttributedString(string: "- a\n2. b\n> q") |
| + XCTAssertEqual(composerMarkdown(of: a, escapeLiterals: true), "\\- a\n2\\. b\n\\> q") |
| + } |
| + |
| + func testEscapedModeBackslashAndBacktick() { |
| + let a = NSAttributedString(string: "\\* and `tick`") |
| + XCTAssertEqual(composerMarkdown(of: a, escapeLiterals: true), "\\\\\\* and \\`tick\\`") |
| + } |
| +} |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ComposerRulesTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ComposerRulesTests.swift| index fbd52dc..720520b 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/ComposerRulesTests.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/ComposerRulesTests.swift |
| @@ -26,6 +26,13 @@ final class ComposerRulesTests: XCTestCase { |
| InlineCompletion(replaceRange: 0..<6, content: "code", mark: .code)) |
| XCTAssertEqual(inlineCompletion(lineUpToCaret: "~gone~"), |
| InlineCompletion(replaceRange: 0..<6, content: "gone", mark: .strike)) |
| + XCTAssertEqual(inlineCompletion(lineUpToCaret: "say _it_"), |
| + InlineCompletion(replaceRange: 4..<8, content: "it", mark: .italic)) |
| + } |
| + |
| + func testUnderscoreNeverFiresIntraword() { |
| + XCTAssertNil(inlineCompletion(lineUpToCaret: "snake_case_")) |
| + XCTAssertNil(inlineCompletion(lineUpToCaret: "a_b_")) |
| } |
| func testStrongWinsBeforeEm() { |
| @@ -52,19 +59,6 @@ final class ComposerRulesTests: XCTestCase { |
| InlineCompletion(replaceRange: 3..<8, content: "x", mark: .bold)) |
| } |
| - // MARK: block triggers |
| - |
| - func testBlockTriggers() { |
| - XCTAssertEqual(blockTrigger(lineUpToCaret: "- "), .bullet) |
| - XCTAssertEqual(blockTrigger(lineUpToCaret: "* "), .bullet) |
| - XCTAssertEqual(blockTrigger(lineUpToCaret: "3. "), .ordered(start: 3)) |
| - XCTAssertEqual(blockTrigger(lineUpToCaret: "> "), .quote) |
| - XCTAssertEqual(blockTrigger(lineUpToCaret: "```"), .fence) |
| - XCTAssertNil(blockTrigger(lineUpToCaret: "-x")) |
| - XCTAssertNil(blockTrigger(lineUpToCaret: "1.5 ")) |
| - XCTAssertNil(blockTrigger(lineUpToCaret: "x - ")) |
| - } |
| - |
| // MARK: drift gate against the web rules |
| func testRegexLiteralsMatchComposerPmJs() throws { |
| @@ -74,12 +68,9 @@ final class ComposerRulesTests: XCTestCase { |
| let literals = [ |
| #"/\*\*([^*\s](?:[^*]*[^*\s])?)\*\*$/"#, |
| #"/(?<!\*)\*([^*\s](?:[^*]*[^*\s])?)\*$/"#, |
| + #"/(?<![\w_])_([^_\s](?:[^_]*[^_\s])?)_$/"#, |
| #"/`([^`\s](?:[^`]*[^`\s])?)`$/"#, |
| #"/(?<!~)~([^~\s](?:[^~]*[^~\s])?)~$/"#, |
| - #"/^\s*([-*])\s$/"#, |
| - #"/^(\d+)\.\s$/"#, |
| - #"/^\s*>\s$/"#, |
| - #"/^```$/"#, |
| ] |
| for literal in literals { |
| XCTAssertTrue(js.contains(literal), "composer-pm.js no longer contains \(literal) — mirror the rule change into ComposerRules.swift") |
diff --git a/ios/LChatMac/MacComposerField.swift b/ios/LChatMac/MacComposerField.swift| index e88c1a1..0042db7 100644 |
| --- a/ios/LChatMac/MacComposerField.swift |
| +++ b/ios/LChatMac/MacComposerField.swift |
| @@ -297,9 +297,9 @@ final class MacComposerController { |
| // The wire form of the draft — serialized markdown from the LChatCore |
| // twin (vector-pinned against the web serializer). The binding stays the |
| // display string; this is the getMarkdown to its getText. |
| - func serializedMarkdown() -> String { |
| + func serializedMarkdown(escapeLiterals: Bool = false) -> String { |
| guard let tv = view, let storage = tv.textStorage else { return "" } |
| - return composerMarkdown(of: storage) |
| + return composerMarkdown(of: storage, escapeLiterals: escapeLiterals) |
| } |
| } |
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift| index a704007..5eed7fc 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -1448,7 +1448,9 @@ struct NativeFeedView: View { |
| // draft otherwise. Commands and routing always read `draft` (the display |
| // text), mirroring the web's plain/markdown split. |
| private var wireDraft: String { |
| - let wire = composerController.serializedMarkdown() |
| + // Opted out of format-as-you-type = typed markers are literal and |
| + // must render literally: escape them; only the WYSIWYG formats. |
| + let wire = composerController.serializedMarkdown(escapeLiterals: !formatAsYouType) |
| return wire.isEmpty ? draft : wire |
| } |
diff --git a/test/composer.test.mjs b/test/composer.test.mjs| index 0af3d68..5db79d1 100644 |
| --- a/test/composer.test.mjs |
| +++ b/test/composer.test.mjs |
| @@ -14,6 +14,7 @@ import { |
| } from "../web/static/composer-md.js"; |
| import { richTokens, blockTokens } from "../web/static/format.js"; |
| import { composerToken } from "../web/static/ui.js"; |
| +import { parseMessage } from "../web/static/markdown.js"; |
| let failures = 0; |
| async function test(name, fn) { |
| @@ -209,3 +210,25 @@ if (failures) { |
| process.exit(1); |
| } |
| console.log("\ncomposer: all tests passed"); |
| + |
| +await test("escaped mode: plain markers escape, WYSIWYG marks keep theirs", () => { |
| + // Mirrors ComposerAttributesTests' escaped-mode cases byte-for-byte. |
| + const doc = composerSchema.node("doc", null, [composerSchema.node("paragraph", null, [ |
| + composerSchema.text("lit *x* _y_ "), |
| + composerSchema.text("bold", [composerSchema.marks.strong.create()]), |
| + ])]); |
| + assert.equal(serializeDoc(doc), "lit *x* _y_ **bold**"); |
| + assert.equal(serializeDoc(doc, { escapeLiterals: true }), "lit \\*x\\* \\_y\\_ **bold**"); |
| + const blocks = composerSchema.node("doc", null, [composerSchema.node("paragraph", null, [ |
| + composerSchema.text("- a"), composerSchema.nodes.hard_break.create(), |
| + composerSchema.text("2. b"), composerSchema.nodes.hard_break.create(), |
| + composerSchema.text("> q"), |
| + ])]); |
| + assert.equal(serializeDoc(blocks, { escapeLiterals: true }), "\\- a\n2\\. b\n\\> q"); |
| + const tick = composerSchema.node("doc", null, [composerSchema.node("paragraph", null, [ |
| + composerSchema.text("\\* and `tick`"), |
| + ])]); |
| + assert.equal(serializeDoc(tick, { escapeLiterals: true }), "\\\\\\* and \\`tick\\`"); |
| + // Escaped bytes render as the literal characters in the feed. |
| + assert.deepEqual(parseMessage("\\*x\\* \\_y\\_"), [{ t: "p", children: [{ t: "text", text: "*x* _y_" }] }]); |
| +}); |
diff --git a/web/static/composer-md.js b/web/static/composer-md.js| index b95fa7f..63797de 100644 |
| --- a/web/static/composer-md.js |
| +++ b/web/static/composer-md.js |
| @@ -187,6 +187,21 @@ function backticksFor(node, side) { |
| return result; |
| } |
| +// Literal-mode escaping for a plain run: marker characters everywhere, and |
| +// block-forming syntax at line starts (so a typed "- " or "> " reads back as |
| +// exactly those characters in every feed). `out` is the serializer's |
| +// accumulated output — empty or newline-terminated means this run opens a line. |
| +function escapeLiteralRun(text, out) { |
| + let escaped = text.replace(/[\\`*_~]/g, (c) => "\\" + c); |
| + const atLineStart = out === "" || out.endsWith("\n"); |
| + const guardLine = (line) => |
| + line |
| + .replace(/^(\s*)([->+])(\s)/, "$1\\$2$3") |
| + .replace(/^(\s*\d+)\.(\s)/, "$1\\.$2"); |
| + if (atLineStart) escaped = guardLine(escaped); |
| + return escaped; |
| +} |
| + |
| function safeHref(href) { |
| return /[\s()]/.test(href) ? "<" + href + ">" : href; |
| } |
| @@ -228,7 +243,15 @@ const mdSerializer = new MarkdownSerializer( |
| state.renderContent(node); |
| }, |
| text(state, node) { |
| - state.text(escapeInMarks(node.text, node.marks), false); |
| + // escapeLiterals (format-as-you-type OFF): plain-run marker characters |
| + // are the member's literal text and must RENDER literally — escape |
| + // them. Marked runs keep their generated markers (the WYSIWYG's work) |
| + // and the normal in-mark escaping. |
| + if (state.options.escapeLiterals && node.marks.length === 0) { |
| + state.text(escapeLiteralRun(node.text, state.out), false); |
| + } else { |
| + state.text(escapeInMarks(node.text, node.marks), false); |
| + } |
| }, |
| }, |
| { |
| @@ -249,7 +272,7 @@ const mdSerializer = new MarkdownSerializer( |
| }, |
| ); |
| -export function serializeDoc(doc) { |
| +export function serializeDoc(doc, options = {}) { |
| // tightLists: chat lists are always tight (the feed renders them tight, and |
| // loose serialization would inject \n\n old clients show as blank lines). |
| // The one escape prosemirror-markdown emits outside our handlers is the |
| @@ -257,7 +280,9 @@ export function serializeDoc(doc) { |
| // image syntax). Our zero-escape policy never writes "\!", so stripping it |
| // is unambiguous — and required: it's how an edit-loaded  |
| // round-trips byte-identical (the feed renders images literally anyway). |
| - return mdSerializer.serialize(doc, { tightLists: true }).replace(/\\(!\[)/g, "$1"); |
| + return mdSerializer |
| + .serialize(doc, { tightLists: true, escapeLiterals: !!options.escapeLiterals }) |
| + .replace(/\\(!\[)/g, "$1"); |
| } |
| // --- doc ⇄ plaintext offsets ---------------------------------------------------- |
diff --git a/web/static/composer-pm.js b/web/static/composer-pm.js| index 162016a..d32ae6d 100644 |
| --- a/web/static/composer-pm.js |
| +++ b/web/static/composer-pm.js |
| @@ -20,9 +20,9 @@ |
| import { |
| EditorState, EditorView, TextSelection, |
| history, undo, redo, keymap, baseKeymap, |
| - inputRules, InputRule, wrappingInputRule, textblockTypeInputRule, undoInputRule, |
| - chainCommands, toggleMark, newlineInCode, wrapIn, lift, |
| - wrapInList, splitListItem, liftListItem, |
| + inputRules, InputRule, undoInputRule, |
| + chainCommands, toggleMark, newlineInCode, |
| + splitListItem, |
| } from "./vendor/prosemirror.js"; |
| import { |
| composerSchema as schema, |
| @@ -54,22 +54,19 @@ function markInputRule(regexp, markType) { |
| } |
| function buildInputRules() { |
| + // Inline marks ONLY — block conversion (lists/quotes/fences as you type) |
| + // was deliberately removed after the native attempt showed the input-side |
| + // jank isn't worth it: the composers stick to the basics, and block |
| + // markdown stays a render-side ability (bots, history, hand-typed). |
| return inputRules({ |
| rules: [ |
| // strong before em, so the ** closer never half-fires the * rule |
| markInputRule(/\*\*([^*\s](?:[^*]*[^*\s])?)\*\*$/, schema.marks.strong), |
| markInputRule(/(?<!\*)\*([^*\s](?:[^*]*[^*\s])?)\*$/, schema.marks.em), |
| + // _underscores_ italicize too — never intraword (snake_case stays put) |
| + markInputRule(/(?<![\w_])_([^_\s](?:[^_]*[^_\s])?)_$/, schema.marks.em), |
| markInputRule(/`([^`\s](?:[^`]*[^`\s])?)`$/, schema.marks.code), |
| markInputRule(/(?<!~)~([^~\s](?:[^~]*[^~\s])?)~$/, schema.marks.strike), |
| - wrappingInputRule(/^\s*([-*])\s$/, schema.nodes.bullet_list), |
| - wrappingInputRule( |
| - /^(\d+)\.\s$/, |
| - schema.nodes.ordered_list, |
| - (match) => ({ order: +match[1] }), |
| - (match, node) => node.childCount + node.attrs.order === +match[1], |
| - ), |
| - wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote), |
| - textblockTypeInputRule(/^```$/, schema.nodes.code_block), |
| ], |
| }); |
| } |
| @@ -93,9 +90,6 @@ export function createRichComposer(opts = {}) { |
| "Mod-i": toggleMark(schema.marks.em), |
| "Mod-e": toggleMark(schema.marks.code), |
| "Mod-Shift-x": toggleMark(schema.marks.strike), |
| - "Mod-Shift-7": wrapInList(schema.nodes.ordered_list), |
| - "Mod-Shift-8": wrapInList(schema.nodes.bullet_list), |
| - "Mod-Shift-9": chainCommands(wrapIn(schema.nodes.blockquote), lift), |
| "Mod-z": undo, |
| "Mod-Shift-z": redo, |
| "Mod-y": redo, |
| @@ -248,7 +242,11 @@ export function createRichComposer(opts = {}) { |
| view.focus(); |
| }, |
| getMarkdown() { |
| - return serializeDoc(view.state.doc); |
| + // Opted out of format-as-you-type = typed markers are literal text and |
| + // must RENDER literally: escape them at send, so only WYSIWYG-applied |
| + // marks format. Opted in keeps the zero-escape wire (what you type is |
| + // what sends). |
| + return serializeDoc(view.state.doc, { escapeLiterals: !rulesOn }); |
| }, |
| setMarkdown(v, o = {}) { |
| swapDoc(parseToDoc(v), o); |