diff --git a/src-mdviewer/src/bridge.js b/src-mdviewer/src/bridge.js index 684313c2fa..40dd451eac 100644 --- a/src-mdviewer/src/bridge.js +++ b/src-mdviewer/src/bridge.js @@ -188,10 +188,34 @@ function _withSourceLine(protoFn, tagRegex) { }; } +// Paragraphs that span multiple source lines (e.g. lines wrapped by Phoenix's +// reflow on save) get per-source-line children. +// Without these, the whole

shares a single data-source-line attribute and +// cursor sync always maps to the block's first line — so clicking anywhere in +// a wrapped paragraph snaps CM to the same line, regardless of caret position. +// Each line is re-parsed via marked.parseInline so inline markdown inside the +// span renders correctly. The Phoenix-side wrap step deliberately never splits +// inline atoms across source lines, so each line is independently parseable. +function _renderParagraphWithSourceSpans(token) { + const startLine = token._sourceLine; + if (startLine == null) { + return _proto.paragraph.call(this, token); + } + const sourceLines = (token.raw || "").split("\n").filter(l => l !== ""); + if (sourceLines.length <= 1) { + return _proto.paragraph.call(this, token) + .replace(/^

+ `${marked.parseInline(line)}` + ); + return `

${spans.join(" ")}

`; +} + marked.use({ renderer: { heading: _withSourceLine(_proto.heading, /^,

, etc.) + // to highlight them; skipping those would misalign mdLineIdx for the + // rest of the walk. The standalone overlay variants always carry the + // cursor-sync-br-line or cursor-sync-code-line companion class. if (el.classList.contains("table-row-handles") || el.classList.contains("table-col-handles") || el.classList.contains("table-add-row-btn") || el.classList.contains("table-col-add-btn") || - el.classList.contains("cursor-sync-highlight")) { + el.classList.contains("cursor-sync-br-line") || + el.classList.contains("cursor-sync-code-line")) { continue; } @@ -1840,11 +1847,113 @@ function _updateSourceLineAttrs(contentEl, markdown) { // Single-line or multi-line block: advance to next blank line. // Paragraphs with
(soft line breaks) are a single block — // the data-source-line on the

points to the block's start. + const blockStart = mdLineIdx; mdLineIdx++; while (mdLineIdx < mdLines.length && mdLines[mdLineIdx].trim() !== "") { mdLineIdx++; } + // For paragraphs that span multiple source lines (Phoenix-side + // wrap reflow), keep per-line children in + // sync so cursor sync resolves to the exact line the caret is on, + // not just the block start. + if (tag === "P") { + const blockLines = mdLines.slice(blockStart, mdLineIdx) + .filter(l => l !== ""); + _refreshParagraphSourceSpans(el, blockLines, blockStart + 1); + } + } + } +} + +// Maintain the per-source-line structure inside a multi-line paragraph. +// Cases: +// - Span count matches sourceLines.length and starting line matches → no-op +// (line numbers may have shifted upward, in which case attributes are +// updated in place without touching content or cursor). +// - Single source line and no spans → no-op. +// - Single source line but spans present (paragraph just stopped wrapping) +// → unwrap them, preserving caret position by character offset. +// - Multi source line and span structure mismatched (just started wrapping, +// or wrap line count changed) → rebuild innerHTML from source lines, with +// caret position preserved by character offset. +function _refreshParagraphSourceSpans(p, sourceLines, startLineNum) { + const expectedCount = sourceLines.length; + if (expectedCount === 0) { + return; + } + const existingSpans = Array.from(p.children) + .filter(c => c.tagName === "SPAN" && c.hasAttribute("data-source-line")); + + if (expectedCount === 1) { + if (existingSpans.length === 0) { + return; + } + // Paragraph just stopped wrapping — unwrap the spans into the

. + _rebuildParagraphInner(p, marked.parseInline(sourceLines[0] || "")); + return; + } + + if (existingSpans.length === expectedCount) { + // Span count matches. The user is typing inside one of the spans; do + // not disturb their content. Just update the data-source-line values + // in case the paragraph shifted up/down in the source. + const firstAttr = parseInt(existingSpans[0].getAttribute("data-source-line"), 10); + if (firstAttr !== startLineNum) { + for (let i = 0; i < existingSpans.length; i++) { + existingSpans[i].setAttribute("data-source-line", String(startLineNum + i)); + } } + return; + } + + // Span structure doesn't match expected — rebuild from source. + const newHtml = sourceLines.map((line, i) => + `${marked.parseInline(line)}` + ).join(" "); + _rebuildParagraphInner(p, newHtml); +} + +// Replace a paragraph's innerHTML while preserving the user's caret position +// by character offset within the block. Used when wrap state changes mid-edit +// (paragraph starts/stops wrapping, or wrap line count changes). +function _rebuildParagraphInner(p, newInnerHtml) { + if (p.innerHTML === newInnerHtml) { + return; + } + const sel = window.getSelection(); + let savedOffset = null; + if (sel && sel.rangeCount) { + const range = sel.getRangeAt(0); + if (p.contains(range.startContainer)) { + const pre = document.createRange(); + pre.setStart(p, 0); + pre.setEnd(range.startContainer, range.startOffset); + savedOffset = pre.toString().length; + } + } + p.innerHTML = newInnerHtml; + if (savedOffset !== null && sel) { + const walker = document.createTreeWalker(p, NodeFilter.SHOW_TEXT, null); + let remaining = savedOffset; + let node = walker.nextNode(); + while (node) { + if (remaining <= node.textContent.length) { + const range = document.createRange(); + range.setStart(node, remaining); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + return; + } + remaining -= node.textContent.length; + node = walker.nextNode(); + } + // Fallback: place cursor at end of paragraph + const range = document.createRange(); + range.selectNodeContents(p); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); } } diff --git a/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js b/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js index 97902ade7d..4b52c86388 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js @@ -30,8 +30,17 @@ define(function (require, exports, module) { Commands = require("command/Commands"), KeyBindingManager = require("command/KeyBindingManager"), Metrics = require("utils/Metrics"), + PreferencesManager = require("preferences/PreferencesManager"), + EditorOptionHandlers = require("editor/EditorOptionHandlers"), + markdownLineWrap = require("./markdown-line-wrap"), utils = require("./utils"); + const PREF_MD_WRAP_EDITED_LINES = "mdViewerWrapEditedLines"; + PreferencesManager.definePreference(PREF_MD_WRAP_EDITED_LINES, "boolean", true, { + description: "When editing markdown in the live preview, wrap edited lines " + + "to the editor's max-line-length guide. Other lines stay byte-identical." + }); + // Commands whose shortcuts, when forwarded from the md viewer iframe, // open a parent-side UI that needs to keep keyboard focus. The iframe's // 100ms auto-refocus must skip these shortcuts — otherwise it yanks @@ -620,6 +629,21 @@ define(function (require, exports, module) { return; } + // Reflow only the edited long lines back to the editor's max-line-length + // guide. Turndown emits each block as a single physical line; without + // this step a long paragraph stays on one line until the user runs the + // full beautifier. Unchanged lines are guaranteed byte-identical, so + // git diffs stay scoped to the actual edit. + if (PreferencesManager.get(PREF_MD_WRAP_EDITED_LINES)) { + const printWidth = EditorOptionHandlers.getMaxLineLength(); + if (printWidth && printWidth >= 20) { + newText = markdownLineWrap.wrapEditedLines(oldText, newText, printWidth); + if (oldText === newText) { + return; + } + } + } + // Find first differing character let prefixLen = 0; const minLen = Math.min(oldText.length, newText.length); diff --git a/src/extensionsIntegrated/Phoenix-live-preview/markdown-line-wrap.js b/src/extensionsIntegrated/Phoenix-live-preview/markdown-line-wrap.js new file mode 100644 index 0000000000..5575160765 --- /dev/null +++ b/src/extensionsIntegrated/Phoenix-live-preview/markdown-line-wrap.js @@ -0,0 +1,419 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +// Line-scoped markdown reflow. Used by MarkdownSync to wrap only the lines +// the user actually edited in the md viewer, leaving everything else +// byte-identical. Turndown emits each block as one logical line regardless +// of width; this module reintroduces wrapping per line, indent-aware, so +// the editor's max-line-length guide is honored without running a full +// markdown formatter on save. +// +// Pure function module — no Phoenix dependencies. Wrap algorithm is +// deliberately conservative: when in doubt, do not touch the line. + +define(function (require, exports, module) { + + // Lines matching any of these patterns are passed through unchanged. + // The renderer treats them as structural and wrapping would corrupt + // them (table cells, link reference definitions, hr, setext underlines). + const RE_HR = /^[ ]{0,3}([-*_])[ \t]*(?:\1[ \t]*){2,}$/; + const RE_ATX_HEADING = /^[ ]{0,3}#{1,6}(\s|$)/; + const RE_SETEXT_UNDERLINE = /^[ ]{0,3}(=+|-+)\s*$/; + const RE_LINK_REF_DEF = /^[ ]{0,3}\[[^\]]+\]:\s/; + const RE_TABLE_LINE = /^\s*\|.*\|\s*$/; + const RE_FENCE_OPEN = /^(\s*)(`{3,}|~{3,})/; + const RE_HTML_BLOCK_OPEN = /^[ ]{0,3}<(?:[a-zA-Z][a-zA-Z0-9-]*|!--)/; + + // Inline atoms we must not split: image-only links, image links, inline + // links, inline code, inline HTML tags. Greedy alternation, in priority + // order — the image-only-link pattern must precede the plain link pattern + // so a nested badge-row like [![a](u) ![b](u)](url) stays a single atom. + const RE_INLINE_ATOM = new RegExp([ + "\\[(?:!\\[[^\\]\\n]*\\]\\([^)\\n]*\\)\\s*)+\\]\\([^)\\n]*\\)", // [![alt](src) ![alt](src) ...](href) + "!\\[[^\\]\\n]*\\]\\([^)\\n]*\\)", // image: ![alt](src) + "\\[[^\\]\\n]*\\]\\([^)\\n]*\\)", // link: [text](url) + "`+[^`\\n]+`+", // inline code + "<[a-zA-Z/][^>\\n]*>" // inline HTML tag + ].join("|"), "g"); + + // Detect the line-leading construct that determines indent context. + // "- foo" -> { indent: "", marker: "- ", contIndent: " " } + // " - foo" -> { indent: " ", marker: "- ", contIndent: " " } + // "1. foo" -> { indent: "", marker: "1. ", contIndent: " " } + // "> foo" -> { indent: "", marker: "> ", contIndent: "> " } + // " foo" -> { indent: " ", marker: "", contIndent: " " } + // "foo" -> { indent: "", marker: "", contIndent: "" } + function _detectLeading(line) { + const m = line.match(/^([ \t]*)(?:([-*+])[ \t]+|(\d+)[.)][ \t]+|(>[ \t]?))?/); + const indent = m[1] || ""; + if (m[2]) { + const marker = m[2] + " "; + return { + indent, + marker, + contIndent: indent + " ".repeat(marker.length), + contentStart: m[0].length + }; + } + if (m[3]) { + const marker = m[3] + ". "; + return { + indent, + marker, + contIndent: indent + " ".repeat(marker.length), + contentStart: m[0].length + }; + } + if (m[4]) { + // Blockquote — continuation must repeat the "> " marker so the + // renderer keeps the next line inside the quote. + return { + indent, + marker: m[4], + contIndent: indent + "> ", + contentStart: m[0].length + }; + } + return { + indent, + marker: "", + contIndent: indent, + contentStart: indent.length + }; + } + + // Split text into atoms: inline markdown constructs stay whole, the + // rest is whitespace-separated tokens. Inter-token whitespace is collapsed + // to a single space at join time. + function _tokenize(text) { + const tokens = []; + let lastIdx = 0; + let m; + RE_INLINE_ATOM.lastIndex = 0; + while ((m = RE_INLINE_ATOM.exec(text)) !== null) { + // Words before this atom + const pre = text.substring(lastIdx, m.index); + for (const w of pre.split(/\s+/)) { + if (w) { tokens.push(w); } + } + tokens.push(m[0]); + lastIdx = m.index + m[0].length; + } + // Trailing words + const tail = text.substring(lastIdx); + for (const w of tail.split(/\s+/)) { + if (w) { tokens.push(w); } + } + return tokens; + } + + // Greedy packer: fill each line up to `width`, never splitting a token. + // A single token longer than `width` (e.g. a long URL) gets its own line + // even though it overflows — splitting it would change semantics. + function _packTokens(tokens, width) { + const lines = []; + let cur = ""; + for (const tok of tokens) { + if (cur === "") { + cur = tok; + } else if (cur.length + 1 + tok.length <= width) { + cur += " " + tok; + } else { + lines.push(cur); + cur = tok; + } + } + if (cur) { lines.push(cur); } + return lines; + } + + // Balanced packer: distribute tokens so the resulting lines are roughly + // equal length while still respecting `width` as a hard maximum. Critical + // for cursor-sync alignment in the markdown viewer: with greedy packing, + // the last line is a tiny remainder and the first line absorbs ~all the + // content, so the per-source-line spans in the iframe become wildly + // uneven and visual line N rarely matches source line N. Balancing makes + // each span cover a comparable visual area in the rendered paragraph. + // + // Strategy: greedy first to learn the minimum line count N. Then binary + // search for the smallest width that still produces exactly N lines — + // that width gives the most even distribution (each line fills as much + // as it can without spilling into an extra line). + function _balancedPack(tokens, width) { + if (tokens.length <= 1) { + return _packTokens(tokens, width); + } + const greedy = _packTokens(tokens, width); + if (greedy.length <= 1) { + return greedy; + } + const N = greedy.length; + let lo = 0; + for (const t of tokens) { + if (t.length > lo) { lo = t.length; } + } + let hi = width; + let best = greedy; + while (lo <= hi) { + // Midpoint of two non-negative ints; >>1 is faster than + // Math.floor and the values stay well under 2^31. + // eslint-disable-next-line no-bitwise + const mid = (lo + hi) >> 1; + const trial = _packTokens(tokens, mid); + if (trial.length <= N) { + best = trial; + hi = mid - 1; + } else { + lo = mid + 1; + } + } + return best; + } + + // Wrap one logical line to printWidth, respecting indent context. + // Returns an array of physical lines (the input line replaced by these). + function _wrapLine(line, printWidth) { + const ctx = _detectLeading(line); + const content = line.substring(ctx.contentStart); + if (!content.trim()) { + return [line]; + } + const tokens = _tokenize(content); + if (tokens.length <= 1) { + // Nothing to break on + return [line]; + } + const firstWidth = printWidth - (ctx.indent.length + ctx.marker.length); + const restWidth = printWidth - ctx.contIndent.length; + if (firstWidth <= 0 || restWidth <= 0) { + return [line]; + } + // For a single logical line we balance across ALL output lines as one + // unit (first + rest share the same width, since contIndent matches + // indent + marker width for paragraph/list contexts in practice). + // If first-line and continuation widths differ significantly we fall + // back to greedy on the rest portion. + if (firstWidth === restWidth) { + const allLines = _balancedPack(tokens, firstWidth); + if (allLines.length === 0) { + return [line]; + } + const out = [ctx.indent + ctx.marker + allLines[0]]; + for (let i = 1; i < allLines.length; i++) { + out.push(ctx.contIndent + allLines[i]); + } + return out; + } + // Asymmetric first/continuation widths (e.g. ordered list "12. " marker + // wider than continuation indent). Pack the first line greedily, then + // balance the remainder. + const firstLineTokens = []; + const restTokens = []; + let used = 0; + let consumed = 0; + for (const tok of tokens) { + const extra = firstLineTokens.length === 0 ? tok.length : tok.length + 1; + if (used + extra <= firstWidth) { + firstLineTokens.push(tok); + used += extra; + consumed++; + } else { + break; + } + } + for (let i = consumed; i < tokens.length; i++) { + restTokens.push(tokens[i]); + } + if (firstLineTokens.length === 0) { + firstLineTokens.push(tokens[0]); + for (let i = 1; i < tokens.length; i++) { restTokens.push(tokens[i]); } + } + const out = [ctx.indent + ctx.marker + firstLineTokens.join(" ")]; + if (restTokens.length === 0) { + return out; + } + const restLines = _balancedPack(restTokens, restWidth); + for (const rl of restLines) { + out.push(ctx.contIndent + rl); + } + return out; + } + + // True iff the line, with its surrounding context, is a candidate for + // wrapping. Lines inside fenced code, tables, HTML blocks etc. are not. + function _isWrappable(line, state) { + if (state.inFence || state.inHtmlBlock || state.inFrontmatter) { + return false; + } + if (!line.trim()) { + return false; + } + if (RE_HR.test(line) || RE_ATX_HEADING.test(line) || + RE_SETEXT_UNDERLINE.test(line) || RE_LINK_REF_DEF.test(line) || + RE_TABLE_LINE.test(line)) { + return false; + } + return true; + } + + // Walk lines tracking enter/exit of fenced code, HTML blocks, frontmatter. + // Caller uses the per-line state to gate wrapping decisions. + function _scanState(lines) { + const state = new Array(lines.length); + let inFence = false; + let fenceChar = ""; + let fenceLen = 0; + let inHtmlBlock = false; + let inFrontmatter = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (i === 0 && line.trim() === "---") { + inFrontmatter = true; + state[i] = { inFence, inHtmlBlock, inFrontmatter }; + continue; + } + if (inFrontmatter) { + state[i] = { inFence, inHtmlBlock, inFrontmatter }; + if (line.trim() === "---" || line.trim() === "...") { + inFrontmatter = false; + } + continue; + } + if (inFence) { + state[i] = { inFence, inHtmlBlock, inFrontmatter }; + const close = new RegExp("^\\s*" + fenceChar + "{" + fenceLen + ",}\\s*$"); + if (close.test(line)) { + inFence = false; + fenceChar = ""; + fenceLen = 0; + } + continue; + } + const fm = line.match(RE_FENCE_OPEN); + if (fm) { + inFence = true; + fenceChar = fm[2][0]; + fenceLen = fm[2].length; + state[i] = { inFence: true, inHtmlBlock, inFrontmatter }; + continue; + } + if (inHtmlBlock) { + state[i] = { inFence, inHtmlBlock, inFrontmatter }; + if (!line.trim()) { + inHtmlBlock = false; + } + continue; + } + if (RE_HTML_BLOCK_OPEN.test(line)) { + inHtmlBlock = true; + state[i] = { inFence, inHtmlBlock: true, inFrontmatter }; + continue; + } + state[i] = { inFence, inHtmlBlock, inFrontmatter }; + } + return state; + } + + /** + * Wrap lines in `newText` that differ from `oldText` and exceed `printWidth`. + * Lines that didn't change stay byte-identical. Lines inside fenced code, + * tables, HTML blocks, frontmatter, and other structural constructs are + * never wrapped. + * + * @param {string} oldText - Previous content (CM document text). + * @param {string} newText - New content from Turndown roundtrip. + * @param {number} printWidth - Max line length (e.g. editor guide). + * @returns {string} Possibly modified `newText` with edited long lines wrapped. + */ + function wrapEditedLines(oldText, newText, printWidth) { + if (typeof printWidth !== "number" || printWidth < 20) { + return newText; + } + if (oldText === newText) { + return newText; + } + const oldLines = oldText.split("\n"); + const newLines = newText.split("\n"); + + // Line-range diff using prefix/suffix scan — mirrors _applyDiffToEditor's + // character-level approach but at line granularity. Lines outside this + // range are guaranteed identical and stay untouched. + let prefix = 0; + const minLen = Math.min(oldLines.length, newLines.length); + while (prefix < minLen && oldLines[prefix] === newLines[prefix]) { + prefix++; + } + let oldSuf = oldLines.length; + let newSuf = newLines.length; + while (oldSuf > prefix && newSuf > prefix && + oldLines[oldSuf - 1] === newLines[newSuf - 1]) { + oldSuf--; + newSuf--; + } + + if (prefix === newSuf) { + // Only deletions — nothing to wrap. + return newText; + } + + // Fast path: if no changed line exceeds printWidth, skip the regex-heavy + // state scan and the result rebuild entirely. This is the common case + // for every keystroke that doesn't push a line past the guide. + let anyOverflow = false; + for (let i = prefix; i < newSuf; i++) { + if (newLines[i].length > printWidth) { + anyOverflow = true; + break; + } + } + if (!anyOverflow) { + return newText; + } + + const state = _scanState(newLines); + const result = []; + for (let i = 0; i < prefix; i++) { + result.push(newLines[i]); + } + for (let i = prefix; i < newSuf; i++) { + const line = newLines[i]; + if (line.length <= printWidth) { + result.push(line); + continue; + } + if (!_isWrappable(line, state[i])) { + result.push(line); + continue; + } + const wrapped = _wrapLine(line, printWidth); + for (const w of wrapped) { result.push(w); } + } + for (let i = newSuf; i < newLines.length; i++) { + result.push(newLines[i]); + } + return result.join("\n"); + } + + exports.wrapEditedLines = wrapEditedLines; + // Exported for tests: + exports._detectLeading = _detectLeading; + exports._tokenize = _tokenize; + exports._wrapLine = _wrapLine; +}); diff --git a/test/UnitTestSuite.js b/test/UnitTestSuite.js index 97d84344be..f773202e2f 100644 --- a/test/UnitTestSuite.js +++ b/test/UnitTestSuite.js @@ -126,6 +126,7 @@ define(function (require, exports, module) { require("spec/Generic-integ-test"); require("spec/spacing-auto-detect-integ-test"); require("spec/LocalizationUtils-test"); + require("spec/markdown-line-wrap-test"); require("spec/ScrollTrackHandler-integ-test"); // Integrated extension tests require("spec/Extn-RemoteFileAdapter-integ-test"); diff --git a/test/spec/markdown-line-wrap-test.js b/test/spec/markdown-line-wrap-test.js new file mode 100644 index 0000000000..cdaaa75e6d --- /dev/null +++ b/test/spec/markdown-line-wrap-test.js @@ -0,0 +1,267 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/*global describe, it, expect*/ + +define(function (require, exports, module) { + const lineWrap = require("extensionsIntegrated/Phoenix-live-preview/markdown-line-wrap"); + + describe("unit:markdown-line-wrap", function () { + + describe("wrapEditedLines", function () { + + it("should leave unchanged lines byte-identical", function () { + const old = "para one\npara two\npara three\n"; + const out = lineWrap.wrapEditedLines(old, old, 80); + expect(out).toBe(old); + }); + + it("should not touch lines that already fit", function () { + const old = "alpha\n"; + const next = "alpha\nbeta\n"; + const out = lineWrap.wrapEditedLines(old, next, 80); + expect(out).toBe(next); + }); + + it("should wrap a long edited paragraph at the print width", function () { + const old = "short\n"; + const longLine = "lorem ipsum dolor sit amet consectetur adipiscing elit " + + "sed do eiusmod tempor incididunt ut labore et dolore magna aliqua"; + const next = longLine + "\n"; + const out = lineWrap.wrapEditedLines(old, next, 40); + const lines = out.split("\n"); + // First line must be <= width + expect(lines[0].length).toBeLessThanOrEqual(40); + // All resulting lines combined must contain the original tokens + expect(out.replace(/\s+/g, " ").trim()) + .toBe(longLine.trim()); + }); + + it("should preserve list marker and indent continuation lines", function () { + const old = "- short item\n"; + const longItem = "- this is a much longer list item that exceeds the " + + "print width and needs to wrap across multiple lines cleanly"; + const out = lineWrap.wrapEditedLines(old, longItem + "\n", 40); + const lines = out.split("\n").filter(l => l !== ""); + expect(lines.length).toBeGreaterThan(1); + expect(lines[0].startsWith("- ")).toBe(true); + for (let i = 1; i < lines.length; i++) { + // Continuation indent must be " " (2 spaces — width of "- ") + expect(lines[i].startsWith(" ")).toBe(true); + expect(lines[i].length).toBeLessThanOrEqual(40); + } + }); + + it("should preserve nested list indent", function () { + const old = " - short\n"; + const longNested = " - nested item content that goes on and on " + + "past the limit and should wrap with the right indent"; + const out = lineWrap.wrapEditedLines(old, longNested + "\n", 40); + const lines = out.split("\n").filter(l => l !== ""); + expect(lines[0].startsWith(" - ")).toBe(true); + for (let i = 1; i < lines.length; i++) { + // Continuation indent for " - " is " " (4 spaces) + expect(lines[i].startsWith(" ")).toBe(true); + } + }); + + it("should preserve ordered list marker", function () { + const old = "1. short\n"; + const longOrdered = "1. ordered list item with enough text to need " + + "wrapping past the print width limit"; + const out = lineWrap.wrapEditedLines(old, longOrdered + "\n", 40); + const lines = out.split("\n").filter(l => l !== ""); + expect(lines[0].startsWith("1. ")).toBe(true); + for (let i = 1; i < lines.length; i++) { + // Continuation indent matches "1. " (3 spaces) + expect(lines[i].startsWith(" ")).toBe(true); + } + }); + + it("should keep blockquote prefix on continuation lines", function () { + const old = "> short\n"; + const longQuote = "> blockquote content that is much longer than the " + + "configured print width and needs to be wrapped while preserving the quote marker"; + const out = lineWrap.wrapEditedLines(old, longQuote + "\n", 40); + const lines = out.split("\n").filter(l => l !== ""); + expect(lines.length).toBeGreaterThan(1); + for (const l of lines) { + expect(l.startsWith("> ")).toBe(true); + } + }); + + it("should not wrap inside fenced code blocks", function () { + const old = "para\n"; + const longCode = "this line inside a code fence is intentionally " + + "very long because code formatting must be preserved exactly"; + const next = "```js\n" + longCode + "\n```\n"; + const out = lineWrap.wrapEditedLines(old, next, 40); + expect(out).toBe(next); + }); + + it("should not wrap table lines", function () { + const old = "para\n"; + const longRow = "| this is a very long table cell content that " + + "must not be wrapped because table syntax requires the line stay intact |"; + const next = "| header |\n| --- |\n" + longRow + "\n"; + const out = lineWrap.wrapEditedLines(old, next, 40); + expect(out).toBe(next); + }); + + it("should not wrap ATX headings", function () { + const old = "para\n"; + const longHeading = "## this is a really long heading that goes on " + + "past forty columns easily"; + const next = longHeading + "\n"; + const out = lineWrap.wrapEditedLines(old, next, 40); + expect(out).toBe(next); + }); + + it("should not wrap link reference definitions", function () { + const old = "para\n"; + const linkRef = "[my-ref]: https://example.com/some/very/long/path?with=query¶ms=here"; + const next = linkRef + "\n"; + const out = lineWrap.wrapEditedLines(old, next, 40); + expect(out).toBe(next); + }); + + it("should not split an inline link across lines", function () { + const old = "short\n"; + const longLinkLine = "see [the docs](https://example.com/very/long/path/here) for more details on this topic"; + const out = lineWrap.wrapEditedLines(old, longLinkLine + "\n", 40); + // The link atom must appear intact on exactly one line + const lines = out.split("\n"); + const linksFound = lines.filter(l => l.includes("[the docs]")); + expect(linksFound.length).toBe(1); + expect(linksFound[0]).toContain("[the docs](https://example.com/very/long/path/here)"); + }); + + it("should not split inline code spans", function () { + const old = "short\n"; + const next = "inline `code_with_many_underscores_inside` followed by enough words to push past the print width easily"; + const out = lineWrap.wrapEditedLines(old, next + "\n", 40); + const lines = out.split("\n"); + const codeFound = lines.filter(l => l.includes("`code_with_many_underscores_inside`")); + expect(codeFound.length).toBe(1); + }); + + it("should leave a single-token line that already overflows alone", function () { + const old = "short\n"; + // One huge URL token — splitting would break it + const next = "https://example.com/an/extremely/long/url/that/cannot/be/wrapped/without/breaking\n"; + const out = lineWrap.wrapEditedLines(old, next, 40); + expect(out).toBe(next); + }); + + it("should skip wrapping inside YAML frontmatter", function () { + const old = "---\ntitle: short\n---\npara\n"; + const longFm = "---\ntitle: this is a much longer frontmatter title that exceeds the print width\n---\npara\n"; + const out = lineWrap.wrapEditedLines(old, longFm, 40); + expect(out).toBe(longFm); + }); + + it("should only touch lines in the edited range", function () { + const old = "first\nsecond\nthird\n"; + const longMiddle = "second line is now very long and definitely exceeds " + + "the print width forcing it to wrap"; + const next = "first\n" + longMiddle + "\nthird\n"; + const out = lineWrap.wrapEditedLines(old, next, 40); + const lines = out.split("\n"); + expect(lines[0]).toBe("first"); + // last non-empty line stays "third" + expect(lines[lines.length - 2]).toBe("third"); + }); + + it("should not modify when printWidth is below the safety floor", function () { + const old = "x\n"; + const next = "this is a longer line being added at width 0\n"; + expect(lineWrap.wrapEditedLines(old, next, 0)).toBe(next); + expect(lineWrap.wrapEditedLines(old, next, 10)).toBe(next); + }); + + it("should preserve the README sonarcloud badges line (regression)", function () { + // The Turndown fix already guarantees this comes through clean + // (no leading spaces). The wrap step must then leave it alone + // because the line is one giant link atom — non-splittable. + const old = "old\n"; + const badges = "[![Sonar code quality check](https://sonarcloud.io/api/project_badges/measure?project=phcode-dev_phoenix&metric=alert_status) ![Security rating](https://sonarcloud.io/api/project_badges/measure?project=phcode-dev_phoenix&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=phcode-dev_phoenix)"; + const next = badges + "\n"; + const out = lineWrap.wrapEditedLines(old, next, 80); + // The whole line is two link atoms with one space between them — + // wrap may break between them but each atom stays whole. + const lines = out.split("\n").filter(l => l !== ""); + for (const l of lines) { + // Both opening brackets must stay paired with their closing parens + const openLinks = (l.match(/\[/g) || []).length; + const closeLinks = (l.match(/\]\(/g) || []).length; + expect(openLinks).toBe(closeLinks); + } + }); + }); + + describe("_detectLeading", function () { + it("identifies bullet markers", function () { + expect(lineWrap._detectLeading("- foo").marker).toBe("- "); + expect(lineWrap._detectLeading("- foo").contIndent).toBe(" "); + }); + + it("identifies nested indent", function () { + const ctx = lineWrap._detectLeading(" - foo"); + expect(ctx.indent).toBe(" "); + expect(ctx.marker).toBe("- "); + expect(ctx.contIndent).toBe(" "); + }); + + it("identifies ordered list markers", function () { + expect(lineWrap._detectLeading("12. foo").marker).toBe("12. "); + expect(lineWrap._detectLeading("12. foo").contIndent).toBe(" "); + }); + + it("identifies blockquote", function () { + const ctx = lineWrap._detectLeading("> foo"); + expect(ctx.marker).toBe("> "); + expect(ctx.contIndent).toBe("> "); + }); + + it("returns no marker for plain paragraph", function () { + const ctx = lineWrap._detectLeading("hello world"); + expect(ctx.marker).toBe(""); + expect(ctx.contIndent).toBe(""); + }); + }); + + describe("_tokenize", function () { + it("keeps link atoms whole", function () { + const toks = lineWrap._tokenize("see [the docs](https://e.com/p) here"); + expect(toks).toContain("[the docs](https://e.com/p)"); + }); + + it("keeps image atoms whole", function () { + const toks = lineWrap._tokenize("![alt text](https://e.com/img.png) end"); + expect(toks).toContain("![alt text](https://e.com/img.png)"); + }); + + it("keeps inline code whole", function () { + const toks = lineWrap._tokenize("use `npm install -g foo` then"); + expect(toks).toContain("`npm install -g foo`"); + }); + }); + }); +});