formatferry 1.0.34 → 1.0.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +332 -198
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -58205,6 +58205,7 @@ var InputSanitizer = class {
58205
58205
  ALLOWED_TAGS: [
58206
58206
  "p",
58207
58207
  "br",
58208
+ "hr",
58208
58209
  "div",
58209
58210
  "span",
58210
58211
  "h1",
@@ -58255,6 +58256,7 @@ var InputSanitizer = class {
58255
58256
  ALLOWED_TAGS: [
58256
58257
  "p",
58257
58258
  "br",
58259
+ "hr",
58258
58260
  "div",
58259
58261
  "span",
58260
58262
  "h1",
@@ -58513,6 +58515,49 @@ function normalizeAtlassianHTML(html, domParser) {
58513
58515
 
58514
58516
  // ../../src/shared/confluence-slack-services.ts
58515
58517
  var import_turndown = __toESM(require_turndown_cjs(), 1);
58518
+
58519
+ // ../../src/shared/text-decoration.ts
58520
+ var DECLARATION = /(?:^|;)\s*text-decoration(?:-line)?\s*:([^;]*)/gi;
58521
+ function styleDecorations(style) {
58522
+ const result = { underline: false, strike: false };
58523
+ if (!style) return result;
58524
+ DECLARATION.lastIndex = 0;
58525
+ let match;
58526
+ while ((match = DECLARATION.exec(style)) !== null) {
58527
+ const value = match[1].toLowerCase();
58528
+ result.underline = /\bunderline\b/.test(value);
58529
+ result.strike = /\bline-through\b/.test(value);
58530
+ }
58531
+ return result;
58532
+ }
58533
+ function nodeStyle(node) {
58534
+ const el = node;
58535
+ return typeof el.getAttribute === "function" ? el.getAttribute("style") || "" : "";
58536
+ }
58537
+ function nodeDecorations(node) {
58538
+ const fromStyle = styleDecorations(nodeStyle(node));
58539
+ const name = node.nodeName;
58540
+ return {
58541
+ underline: fromStyle.underline || name === "U" || name === "INS",
58542
+ strike: fromStyle.strike || name === "DEL" || name === "S" || name === "STRIKE"
58543
+ };
58544
+ }
58545
+ function isUnderlineSpan(node) {
58546
+ return node.nodeName === "SPAN" && styleDecorations(nodeStyle(node)).underline;
58547
+ }
58548
+ function isStrikeSpan(node) {
58549
+ return node.nodeName === "SPAN" && styleDecorations(nodeStyle(node)).strike;
58550
+ }
58551
+ function decorate(content, node, syntax) {
58552
+ if (!content.trim()) return content;
58553
+ const d = nodeDecorations(node);
58554
+ let out = content;
58555
+ if (d.underline && syntax.underline) out = syntax.underline[0] + out + syntax.underline[1];
58556
+ if (d.strike && syntax.strike) out = syntax.strike[0] + out + syntax.strike[1];
58557
+ return out;
58558
+ }
58559
+
58560
+ // ../../src/shared/confluence-slack-services.ts
58516
58561
  function escapeSlackEntities(text) {
58517
58562
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
58518
58563
  }
@@ -58575,13 +58620,9 @@ function createSlackService(deps) {
58575
58620
  service.addRule("slackStrikethrough", {
58576
58621
  filter: (node) => {
58577
58622
  if (["DEL", "S", "STRIKE"].includes(node.nodeName)) return true;
58578
- if (node.nodeName === "SPAN") {
58579
- const style = node.getAttribute("style") || "";
58580
- return style.includes("text-decoration: line-through") || style.includes("text-decoration:line-through") || style.includes("text-decoration-line: line-through") || style.includes("text-decoration-line:line-through");
58581
- }
58582
- return false;
58623
+ return isStrikeSpan(node);
58583
58624
  },
58584
- replacement: (content) => content.trim() ? `~${content}~` : content
58625
+ replacement: (content, node) => decorate(content, node, { underline: null, strike: ["~", "~"] })
58585
58626
  });
58586
58627
  service.addRule("slackMonospaceCode", {
58587
58628
  filter: (node) => {
@@ -58642,13 +58683,10 @@ ${escapeSlackEntities(code)}
58642
58683
  service.addRule("slackUnderline", {
58643
58684
  filter: (node) => {
58644
58685
  if (node.nodeName === "U" || node.nodeName === "INS") return true;
58645
- if (node.nodeName === "SPAN") {
58646
- const style = node.getAttribute("style") || "";
58647
- return style.includes("text-decoration: underline") || style.includes("text-decoration:underline");
58648
- }
58649
- return false;
58686
+ return isUnderlineSpan(node);
58650
58687
  },
58651
- replacement: (content) => content
58688
+ // Slack has no underline; keep a strikethrough set on the same span
58689
+ replacement: (content, node) => decorate(content, node, { underline: null, strike: ["~", "~"] })
58652
58690
  });
58653
58691
  service.addRule("slackLink", {
58654
58692
  filter: (node) => node.nodeName === "A" && !!node.getAttribute("href"),
@@ -58680,34 +58718,38 @@ ${plainText}
58680
58718
  });
58681
58719
  return service;
58682
58720
  }
58683
- function processConfluenceList(list, service, depth) {
58684
- const isOrdered = list.nodeName === "OL";
58685
- const marker = (isOrdered ? "#" : "*").repeat(depth);
58721
+ function processConfluenceList(list, service, parentPrefix) {
58722
+ const prefix = parentPrefix + (list.nodeName === "OL" ? "#" : "*");
58686
58723
  const items = Array.from(list.children).filter((c) => c.nodeName === "LI");
58687
- let result = depth === 1 ? "\n" : "";
58724
+ const isTop = parentPrefix === "";
58725
+ let result = isTop ? "\n" : "";
58688
58726
  items.forEach((item) => {
58689
- const itemContent = processConfluenceListItem(item, service, depth);
58690
- result += `${marker} ${itemContent}
58727
+ const itemContent = processConfluenceListItem(item, service, prefix);
58728
+ result += `${prefix} ${itemContent}
58691
58729
  `;
58692
58730
  });
58693
- return depth === 1 ? result + "\n" : result;
58731
+ return isTop ? result + "\n" : result;
58694
58732
  }
58695
- function processConfluenceListItem(item, service, depth) {
58696
- let content = "";
58697
- const TEXT_NODE = 3;
58733
+ function processConfluenceListItem(item, service, prefix) {
58698
58734
  const ELEMENT_NODE2 = 1;
58735
+ const doc = item.ownerDocument;
58736
+ let content = "";
58737
+ let buffer = doc.createElement("div");
58738
+ const flush = () => {
58739
+ if (buffer.childNodes.length > 0) {
58740
+ content += service.turndown(buffer).replace(/[ \t]*\n(?:[ \t]*\n)+[ \t]*/g, " \\\\ ");
58741
+ }
58742
+ buffer = doc.createElement("div");
58743
+ };
58699
58744
  Array.from(item.childNodes).forEach((child) => {
58700
- if (child.nodeType === TEXT_NODE) {
58701
- content += child.textContent || "";
58702
- } else if (child.nodeType === ELEMENT_NODE2) {
58703
- const el = child;
58704
- if (el.nodeName === "UL" || el.nodeName === "OL") {
58705
- content += "\n" + processConfluenceList(el, service, depth + 1);
58706
- } else {
58707
- content += service.turndown(el.outerHTML);
58708
- }
58745
+ if (child.nodeType === ELEMENT_NODE2 && (child.nodeName === "UL" || child.nodeName === "OL")) {
58746
+ flush();
58747
+ content = content.trimEnd() + "\n" + processConfluenceList(child, service, prefix);
58748
+ } else {
58749
+ buffer.appendChild(child.cloneNode(true));
58709
58750
  }
58710
58751
  });
58752
+ flush();
58711
58753
  return content.trim();
58712
58754
  }
58713
58755
  function createConfluenceService(deps) {
@@ -58783,24 +58825,16 @@ ${codeContent}
58783
58825
  service.addRule("confluenceUnderline", {
58784
58826
  filter: (node) => {
58785
58827
  if (node.nodeName === "U" || node.nodeName === "INS") return true;
58786
- if (node.nodeName === "SPAN") {
58787
- const style = node.getAttribute("style") || "";
58788
- return style.includes("text-decoration: underline") || style.includes("text-decoration:underline");
58789
- }
58790
- return false;
58828
+ return isUnderlineSpan(node);
58791
58829
  },
58792
- replacement: (content) => content.trim() ? `+${content}+` : content
58830
+ replacement: (content, node) => decorate(content, node, { underline: ["+", "+"], strike: ["-", "-"] })
58793
58831
  });
58794
58832
  service.addRule("confluenceStrikethrough", {
58795
58833
  filter: (node) => {
58796
58834
  if (["DEL", "S", "STRIKE"].includes(node.nodeName)) return true;
58797
- if (node.nodeName === "SPAN") {
58798
- const style = node.getAttribute("style") || "";
58799
- return style.includes("text-decoration: line-through") || style.includes("text-decoration:line-through") || style.includes("text-decoration-line: line-through") || style.includes("text-decoration-line:line-through");
58800
- }
58801
- return false;
58835
+ return isStrikeSpan(node);
58802
58836
  },
58803
- replacement: (content) => content.trim() ? `-${content}-` : content
58837
+ replacement: (content, node) => decorate(content, node, { underline: ["+", "+"], strike: ["-", "-"] })
58804
58838
  });
58805
58839
  service.addRule("confluenceMonospaceCode", {
58806
58840
  filter: (node) => {
@@ -58884,7 +58918,7 @@ h${level}. ${trimmed}
58884
58918
  service.addRule("confluenceList", {
58885
58919
  filter: ["ul", "ol"],
58886
58920
  replacement: (_content, node) => {
58887
- return processConfluenceList(node, service, 1);
58921
+ return processConfluenceList(node, service, "");
58888
58922
  }
58889
58923
  });
58890
58924
  service.addRule("confluenceTable", {
@@ -58898,7 +58932,7 @@ h${level}. ${trimmed}
58898
58932
  const cells = Array.from(row.querySelectorAll("th, td"));
58899
58933
  const isHeaderRow = cells.some((c) => c.nodeName === "TH");
58900
58934
  const delim = isHeaderRow ? "||" : "|";
58901
- const cellTexts = cells.map((c) => (c.textContent || "").trim());
58935
+ const cellTexts = cells.map((c) => (c.textContent || "").trim().replace(/\|/g, "\\|"));
58902
58936
  result += delim + cellTexts.join(delim) + delim + "\n";
58903
58937
  });
58904
58938
  return result + "\n";
@@ -58981,7 +59015,7 @@ var ConversionEngine = class {
58981
59015
  emDelimiter: "_"
58982
59016
  });
58983
59017
  this.addGoogleDocsRules(this.githubService);
58984
- this.addGoogleDocsRules(this.commonmarkService);
59018
+ this.addGoogleDocsRules(this.commonmarkService, ["<del>", "</del>"]);
58985
59019
  this.addGoogleDocsRules(this.customService);
58986
59020
  this.addTableRules(this.githubService);
58987
59021
  this.addTableRules(this.commonmarkService);
@@ -58995,6 +59029,13 @@ var ConversionEngine = class {
58995
59029
  this.addSupSubMathRules(this.githubService);
58996
59030
  this.addSupSubMathRules(this.commonmarkService);
58997
59031
  this.addSupSubMathRules(this.customService);
59032
+ this.commonmarkService.addRule("commonmarkStrikethrough", {
59033
+ filter: (node) => {
59034
+ if (["DEL", "S", "STRIKE"].includes(node.nodeName)) return true;
59035
+ return isStrikeSpan(node);
59036
+ },
59037
+ replacement: (content, node) => decorate(content, node, { underline: ["<u>", "</u>"], strike: ["<del>", "</del>"] })
59038
+ });
58998
59039
  this.addPdfPageMarkerRules(this.githubService);
58999
59040
  this.addPdfPageMarkerRules(this.commonmarkService);
59000
59041
  this.addPdfPageMarkerRules(this.customService);
@@ -59013,6 +59054,37 @@ var ConversionEngine = class {
59013
59054
  this.confluenceService = createConfluenceService(deps);
59014
59055
  this.addSlideMarkerRules(this.slackService);
59015
59056
  this.addSlideMarkerRules(this.confluenceService);
59057
+ [
59058
+ this.githubService,
59059
+ this.commonmarkService,
59060
+ this.customService,
59061
+ this.discordService,
59062
+ this.redditService,
59063
+ this.rmarkdownService,
59064
+ this.slackService,
59065
+ this.confluenceService
59066
+ ].forEach((service) => this.addGoogleDocsWrapperRule(service));
59067
+ }
59068
+ /**
59069
+ * Google Docs wraps every copied selection in
59070
+ * <b style="font-weight:normal" id="docs-internal-guid-...">. Treating that
59071
+ * as bold puts a stray pair of bold markers around the whole document, so a
59072
+ * <b>/<strong> whose inline style explicitly sets a non-bold weight is
59073
+ * rendered as its plain content.
59074
+ */
59075
+ addGoogleDocsWrapperRule(service) {
59076
+ service.addRule("googleDocsNonBoldWrapper", {
59077
+ filter: (node) => {
59078
+ if (node.nodeName !== "B" && node.nodeName !== "STRONG") return false;
59079
+ const style = (node.getAttribute("style") || "").toLowerCase();
59080
+ const matches = [...style.matchAll(/font-weight\s*:\s*([a-z0-9]+)/g)];
59081
+ if (matches.length === 0) return false;
59082
+ const weight = matches[matches.length - 1][1];
59083
+ if (weight === "normal" || weight === "lighter") return true;
59084
+ return /^\d+$/.test(weight) && parseInt(weight, 10) < 600;
59085
+ },
59086
+ replacement: (content) => content
59087
+ });
59016
59088
  }
59017
59089
  /**
59018
59090
  * Create Discord-optimized Turndown service
@@ -59034,8 +59106,10 @@ var ConversionEngine = class {
59034
59106
  this.addPdfPageMarkerRules(service);
59035
59107
  this.addSlideMarkerRules(service);
59036
59108
  service.addRule("discordUnderline", {
59037
- filter: ["u", "ins"],
59038
- replacement: (content) => content.trim() ? `__${content}__` : content
59109
+ // Includes Google Docs CSS underline, which the shared rules would
59110
+ // otherwise emit as <u>…</u> (shown literally in Discord)
59111
+ filter: (node) => node.nodeName === "U" || node.nodeName === "INS" || isUnderlineSpan(node),
59112
+ replacement: (content, node) => decorate(content, node, { underline: ["__", "__"], strike: ["~~", "~~"] })
59039
59113
  });
59040
59114
  service.addRule("discordHeadingLimit", {
59041
59115
  filter: ["h4", "h5", "h6"],
@@ -59069,7 +59143,7 @@ ${plainText}
59069
59143
  },
59070
59144
  replacement: (_content, node) => {
59071
59145
  const checked = node.checked || node.hasAttribute("checked");
59072
- return checked ? "\u2611 " : "\u2610 ";
59146
+ return (checked ? "\u2611" : "\u2610") + this.checkboxSpacer(node);
59073
59147
  }
59074
59148
  });
59075
59149
  return service;
@@ -59097,9 +59171,9 @@ ${plainText}
59097
59171
  * Converts Unicode checkboxes to [x] and [ ]
59098
59172
  */
59099
59173
  normalizeGitHubCheckboxes(markdown) {
59100
- let result = markdown;
59101
- result = result.replace(/^([-*]\s+)[☑✓✔■⬛]/gm, "$1[x]");
59102
- result = result.replace(/^([-*]\s+)[☐✗✘□⬜]/gm, "$1[ ]");
59174
+ let result = markdown.replace(/^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)\\\[([ xX])\\\]/gm, "$1[$2]");
59175
+ result = result.replace(/^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)[☑✓✔■⬛]/gm, "$1[x]");
59176
+ result = result.replace(/^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)[☐✗✘□⬜]/gm, "$1[ ]");
59103
59177
  return result;
59104
59178
  }
59105
59179
  /**
@@ -59107,10 +59181,10 @@ ${plainText}
59107
59181
  * Converts [x], [ ], and other checkbox patterns to emoji
59108
59182
  */
59109
59183
  normalizeDiscordCheckboxes(markdown) {
59110
- let result = markdown.replace(/^([-*]\s+)\[x\]/gmi, "$1\u2611");
59111
- result = result.replace(/^([-*]\s+)[✓✔■⬛]/gm, "$1\u2611");
59112
- result = result.replace(/^([-*]\s+)\[\s?\]/gm, "$1\u2610");
59113
- result = result.replace(/^([-*]\s+)[✗✘□⬜]/gm, "$1\u2610");
59184
+ let result = markdown.replace(/^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)\\?\[x\\?\]/gmi, "$1\u2611");
59185
+ result = result.replace(/^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)[✓✔■⬛]/gm, "$1\u2611");
59186
+ result = result.replace(/^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)\\?\[\s?\\?\]/gm, "$1\u2610");
59187
+ result = result.replace(/^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)[✗✘□⬜]/gm, "$1\u2610");
59114
59188
  return result;
59115
59189
  }
59116
59190
  /**
@@ -59118,7 +59192,6 @@ ${plainText}
59118
59192
  * Converts Unicode superscript to ^text syntax
59119
59193
  */
59120
59194
  normalizeRedditUnicode(markdown) {
59121
- let result = markdown;
59122
59195
  const superscriptMap = {
59123
59196
  "\u2070": "0",
59124
59197
  "\xB9": "1",
@@ -59133,9 +59206,6 @@ ${plainText}
59133
59206
  "\u207F": "n",
59134
59207
  "\u2071": "i"
59135
59208
  };
59136
- for (const [unicode, digit] of Object.entries(superscriptMap)) {
59137
- result = result.replace(new RegExp(unicode, "g"), `^${digit}`);
59138
- }
59139
59209
  const subscriptMap = {
59140
59210
  "\u2080": "0",
59141
59211
  "\u2081": "1",
@@ -59148,17 +59218,13 @@ ${plainText}
59148
59218
  "\u2088": "8",
59149
59219
  "\u2089": "9"
59150
59220
  };
59151
- for (const [unicode, digit] of Object.entries(subscriptMap)) {
59152
- result = result.replace(new RegExp(unicode, "g"), `[${digit}]`);
59153
- }
59154
- return result;
59221
+ return this.replaceOutsideCode(markdown, (text) => text.replace(/[⁰¹²³⁴⁵⁶⁷⁸⁹ⁿⁱ]+/g, (run) => `^(${Array.from(run).map((c) => superscriptMap[c]).join("")})`).replace(/[₀₁₂₃₄₅₆₇₈₉]+/g, (run) => `[${Array.from(run).map((c) => subscriptMap[c]).join("")}]`));
59155
59222
  }
59156
59223
  /**
59157
59224
  * Normalize Unicode for R Markdown format
59158
59225
  * Converts Unicode superscript/subscript to Pandoc syntax
59159
59226
  */
59160
59227
  normalizeRMarkdownUnicode(markdown) {
59161
- let result = markdown;
59162
59228
  const superscriptMap = {
59163
59229
  "\u2070": "0",
59164
59230
  "\xB9": "1",
@@ -59173,9 +59239,6 @@ ${plainText}
59173
59239
  "\u207F": "n",
59174
59240
  "\u2071": "i"
59175
59241
  };
59176
- for (const [unicode, digit] of Object.entries(superscriptMap)) {
59177
- result = result.replace(new RegExp(unicode, "g"), `^${digit}^`);
59178
- }
59179
59242
  const subscriptMap = {
59180
59243
  "\u2080": "0",
59181
59244
  "\u2081": "1",
@@ -59188,10 +59251,7 @@ ${plainText}
59188
59251
  "\u2088": "8",
59189
59252
  "\u2089": "9"
59190
59253
  };
59191
- for (const [unicode, digit] of Object.entries(subscriptMap)) {
59192
- result = result.replace(new RegExp(unicode, "g"), `~${digit}~`);
59193
- }
59194
- return result;
59254
+ return this.replaceOutsideCode(markdown, (text) => text.replace(/[⁰¹²³⁴⁵⁶⁷⁸⁹ⁿⁱ]+/g, (run) => `^${Array.from(run).map((c) => superscriptMap[c]).join("")}^`).replace(/[₀₁₂₃₄₅₆₇₈₉]+/g, (run) => `~${Array.from(run).map((c) => subscriptMap[c]).join("")}~`));
59195
59255
  }
59196
59256
  /**
59197
59257
  * Create Reddit-optimized Turndown service
@@ -59199,6 +59259,104 @@ ${plainText}
59199
59259
  * - Subscript to bracketed text
59200
59260
  * - Fenced code blocks (new Reddit)
59201
59261
  */
59262
+ /**
59263
+ * Reddit's bare ^word superscript runs to the next space, so "x^2y" would
59264
+ * superscript "2y". The parenthesised form ^(…) always ends where it should.
59265
+ */
59266
+ redditSuperscript(content) {
59267
+ const trimmed = content.trim();
59268
+ return trimmed ? `^(${trimmed.replace(/\)/g, "\\)")})` : "";
59269
+ }
59270
+ /**
59271
+ * Pandoc ^x^ / ~x~ cannot contain bare spaces (they must be backslash-escaped),
59272
+ * and a bare delimiter inside the content would close the span early.
59273
+ */
59274
+ pandocScript(content, delimiter) {
59275
+ const trimmed = content.trim();
59276
+ if (!trimmed) return "";
59277
+ const escaped = trimmed.replace(delimiter === "^" ? /(?<!\\)\^/g : /(?<!\\)~/g, `\\${delimiter}`).replace(/ /g, "\\ ");
59278
+ return `${delimiter}${escaped}${delimiter}`;
59279
+ }
59280
+ /**
59281
+ * Apply a text transform everywhere except fenced code blocks and inline code spans.
59282
+ *
59283
+ * Fences may use ``` or ~~~ of any length (3+) and may sit behind list
59284
+ * indentation or blockquote markers; a fence closes only on a run of the same
59285
+ * character at least as long as the opener. Inline code spans close on a
59286
+ * backtick run of exactly the opening length (CommonMark), so `` a`b `` is
59287
+ * protected whole.
59288
+ */
59289
+ replaceOutsideCode(markdown, transform) {
59290
+ const fenceOpen = /^((?:[ \t]*>)*[ \t]*(?:(?:[-*+]|\d+[.)])[ \t]+)?[ \t]*)(`{3,}|~{3,})/;
59291
+ const lines = markdown.split(/(?<=\n)/);
59292
+ const segments = [];
59293
+ const push = (text, code) => {
59294
+ const last = segments[segments.length - 1];
59295
+ if (last && last.code === code) last.text += text;
59296
+ else segments.push({ text, code });
59297
+ };
59298
+ let fence = null;
59299
+ for (const line of lines) {
59300
+ if (fence) {
59301
+ push(line, true);
59302
+ const body = line.replace(/^(?:[ \t]*>)*[ \t]*/, "");
59303
+ const close = body.match(/^(`{3,}|~{3,})[ \t]*\r?\n?$/);
59304
+ if (close && close[1][0] === fence.char && close[1].length >= fence.len) fence = null;
59305
+ continue;
59306
+ }
59307
+ const open = line.match(fenceOpen);
59308
+ if (open && !(open[2][0] === "`" && line.slice(open[0].length).includes("`"))) {
59309
+ fence = { char: open[2][0], len: open[2].length };
59310
+ push(line, true);
59311
+ continue;
59312
+ }
59313
+ push(line, false);
59314
+ }
59315
+ const out = [];
59316
+ for (const seg of segments) {
59317
+ if (seg.code) {
59318
+ out.push(seg.text);
59319
+ continue;
59320
+ }
59321
+ const text = seg.text;
59322
+ let plainStart = 0;
59323
+ let i = 0;
59324
+ while (i < text.length) {
59325
+ if (text[i] !== "`") {
59326
+ i++;
59327
+ continue;
59328
+ }
59329
+ let j = i;
59330
+ while (j < text.length && text[j] === "`") j++;
59331
+ const runLen = j - i;
59332
+ let k = j;
59333
+ let closeAt = -1;
59334
+ while (k < text.length) {
59335
+ if (text[k] !== "`") {
59336
+ k++;
59337
+ continue;
59338
+ }
59339
+ let m = k;
59340
+ while (m < text.length && text[m] === "`") m++;
59341
+ if (m - k === runLen) {
59342
+ closeAt = m;
59343
+ break;
59344
+ }
59345
+ k = m;
59346
+ }
59347
+ if (closeAt === -1) {
59348
+ i = j;
59349
+ continue;
59350
+ }
59351
+ out.push(transform(text.slice(plainStart, i)));
59352
+ out.push(text.slice(i, closeAt));
59353
+ plainStart = closeAt;
59354
+ i = closeAt;
59355
+ }
59356
+ out.push(transform(text.slice(plainStart)));
59357
+ }
59358
+ return out.join("");
59359
+ }
59202
59360
  createRedditService() {
59203
59361
  const service = new import_turndown2.default({
59204
59362
  headingStyle: "atx",
@@ -59212,13 +59370,15 @@ ${plainText}
59212
59370
  this.addListRules(service);
59213
59371
  this.addPdfPageMarkerRules(service);
59214
59372
  this.addSlideMarkerRules(service);
59373
+ service.addRule("redditUnderline", {
59374
+ filter: (node) => node.nodeName === "U" || node.nodeName === "INS" || isUnderlineSpan(node),
59375
+ // Keep a strikethrough set on the same span
59376
+ replacement: (content, node) => decorate(content, node, { underline: null, strike: ["~~", "~~"] })
59377
+ });
59378
+ this.addTaskListRules(service);
59215
59379
  service.addRule("redditSuperscript", {
59216
59380
  filter: (node) => node.nodeName === "SUP",
59217
- replacement: (content) => {
59218
- const trimmed = content.trim();
59219
- if (!trimmed) return "";
59220
- return trimmed.includes(" ") ? `^(${trimmed})` : `^${trimmed}`;
59221
- }
59381
+ replacement: (content) => this.redditSuperscript(content)
59222
59382
  });
59223
59383
  service.addRule("redditSuperscriptStyle", {
59224
59384
  filter: (node) => {
@@ -59226,11 +59386,7 @@ ${plainText}
59226
59386
  const style = node.getAttribute("style") || "";
59227
59387
  return style.includes("vertical-align: super") || style.includes("vertical-align:super");
59228
59388
  },
59229
- replacement: (content) => {
59230
- const trimmed = content.trim();
59231
- if (!trimmed) return "";
59232
- return trimmed.includes(" ") ? `^(${trimmed})` : `^${trimmed}`;
59233
- }
59389
+ replacement: (content) => this.redditSuperscript(content)
59234
59390
  });
59235
59391
  service.addRule("redditSubscript", {
59236
59392
  filter: (node) => node.nodeName === "SUB",
@@ -59271,12 +59427,10 @@ ${plainText}
59271
59427
  this.addListRules(service);
59272
59428
  this.addPdfPageMarkerRules(service);
59273
59429
  this.addSlideMarkerRules(service);
59430
+ this.addTaskListRules(service);
59274
59431
  service.addRule("rmdSuperscript", {
59275
59432
  filter: (node) => node.nodeName === "SUP",
59276
- replacement: (content) => {
59277
- const trimmed = content.trim();
59278
- return trimmed ? `^${trimmed}^` : "";
59279
- }
59433
+ replacement: (content) => this.pandocScript(content, "^")
59280
59434
  });
59281
59435
  service.addRule("rmdSuperscriptStyle", {
59282
59436
  filter: (node) => {
@@ -59284,17 +59438,11 @@ ${plainText}
59284
59438
  const style = node.getAttribute("style") || "";
59285
59439
  return style.includes("vertical-align: super") || style.includes("vertical-align:super");
59286
59440
  },
59287
- replacement: (content) => {
59288
- const trimmed = content.trim();
59289
- return trimmed ? `^${trimmed}^` : "";
59290
- }
59441
+ replacement: (content) => this.pandocScript(content, "^")
59291
59442
  });
59292
59443
  service.addRule("rmdSubscript", {
59293
59444
  filter: (node) => node.nodeName === "SUB",
59294
- replacement: (content) => {
59295
- const trimmed = content.trim();
59296
- return trimmed ? `~${trimmed}~` : "";
59297
- }
59445
+ replacement: (content) => this.pandocScript(content, "~")
59298
59446
  });
59299
59447
  service.addRule("rmdSubscriptStyle", {
59300
59448
  filter: (node) => {
@@ -59302,10 +59450,7 @@ ${plainText}
59302
59450
  const style = node.getAttribute("style") || "";
59303
59451
  return style.includes("vertical-align: sub") || style.includes("vertical-align:sub");
59304
59452
  },
59305
- replacement: (content) => {
59306
- const trimmed = content.trim();
59307
- return trimmed ? `~${trimmed}~` : "";
59308
- }
59453
+ replacement: (content) => this.pandocScript(content, "~")
59309
59454
  });
59310
59455
  service.addRule("rmdMath", {
59311
59456
  filter: (node) => {
@@ -59365,7 +59510,7 @@ ${plainText}
59365
59510
  });
59366
59511
  return doc.body.firstElementChild?.innerHTML ?? html;
59367
59512
  }
59368
- addGoogleDocsRules(service) {
59513
+ addGoogleDocsRules(service, strike = ["~~", "~~"]) {
59369
59514
  service.addRule("googleDocsBold", {
59370
59515
  filter: (node) => {
59371
59516
  if (node.nodeName !== "SPAN") return false;
@@ -59435,16 +59580,9 @@ ${plainText}
59435
59580
  }
59436
59581
  });
59437
59582
  service.addRule("googleDocsUnderline", {
59438
- filter: (node) => {
59439
- if (node.nodeName === "SPAN") {
59440
- const style = node.getAttribute("style") || "";
59441
- return style.includes("text-decoration: underline") || style.includes("text-decoration:underline");
59442
- }
59443
- return false;
59444
- },
59445
- replacement: (content) => {
59446
- return content.trim() ? `<u>${content}</u>` : content;
59447
- }
59583
+ filter: (node) => isUnderlineSpan(node),
59584
+ // Also applies a line-through set on the same span
59585
+ replacement: (content, node) => decorate(content, node, { underline: ["<u>", "</u>"], strike })
59448
59586
  });
59449
59587
  service.addRule("strikethroughTags", {
59450
59588
  filter: ["del", "s"],
@@ -59459,14 +59597,9 @@ ${plainText}
59459
59597
  }
59460
59598
  });
59461
59599
  service.addRule("strikethroughStyle", {
59462
- filter: (node) => {
59463
- if (node.nodeName !== "SPAN") return false;
59464
- const style = node.getAttribute("style") || "";
59465
- return style.includes("text-decoration: line-through") || style.includes("text-decoration:line-through") || style.includes("text-decoration-line: line-through") || style.includes("text-decoration-line:line-through");
59466
- },
59467
- replacement: (content) => {
59468
- return content.trim() ? `~~${content}~~` : content;
59469
- }
59600
+ filter: (node) => isStrikeSpan(node),
59601
+ // Also applies an underline set on the same span
59602
+ replacement: (content, node) => decorate(content, node, { underline: ["<u>", "</u>"], strike })
59470
59603
  });
59471
59604
  service.addRule("monospaceCode", {
59472
59605
  filter: (node) => {
@@ -59580,6 +59713,11 @@ ${plainText}
59580
59713
  escapeTableCell(content) {
59581
59714
  return content.replace(/\|/g, "\\|").replace(/\s*<br>\s*/g, "<br>").trim();
59582
59715
  }
59716
+ /** A space after a checkbox marker unless the label text already starts with one. */
59717
+ checkboxSpacer(node) {
59718
+ const next = node.nextSibling;
59719
+ return next && /^\s/.test(next.textContent || "") ? "" : " ";
59720
+ }
59583
59721
  addTaskListRules(service) {
59584
59722
  service.addRule("gfmTaskListCheckbox", {
59585
59723
  filter: (node) => {
@@ -59587,53 +59725,31 @@ ${plainText}
59587
59725
  },
59588
59726
  replacement: (_content, node) => {
59589
59727
  const checked = node.checked || node.hasAttribute("checked");
59590
- return checked ? "[x] " : "[ ] ";
59728
+ return (checked ? "[x]" : "[ ]") + this.checkboxSpacer(node);
59591
59729
  }
59592
59730
  });
59593
59731
  }
59594
59732
  /**
59595
- * FF-IMP-004: Add superscript, subscript, and math span rules for
59596
- * github/commonmark flavours. Uses the same Pandoc-style syntax as
59597
- * rmarkdown (^text^ for sup, ~text~ for sub, $math$ for inline math)
59598
- * which GitHub renders correctly in Markdown files.
59733
+ * FF-IMP-004: Superscript, subscript and math span rules for the
59734
+ * github/commonmark/gdocs flavours. Neither GFM nor CommonMark has
59735
+ * superscript/subscript syntax: Pandoc-style ^x^ shows literally, and GitHub
59736
+ * renders ~x~ as strikethrough. Both allow inline HTML and GitHub keeps
59737
+ * <sup>/<sub>, so those are emitted instead.
59599
59738
  */
59600
59739
  addSupSubMathRules(service) {
59601
- service.addRule("githubSuperscript", {
59602
- filter: (node) => node.nodeName === "SUP",
59603
- replacement: (content) => {
59604
- const trimmed = content.trim();
59605
- return trimmed ? `^${trimmed}^` : "";
59606
- }
59607
- });
59608
- service.addRule("githubSuperscriptStyle", {
59609
- filter: (node) => {
59610
- if (node.nodeName !== "SPAN") return false;
59611
- const style = node.getAttribute("style") || "";
59612
- return style.includes("vertical-align: super") || style.includes("vertical-align:super");
59613
- },
59614
- replacement: (content) => {
59615
- const trimmed = content.trim();
59616
- return trimmed ? `^${trimmed}^` : "";
59617
- }
59618
- });
59619
- service.addRule("githubSubscript", {
59620
- filter: (node) => node.nodeName === "SUB",
59621
- replacement: (content) => {
59622
- const trimmed = content.trim();
59623
- return trimmed ? `~${trimmed}~` : "";
59624
- }
59625
- });
59626
- service.addRule("githubSubscriptStyle", {
59627
- filter: (node) => {
59628
- if (node.nodeName !== "SPAN") return false;
59629
- const style = node.getAttribute("style") || "";
59630
- return style.includes("vertical-align: sub") || style.includes("vertical-align:sub");
59631
- },
59632
- replacement: (content) => {
59633
- const trimmed = content.trim();
59634
- return trimmed ? `~${trimmed}~` : "";
59635
- }
59636
- });
59740
+ const isStyle = (node, value) => {
59741
+ if (node.nodeName !== "SPAN") return false;
59742
+ const style = node.getAttribute("style") || "";
59743
+ return style.includes(`vertical-align: ${value}`) || style.includes(`vertical-align:${value}`);
59744
+ };
59745
+ const wrap = (tag) => (content) => {
59746
+ const trimmed = content.trim();
59747
+ return trimmed ? `<${tag}>${trimmed}</${tag}>` : "";
59748
+ };
59749
+ service.addRule("githubSuperscript", { filter: (node) => node.nodeName === "SUP", replacement: wrap("sup") });
59750
+ service.addRule("githubSuperscriptStyle", { filter: (node) => isStyle(node, "super"), replacement: wrap("sup") });
59751
+ service.addRule("githubSubscript", { filter: (node) => node.nodeName === "SUB", replacement: wrap("sub") });
59752
+ service.addRule("githubSubscriptStyle", { filter: (node) => isStyle(node, "sub"), replacement: wrap("sub") });
59637
59753
  service.addRule("githubMath", {
59638
59754
  filter: (node) => {
59639
59755
  if (node.nodeName !== "SPAN") return false;
@@ -59653,60 +59769,78 @@ ${plainText}
59653
59769
  service.addRule("enhancedList", {
59654
59770
  filter: ["ul", "ol"],
59655
59771
  replacement: (content, node) => {
59656
- const listType = node?.nodeName.toLowerCase();
59657
59772
  const items = Array.from(node.children).filter(
59658
59773
  (child) => child.nodeName.toLowerCase() === "li"
59659
59774
  );
59660
59775
  if (items.length === 0) return content;
59661
- let markdown = "\n";
59662
- const isOrdered = listType === "ol";
59663
- const marker = isOrdered ? "1." : service.options.bulletListMarker || "-";
59664
- items.forEach((item, index) => {
59665
- const itemContent = this.processListItem(item, service);
59666
- const actualMarker = isOrdered ? `${index + 1}.` : marker;
59667
- markdown += `${actualMarker} ${itemContent}
59668
- `;
59669
- });
59670
- return markdown + "\n";
59671
- }
59672
- });
59673
- }
59674
- processListItem(item, service, depth = 0) {
59675
- let content = "";
59676
- const TEXT_NODE = 3;
59677
- const ELEMENT_NODE2 = 1;
59678
- Array.from(item.childNodes).forEach((child) => {
59679
- if (child.nodeType === TEXT_NODE) {
59680
- content += child.textContent || "";
59681
- } else if (child.nodeType === ELEMENT_NODE2) {
59682
- const element = child;
59683
- if (element.nodeName.toLowerCase() === "ul" || element.nodeName.toLowerCase() === "ol") {
59684
- const nestedContent = this.processNestedList(element, service, depth + 1);
59685
- content += "\n" + nestedContent;
59686
- } else {
59687
- content += service.turndown(element.outerHTML);
59688
- }
59776
+ return "\n" + this.renderList(node, service) + "\n";
59689
59777
  }
59690
59778
  });
59691
- return content.trim();
59692
59779
  }
59693
- processNestedList(list, service, depth) {
59780
+ /**
59781
+ * Render a list with markers at column 0. Continuation lines of each item
59782
+ * (extra paragraphs, nested lists) are indented to the item's content
59783
+ * column (marker width + 1), which is what CommonMark/GFM require for them
59784
+ * to stay inside the item: 2 spaces under "- ", 3 under "1. ", 4 under "10. ".
59785
+ */
59786
+ renderList(list, service) {
59694
59787
  const items = Array.from(list.children).filter(
59695
59788
  (child) => child.nodeName.toLowerCase() === "li"
59696
59789
  );
59697
- if (items.length === 0) return "";
59698
- let markdown = "";
59699
59790
  const isOrdered = list.nodeName.toLowerCase() === "ol";
59700
- const indent = " ".repeat(depth);
59701
- const marker = isOrdered ? "1." : service.options.bulletListMarker || "-";
59791
+ const bullet = service.options.bulletListMarker || "-";
59792
+ let markdown = "";
59702
59793
  items.forEach((item, index) => {
59703
- const itemContent = this.processListItem(item, service, depth);
59704
- const actualMarker = isOrdered ? `${index + 1}.` : marker;
59705
- markdown += `${indent}${actualMarker} ${itemContent}
59794
+ const marker = isOrdered ? `${index + 1}.` : bullet;
59795
+ const indent = " ".repeat(marker.length + 1);
59796
+ const body = this.processListItem(item, service);
59797
+ const lines = body.split("\n");
59798
+ const indented = lines.map((line, i) => i === 0 || line === "" ? line : indent + line).join("\n");
59799
+ markdown += `${marker} ${indented}
59706
59800
  `;
59707
59801
  });
59708
59802
  return markdown;
59709
59803
  }
59804
+ /**
59805
+ * Render one list item's content relative to its own content column.
59806
+ * Inline runs and block children are converted together, so whitespace
59807
+ * between sibling spans survives (Google Docs emits one span per run) and
59808
+ * multiple paragraphs stay separated.
59809
+ */
59810
+ processListItem(item, service) {
59811
+ const parts = [];
59812
+ const ELEMENT_NODE2 = 1;
59813
+ const doc = item.ownerDocument;
59814
+ let buffer = doc.createElement("div");
59815
+ const flush = () => {
59816
+ if (buffer.childNodes.length > 0) {
59817
+ const md = service.turndown(buffer).trim();
59818
+ if (md) parts.push(md);
59819
+ }
59820
+ buffer = doc.createElement("div");
59821
+ };
59822
+ Array.from(item.childNodes).forEach((child) => {
59823
+ const name = child.nodeType === ELEMENT_NODE2 ? child.nodeName.toLowerCase() : "";
59824
+ if (name === "ul" || name === "ol") {
59825
+ flush();
59826
+ const nested = this.renderList(child, service).replace(/\n+$/, "");
59827
+ if (nested) parts.push(nested);
59828
+ } else {
59829
+ buffer.appendChild(child.cloneNode(true));
59830
+ }
59831
+ });
59832
+ flush();
59833
+ let content = "";
59834
+ parts.forEach((part, i) => {
59835
+ if (i === 0) {
59836
+ content = part;
59837
+ return;
59838
+ }
59839
+ const isList = /^(?:[-*+]|\d+\.) /.test(part);
59840
+ content += (isList ? "\n" : "\n\n") + part;
59841
+ });
59842
+ return content;
59843
+ }
59710
59844
  addSlideMarkerRules(service) {
59711
59845
  service.addRule("pptxSlideMarker", {
59712
59846
  filter: (node) => node.nodeName === "DIV" && node.hasAttribute("data-slide-marker"),
@@ -64775,8 +64909,8 @@ import { readFileSync as readFileSync5 } from "fs";
64775
64909
  import { resolve, dirname as dirname3 } from "path";
64776
64910
  import { fileURLToPath } from "url";
64777
64911
  var getVersion = () => {
64778
- if ("1.0.34") {
64779
- return "1.0.34";
64912
+ if ("1.0.36") {
64913
+ return "1.0.36";
64780
64914
  }
64781
64915
  try {
64782
64916
  const __dirname2 = dirname3(fileURLToPath(import.meta.url));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "formatferry",
3
- "version": "1.0.34",
3
+ "version": "1.0.36",
4
4
  "description": "Format Ferry CLI — Convert HTML, DOCX, PDF, XLSX, CSV, JSON, XML, PPTX to Markdown",
5
5
  "keywords": [
6
6
  "markdown",