formatferry 1.0.33 → 1.0.35

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 (3) hide show
  1. package/README.md +2 -0
  2. package/dist/index.js +338 -96
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -103,6 +103,8 @@ formatferry [options]
103
103
  | `-o, --output <path>` | Output file path (defaults to stdout) |
104
104
  | `-f, --format <format>` | Markdown format (default: `github`) |
105
105
  | `--url <url>` | Fetch and convert a web article (requires API key) |
106
+
107
+ `--url` asks the proxy for the page HTML and converts it locally, so `--format` chooses the Markdown flavour exactly as it does for a local file. The proxy also offers the finished Markdown directly: `POST /v1/extract` with `{ "format": "markdown" }` (see the root README).
106
108
  | `--batch <glob>` | Batch convert files matching a glob pattern (Premium) |
107
109
  | `--output-dir <dir>` | Output directory for batch mode |
108
110
  | `--strict` | Exit with code 1 if the conversion produced any warnings (including advisory ones) |
package/dist/index.js CHANGED
@@ -948,14 +948,14 @@ var require_Node = __commonJS({
948
948
  this._nextSibling = this._previousSibling = this;
949
949
  this._index = void 0;
950
950
  }
951
- var ELEMENT_NODE = Node2.ELEMENT_NODE = 1;
951
+ var ELEMENT_NODE2 = Node2.ELEMENT_NODE = 1;
952
952
  var ATTRIBUTE_NODE = Node2.ATTRIBUTE_NODE = 2;
953
953
  var TEXT_NODE = Node2.TEXT_NODE = 3;
954
954
  var CDATA_SECTION_NODE = Node2.CDATA_SECTION_NODE = 4;
955
955
  var ENTITY_REFERENCE_NODE = Node2.ENTITY_REFERENCE_NODE = 5;
956
956
  var ENTITY_NODE = Node2.ENTITY_NODE = 6;
957
957
  var PROCESSING_INSTRUCTION_NODE = Node2.PROCESSING_INSTRUCTION_NODE = 7;
958
- var COMMENT_NODE = Node2.COMMENT_NODE = 8;
958
+ var COMMENT_NODE2 = Node2.COMMENT_NODE = 8;
959
959
  var DOCUMENT_NODE = Node2.DOCUMENT_NODE = 9;
960
960
  var DOCUMENT_TYPE_NODE = Node2.DOCUMENT_TYPE_NODE = 10;
961
961
  var DOCUMENT_FRAGMENT_NODE = Node2.DOCUMENT_FRAGMENT_NODE = 11;
@@ -973,7 +973,7 @@ var require_Node = __commonJS({
973
973
  // we'll come back to this later.
974
974
  baseURI: { get: utils2.nyi },
975
975
  parentElement: { get: function() {
976
- return this.parentNode && this.parentNode.nodeType === ELEMENT_NODE ? this.parentNode : null;
976
+ return this.parentNode && this.parentNode.nodeType === ELEMENT_NODE2 ? this.parentNode : null;
977
977
  } },
978
978
  hasChildNodes: { value: utils2.shouldOverride },
979
979
  firstChild: { get: utils2.shouldOverride },
@@ -1034,7 +1034,7 @@ var require_Node = __commonJS({
1034
1034
  switch (parent.nodeType) {
1035
1035
  case DOCUMENT_NODE:
1036
1036
  case DOCUMENT_FRAGMENT_NODE:
1037
- case ELEMENT_NODE:
1037
+ case ELEMENT_NODE2:
1038
1038
  break;
1039
1039
  default:
1040
1040
  utils2.HierarchyRequestError();
@@ -1046,10 +1046,10 @@ var require_Node = __commonJS({
1046
1046
  switch (node.nodeType) {
1047
1047
  case DOCUMENT_FRAGMENT_NODE:
1048
1048
  case DOCUMENT_TYPE_NODE:
1049
- case ELEMENT_NODE:
1049
+ case ELEMENT_NODE2:
1050
1050
  case TEXT_NODE:
1051
1051
  case PROCESSING_INSTRUCTION_NODE:
1052
- case COMMENT_NODE:
1052
+ case COMMENT_NODE2:
1053
1053
  break;
1054
1054
  default:
1055
1055
  utils2.HierarchyRequestError();
@@ -1062,7 +1062,7 @@ var require_Node = __commonJS({
1062
1062
  case DOCUMENT_FRAGMENT_NODE:
1063
1063
  if (node._countChildrenOfType(TEXT_NODE) > 0)
1064
1064
  utils2.HierarchyRequestError();
1065
- switch (node._countChildrenOfType(ELEMENT_NODE)) {
1065
+ switch (node._countChildrenOfType(ELEMENT_NODE2)) {
1066
1066
  case 0:
1067
1067
  break;
1068
1068
  case 1:
@@ -1074,12 +1074,12 @@ var require_Node = __commonJS({
1074
1074
  utils2.HierarchyRequestError();
1075
1075
  }
1076
1076
  }
1077
- i = parent._countChildrenOfType(ELEMENT_NODE);
1077
+ i = parent._countChildrenOfType(ELEMENT_NODE2);
1078
1078
  if (isPreinsert) {
1079
1079
  if (i > 0)
1080
1080
  utils2.HierarchyRequestError();
1081
1081
  } else {
1082
- if (i > 1 || i === 1 && child.nodeType !== ELEMENT_NODE)
1082
+ if (i > 1 || i === 1 && child.nodeType !== ELEMENT_NODE2)
1083
1083
  utils2.HierarchyRequestError();
1084
1084
  }
1085
1085
  break;
@@ -1087,7 +1087,7 @@ var require_Node = __commonJS({
1087
1087
  utils2.HierarchyRequestError();
1088
1088
  }
1089
1089
  break;
1090
- case ELEMENT_NODE:
1090
+ case ELEMENT_NODE2:
1091
1091
  if (child !== null) {
1092
1092
  if (isPreinsert && child.nodeType === DOCUMENT_TYPE_NODE)
1093
1093
  utils2.HierarchyRequestError();
@@ -1096,23 +1096,23 @@ var require_Node = __commonJS({
1096
1096
  utils2.HierarchyRequestError();
1097
1097
  }
1098
1098
  }
1099
- i = parent._countChildrenOfType(ELEMENT_NODE);
1099
+ i = parent._countChildrenOfType(ELEMENT_NODE2);
1100
1100
  if (isPreinsert) {
1101
1101
  if (i > 0)
1102
1102
  utils2.HierarchyRequestError();
1103
1103
  } else {
1104
- if (i > 1 || i === 1 && child.nodeType !== ELEMENT_NODE)
1104
+ if (i > 1 || i === 1 && child.nodeType !== ELEMENT_NODE2)
1105
1105
  utils2.HierarchyRequestError();
1106
1106
  }
1107
1107
  break;
1108
1108
  case DOCUMENT_TYPE_NODE:
1109
1109
  if (child === null) {
1110
- if (parent._countChildrenOfType(ELEMENT_NODE))
1110
+ if (parent._countChildrenOfType(ELEMENT_NODE2))
1111
1111
  utils2.HierarchyRequestError();
1112
1112
  } else {
1113
1113
  for (kid = parent.firstChild; kid !== null; kid = kid.nextSibling) {
1114
1114
  if (kid === child) break;
1115
- if (kid.nodeType === ELEMENT_NODE)
1115
+ if (kid.nodeType === ELEMENT_NODE2)
1116
1116
  utils2.HierarchyRequestError();
1117
1117
  }
1118
1118
  }
@@ -1229,7 +1229,7 @@ var require_Node = __commonJS({
1229
1229
  var e;
1230
1230
  if (ns === "" || ns === null || ns === void 0) return null;
1231
1231
  switch (this.nodeType) {
1232
- case ELEMENT_NODE:
1232
+ case ELEMENT_NODE2:
1233
1233
  return this._lookupNamespacePrefix(ns, this);
1234
1234
  case DOCUMENT_NODE:
1235
1235
  e = this.documentElement;
@@ -1253,7 +1253,7 @@ var require_Node = __commonJS({
1253
1253
  }
1254
1254
  var e;
1255
1255
  switch (this.nodeType) {
1256
- case ELEMENT_NODE:
1256
+ case ELEMENT_NODE2:
1257
1257
  return utils2.shouldOverride();
1258
1258
  case DOCUMENT_NODE:
1259
1259
  e = this.documentElement;
@@ -1511,14 +1511,14 @@ var require_Node = __commonJS({
1511
1511
  },
1512
1512
  // mirror node type properties in the prototype, so they are present
1513
1513
  // in instances of Node (and subclasses)
1514
- ELEMENT_NODE: { value: ELEMENT_NODE },
1514
+ ELEMENT_NODE: { value: ELEMENT_NODE2 },
1515
1515
  ATTRIBUTE_NODE: { value: ATTRIBUTE_NODE },
1516
1516
  TEXT_NODE: { value: TEXT_NODE },
1517
1517
  CDATA_SECTION_NODE: { value: CDATA_SECTION_NODE },
1518
1518
  ENTITY_REFERENCE_NODE: { value: ENTITY_REFERENCE_NODE },
1519
1519
  ENTITY_NODE: { value: ENTITY_NODE },
1520
1520
  PROCESSING_INSTRUCTION_NODE: { value: PROCESSING_INSTRUCTION_NODE },
1521
- COMMENT_NODE: { value: COMMENT_NODE },
1521
+ COMMENT_NODE: { value: COMMENT_NODE2 },
1522
1522
  DOCUMENT_NODE: { value: DOCUMENT_NODE },
1523
1523
  DOCUMENT_TYPE_NODE: { value: DOCUMENT_TYPE_NODE },
1524
1524
  DOCUMENT_FRAGMENT_NODE: { value: DOCUMENT_FRAGMENT_NODE },
@@ -58046,20 +58046,29 @@ var InputSanitizer = class {
58046
58046
  this.purifyConfig = this.createPurifyConfig();
58047
58047
  }
58048
58048
  /**
58049
- * Sanitize HTML content to prevent XSS attacks
58049
+ * Like sanitizeHTML, but a sanitiser failure is thrown instead of being
58050
+ * turned into an empty string. Use it wherever an empty result would be
58051
+ * read as "the page had no content" (the ConversionEngine on an injected
58052
+ * DOM: the Worker's server-side Markdown and the CLI), so a failure is
58053
+ * reported as a failure (#170).
58050
58054
  */
58051
- sanitizeHTML(html) {
58055
+ sanitizeHTMLOrThrow(html) {
58052
58056
  if (!html || typeof html !== "string") {
58053
58057
  return "";
58054
58058
  }
58059
+ return this.domEnv.DOMPurify.sanitize(html, {
58060
+ ALLOWED_TAGS: this.purifyConfig.ALLOWED_TAGS,
58061
+ ALLOWED_ATTR: this.purifyConfig.ALLOWED_ATTR,
58062
+ FORBID_TAGS: this.purifyConfig.FORBID_TAGS,
58063
+ FORBID_ATTR: this.purifyConfig.FORBID_ATTR
58064
+ });
58065
+ }
58066
+ /**
58067
+ * Sanitize HTML content to prevent XSS attacks
58068
+ */
58069
+ sanitizeHTML(html) {
58055
58070
  try {
58056
- const sanitized = this.domEnv.DOMPurify.sanitize(html, {
58057
- ALLOWED_TAGS: this.purifyConfig.ALLOWED_TAGS,
58058
- ALLOWED_ATTR: this.purifyConfig.ALLOWED_ATTR,
58059
- FORBID_TAGS: this.purifyConfig.FORBID_TAGS,
58060
- FORBID_ATTR: this.purifyConfig.FORBID_ATTR
58061
- });
58062
- return sanitized;
58071
+ return this.sanitizeHTMLOrThrow(html);
58063
58072
  } catch (error) {
58064
58073
  console.warn("HTML sanitization failed:", error);
58065
58074
  return "";
@@ -58196,6 +58205,7 @@ var InputSanitizer = class {
58196
58205
  ALLOWED_TAGS: [
58197
58206
  "p",
58198
58207
  "br",
58208
+ "hr",
58199
58209
  "div",
58200
58210
  "span",
58201
58211
  "h1",
@@ -58246,6 +58256,7 @@ var InputSanitizer = class {
58246
58256
  ALLOWED_TAGS: [
58247
58257
  "p",
58248
58258
  "br",
58259
+ "hr",
58249
58260
  "div",
58250
58261
  "span",
58251
58262
  "h1",
@@ -58312,7 +58323,11 @@ var InputSanitizer = class {
58312
58323
  return { ...this.config };
58313
58324
  }
58314
58325
  };
58315
- var defaultSanitizer = new InputSanitizer();
58326
+ var defaultSanitizerInstance;
58327
+ function getDefaultSanitizer() {
58328
+ defaultSanitizerInstance ??= new InputSanitizer();
58329
+ return defaultSanitizerInstance;
58330
+ }
58316
58331
  function normalizeSlackHTML(html, domParser) {
58317
58332
  if (!html || !html.includes("indent=")) return html;
58318
58333
  const doc = (domParser ?? new globalThis.DOMParser()).parseFromString(html, "text/html");
@@ -58343,7 +58358,7 @@ function normalizeSlackHTML(html, domParser) {
58343
58358
  }
58344
58359
  return root;
58345
58360
  };
58346
- const isListEl = (n) => n.nodeType === Node.ELEMENT_NODE && (n.nodeName === "UL" || n.nodeName === "OL");
58361
+ const isListEl = (n) => n.nodeType === 1 && (n.nodeName === "UL" || n.nodeName === "OL");
58347
58362
  const snapshot = Array.from(body.childNodes);
58348
58363
  let runStart = -1;
58349
58364
  const flushRun = (endIdx) => {
@@ -58377,7 +58392,7 @@ function sanitizeHTML(html, config) {
58377
58392
  const sanitizer = new InputSanitizer(config);
58378
58393
  return sanitizer.sanitizeHTML(html);
58379
58394
  }
58380
- return defaultSanitizer.sanitizeHTML(html);
58395
+ return getDefaultSanitizer().sanitizeHTML(html);
58381
58396
  }
58382
58397
  var PANEL_EMOJI = {
58383
58398
  info: "\u2139\uFE0F Info:",
@@ -58463,11 +58478,11 @@ function normalizeAtlassianTables(doc) {
58463
58478
  }
58464
58479
  }
58465
58480
  function stripUnrecognizedMacros(node) {
58466
- const ELEMENT_NODE = 1;
58481
+ const ELEMENT_NODE2 = 1;
58467
58482
  let i = 0;
58468
58483
  while (i < node.childNodes.length) {
58469
58484
  const child = node.childNodes[i];
58470
- if (child.nodeType === ELEMENT_NODE) {
58485
+ if (child.nodeType === ELEMENT_NODE2) {
58471
58486
  const el = child;
58472
58487
  const tagName = el.tagName.toLowerCase();
58473
58488
  if (tagName.startsWith("ac:") || tagName.startsWith("ri:")) {
@@ -58682,11 +58697,11 @@ function processConfluenceList(list, service, depth) {
58682
58697
  function processConfluenceListItem(item, service, depth) {
58683
58698
  let content = "";
58684
58699
  const TEXT_NODE = 3;
58685
- const ELEMENT_NODE = 1;
58700
+ const ELEMENT_NODE2 = 1;
58686
58701
  Array.from(item.childNodes).forEach((child) => {
58687
58702
  if (child.nodeType === TEXT_NODE) {
58688
58703
  content += child.textContent || "";
58689
- } else if (child.nodeType === ELEMENT_NODE) {
58704
+ } else if (child.nodeType === ELEMENT_NODE2) {
58690
58705
  const el = child;
58691
58706
  if (el.nodeName === "UL" || el.nodeName === "OL") {
58692
58707
  content += "\n" + processConfluenceList(el, service, depth + 1);
@@ -58904,8 +58919,46 @@ var ConversionEngine = class {
58904
58919
  rmarkdownService;
58905
58920
  slackService;
58906
58921
  confluenceService;
58907
- constructor() {
58922
+ dom;
58923
+ domSanitizer;
58924
+ constructor(options = {}) {
58925
+ this.dom = options.dom;
58908
58926
  this.initializeServices();
58927
+ if (this.dom) {
58928
+ for (const service of [
58929
+ this.githubService,
58930
+ this.commonmarkService,
58931
+ this.customService,
58932
+ this.discordService,
58933
+ this.redditService,
58934
+ this.rmarkdownService,
58935
+ this.slackService,
58936
+ this.confluenceService
58937
+ ]) {
58938
+ this.parseWithInjectedDom(service, this.dom);
58939
+ }
58940
+ }
58941
+ }
58942
+ /**
58943
+ * Make `service.turndown(html)` parse with the injected DOM. Turndown parses
58944
+ * a string by wrapping it in `<x-turndown id="turndown-root">` and handing
58945
+ * that element to its converter; this does the same with `dom.DOMParser` and
58946
+ * passes the element, which Turndown accepts in place of a string. Covers
58947
+ * the nested `service.turndown(el.outerHTML)` calls in the list rules too.
58948
+ */
58949
+ parseWithInjectedDom(service, dom) {
58950
+ const turndown = service.turndown.bind(service);
58951
+ service.turndown = (input) => {
58952
+ if (typeof input !== "string") return turndown(input);
58953
+ const doc = new dom.DOMParser().parseFromString(
58954
+ '<x-turndown id="turndown-root">' + input + "</x-turndown>",
58955
+ "text/html"
58956
+ );
58957
+ const root = doc.getElementById("turndown-root");
58958
+ root?.normalize();
58959
+ if (!root) throw new Error("Injected DOM did not produce the turndown root element");
58960
+ return turndown(root);
58961
+ };
58909
58962
  }
58910
58963
  initializeServices() {
58911
58964
  this.githubService = new import_turndown2.default({
@@ -58962,6 +59015,37 @@ var ConversionEngine = class {
58962
59015
  this.confluenceService = createConfluenceService(deps);
58963
59016
  this.addSlideMarkerRules(this.slackService);
58964
59017
  this.addSlideMarkerRules(this.confluenceService);
59018
+ [
59019
+ this.githubService,
59020
+ this.commonmarkService,
59021
+ this.customService,
59022
+ this.discordService,
59023
+ this.redditService,
59024
+ this.rmarkdownService,
59025
+ this.slackService,
59026
+ this.confluenceService
59027
+ ].forEach((service) => this.addGoogleDocsWrapperRule(service));
59028
+ }
59029
+ /**
59030
+ * Google Docs wraps every copied selection in
59031
+ * <b style="font-weight:normal" id="docs-internal-guid-...">. Treating that
59032
+ * as bold puts a stray pair of bold markers around the whole document, so a
59033
+ * <b>/<strong> whose inline style explicitly sets a non-bold weight is
59034
+ * rendered as its plain content.
59035
+ */
59036
+ addGoogleDocsWrapperRule(service) {
59037
+ service.addRule("googleDocsNonBoldWrapper", {
59038
+ filter: (node) => {
59039
+ if (node.nodeName !== "B" && node.nodeName !== "STRONG") return false;
59040
+ const style = (node.getAttribute("style") || "").toLowerCase();
59041
+ const matches = [...style.matchAll(/font-weight\s*:\s*([a-z0-9]+)/g)];
59042
+ if (matches.length === 0) return false;
59043
+ const weight = matches[matches.length - 1][1];
59044
+ if (weight === "normal" || weight === "lighter") return true;
59045
+ return /^\d+$/.test(weight) && parseInt(weight, 10) < 600;
59046
+ },
59047
+ replacement: (content) => content
59048
+ });
58965
59049
  }
58966
59050
  /**
58967
59051
  * Create Discord-optimized Turndown service
@@ -59017,8 +59101,8 @@ ${plainText}
59017
59101
  return node.nodeName === "INPUT" && node.type === "checkbox";
59018
59102
  },
59019
59103
  replacement: (_content, node) => {
59020
- const checked = node.checked;
59021
- return checked ? "\u2611 " : "\u2610 ";
59104
+ const checked = node.checked || node.hasAttribute("checked");
59105
+ return (checked ? "\u2611" : "\u2610") + this.checkboxSpacer(node);
59022
59106
  }
59023
59107
  });
59024
59108
  return service;
@@ -59046,9 +59130,9 @@ ${plainText}
59046
59130
  * Converts Unicode checkboxes to [x] and [ ]
59047
59131
  */
59048
59132
  normalizeGitHubCheckboxes(markdown) {
59049
- let result = markdown;
59050
- result = result.replace(/^([-*]\s+)[☑✓✔■⬛]/gm, "$1[x]");
59051
- result = result.replace(/^([-*]\s+)[☐✗✘□⬜]/gm, "$1[ ]");
59133
+ let result = markdown.replace(/^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)\\\[([ xX])\\\]/gm, "$1[$2]");
59134
+ result = result.replace(/^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)[☑✓✔■⬛]/gm, "$1[x]");
59135
+ result = result.replace(/^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)[☐✗✘□⬜]/gm, "$1[ ]");
59052
59136
  return result;
59053
59137
  }
59054
59138
  /**
@@ -59056,10 +59140,10 @@ ${plainText}
59056
59140
  * Converts [x], [ ], and other checkbox patterns to emoji
59057
59141
  */
59058
59142
  normalizeDiscordCheckboxes(markdown) {
59059
- let result = markdown.replace(/^([-*]\s+)\[x\]/gmi, "$1\u2611");
59060
- result = result.replace(/^([-*]\s+)[✓✔■⬛]/gm, "$1\u2611");
59061
- result = result.replace(/^([-*]\s+)\[\s?\]/gm, "$1\u2610");
59062
- result = result.replace(/^([-*]\s+)[✗✘□⬜]/gm, "$1\u2610");
59143
+ let result = markdown.replace(/^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)\\?\[x\\?\]/gmi, "$1\u2611");
59144
+ result = result.replace(/^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)[✓✔■⬛]/gm, "$1\u2611");
59145
+ result = result.replace(/^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)\\?\[\s?\\?\]/gm, "$1\u2610");
59146
+ result = result.replace(/^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)[✗✘□⬜]/gm, "$1\u2610");
59063
59147
  return result;
59064
59148
  }
59065
59149
  /**
@@ -59298,8 +59382,9 @@ ${plainText}
59298
59382
  return lines.join("\n");
59299
59383
  }
59300
59384
  flattenTables(html) {
59301
- if (typeof DOMParser === "undefined") return html;
59302
- const parser = new DOMParser();
59385
+ const Parser = this.dom?.DOMParser ?? (typeof DOMParser === "undefined" ? void 0 : DOMParser);
59386
+ if (!Parser) return html;
59387
+ const parser = new Parser();
59303
59388
  const doc = parser.parseFromString(`<div>${html}</div>`, "text/html");
59304
59389
  Array.from(doc.querySelectorAll("table")).forEach((table) => {
59305
59390
  const replacement = doc.createElement("div");
@@ -59459,10 +59544,11 @@ ${plainText}
59459
59544
  });
59460
59545
  }
59461
59546
  renderTableCell(cell) {
59547
+ const ELEMENT_NODE2 = 1;
59462
59548
  const blocks = [];
59463
59549
  let inlineBuffer = "";
59464
59550
  cell.childNodes.forEach((child) => {
59465
- if (child.nodeType === Node.ELEMENT_NODE && this.isTableCellBlock(child)) {
59551
+ if (child.nodeType === ELEMENT_NODE2 && this.isTableCellBlock(child)) {
59466
59552
  if (inlineBuffer.trim()) {
59467
59553
  blocks.push(inlineBuffer.trim());
59468
59554
  inlineBuffer = "";
@@ -59486,8 +59572,10 @@ ${plainText}
59486
59572
  return this.renderInlineNode(element).trim();
59487
59573
  }
59488
59574
  renderInlineNode(node) {
59489
- if (node.nodeType === Node.TEXT_NODE) return node.textContent || "";
59490
- if (node.nodeType !== Node.ELEMENT_NODE) return "";
59575
+ const TEXT_NODE = 3;
59576
+ const ELEMENT_NODE2 = 1;
59577
+ if (node.nodeType === TEXT_NODE) return node.textContent || "";
59578
+ if (node.nodeType !== ELEMENT_NODE2) return "";
59491
59579
  const element = node;
59492
59580
  const content = Array.from(element.childNodes).map((child) => this.renderInlineNode(child)).join("");
59493
59581
  const trimmed = content.trim();
@@ -59525,6 +59613,11 @@ ${plainText}
59525
59613
  escapeTableCell(content) {
59526
59614
  return content.replace(/\|/g, "\\|").replace(/\s*<br>\s*/g, "<br>").trim();
59527
59615
  }
59616
+ /** A space after a checkbox marker unless the label text already starts with one. */
59617
+ checkboxSpacer(node) {
59618
+ const next = node.nextSibling;
59619
+ return next && /^\s/.test(next.textContent || "") ? "" : " ";
59620
+ }
59528
59621
  addTaskListRules(service) {
59529
59622
  service.addRule("gfmTaskListCheckbox", {
59530
59623
  filter: (node) => {
@@ -59532,7 +59625,7 @@ ${plainText}
59532
59625
  },
59533
59626
  replacement: (_content, node) => {
59534
59627
  const checked = node.checked || node.hasAttribute("checked");
59535
- return checked ? "[x] " : "[ ] ";
59628
+ return (checked ? "[x]" : "[ ]") + this.checkboxSpacer(node);
59536
59629
  }
59537
59630
  });
59538
59631
  }
@@ -59598,60 +59691,78 @@ ${plainText}
59598
59691
  service.addRule("enhancedList", {
59599
59692
  filter: ["ul", "ol"],
59600
59693
  replacement: (content, node) => {
59601
- const listType = node?.nodeName.toLowerCase();
59602
59694
  const items = Array.from(node.children).filter(
59603
59695
  (child) => child.nodeName.toLowerCase() === "li"
59604
59696
  );
59605
59697
  if (items.length === 0) return content;
59606
- let markdown = "\n";
59607
- const isOrdered = listType === "ol";
59608
- const marker = isOrdered ? "1." : service.options.bulletListMarker || "-";
59609
- items.forEach((item, index) => {
59610
- const itemContent = this.processListItem(item, service);
59611
- const actualMarker = isOrdered ? `${index + 1}.` : marker;
59612
- markdown += `${actualMarker} ${itemContent}
59613
- `;
59614
- });
59615
- return markdown + "\n";
59616
- }
59617
- });
59618
- }
59619
- processListItem(item, service, depth = 0) {
59620
- let content = "";
59621
- const TEXT_NODE = 3;
59622
- const ELEMENT_NODE = 1;
59623
- Array.from(item.childNodes).forEach((child) => {
59624
- if (child.nodeType === TEXT_NODE) {
59625
- content += child.textContent || "";
59626
- } else if (child.nodeType === ELEMENT_NODE) {
59627
- const element = child;
59628
- if (element.nodeName.toLowerCase() === "ul" || element.nodeName.toLowerCase() === "ol") {
59629
- const nestedContent = this.processNestedList(element, service, depth + 1);
59630
- content += "\n" + nestedContent;
59631
- } else {
59632
- content += service.turndown(element.outerHTML);
59633
- }
59698
+ return "\n" + this.renderList(node, service) + "\n";
59634
59699
  }
59635
59700
  });
59636
- return content.trim();
59637
59701
  }
59638
- processNestedList(list, service, depth) {
59702
+ /**
59703
+ * Render a list with markers at column 0. Continuation lines of each item
59704
+ * (extra paragraphs, nested lists) are indented to the item's content
59705
+ * column (marker width + 1), which is what CommonMark/GFM require for them
59706
+ * to stay inside the item: 2 spaces under "- ", 3 under "1. ", 4 under "10. ".
59707
+ */
59708
+ renderList(list, service) {
59639
59709
  const items = Array.from(list.children).filter(
59640
59710
  (child) => child.nodeName.toLowerCase() === "li"
59641
59711
  );
59642
- if (items.length === 0) return "";
59643
- let markdown = "";
59644
59712
  const isOrdered = list.nodeName.toLowerCase() === "ol";
59645
- const indent = " ".repeat(depth);
59646
- const marker = isOrdered ? "1." : service.options.bulletListMarker || "-";
59713
+ const bullet = service.options.bulletListMarker || "-";
59714
+ let markdown = "";
59647
59715
  items.forEach((item, index) => {
59648
- const itemContent = this.processListItem(item, service, depth);
59649
- const actualMarker = isOrdered ? `${index + 1}.` : marker;
59650
- markdown += `${indent}${actualMarker} ${itemContent}
59716
+ const marker = isOrdered ? `${index + 1}.` : bullet;
59717
+ const indent = " ".repeat(marker.length + 1);
59718
+ const body = this.processListItem(item, service);
59719
+ const lines = body.split("\n");
59720
+ const indented = lines.map((line, i) => i === 0 || line === "" ? line : indent + line).join("\n");
59721
+ markdown += `${marker} ${indented}
59651
59722
  `;
59652
59723
  });
59653
59724
  return markdown;
59654
59725
  }
59726
+ /**
59727
+ * Render one list item's content relative to its own content column.
59728
+ * Inline runs and block children are converted together, so whitespace
59729
+ * between sibling spans survives (Google Docs emits one span per run) and
59730
+ * multiple paragraphs stay separated.
59731
+ */
59732
+ processListItem(item, service) {
59733
+ const parts = [];
59734
+ const ELEMENT_NODE2 = 1;
59735
+ const doc = item.ownerDocument;
59736
+ let buffer = doc.createElement("div");
59737
+ const flush = () => {
59738
+ if (buffer.childNodes.length > 0) {
59739
+ const md = service.turndown(buffer).trim();
59740
+ if (md) parts.push(md);
59741
+ }
59742
+ buffer = doc.createElement("div");
59743
+ };
59744
+ Array.from(item.childNodes).forEach((child) => {
59745
+ const name = child.nodeType === ELEMENT_NODE2 ? child.nodeName.toLowerCase() : "";
59746
+ if (name === "ul" || name === "ol") {
59747
+ flush();
59748
+ const nested = this.renderList(child, service).replace(/\n+$/, "");
59749
+ if (nested) parts.push(nested);
59750
+ } else {
59751
+ buffer.appendChild(child.cloneNode(true));
59752
+ }
59753
+ });
59754
+ flush();
59755
+ let content = "";
59756
+ parts.forEach((part, i) => {
59757
+ if (i === 0) {
59758
+ content = part;
59759
+ return;
59760
+ }
59761
+ const isList = /^(?:[-*+]|\d+\.) /.test(part);
59762
+ content += (isList ? "\n" : "\n\n") + part;
59763
+ });
59764
+ return content;
59765
+ }
59655
59766
  addSlideMarkerRules(service) {
59656
59767
  service.addRule("pptxSlideMarker", {
59657
59768
  filter: (node) => node.nodeName === "DIV" && node.hasAttribute("data-slide-marker"),
@@ -59764,6 +59875,10 @@ ${plainText}
59764
59875
  };
59765
59876
  }
59766
59877
  sanitize(html) {
59878
+ if (this.dom) {
59879
+ this.domSanitizer ??= new InputSanitizer({ level: "moderate" }, this.dom);
59880
+ return this.domSanitizer.sanitizeHTMLOrThrow(html);
59881
+ }
59767
59882
  return sanitizeHTML(html, {
59768
59883
  level: "moderate"
59769
59884
  // Use moderate level for conversion
@@ -63205,7 +63320,7 @@ var PptxAdapter = class {
63205
63320
  };
63206
63321
  var pptxAdapter = new PptxAdapter();
63207
63322
 
63208
- // ../../src/shared/readability-processor.ts
63323
+ // ../../src/shared/readability-core.ts
63209
63324
  var import_readability = __toESM(require_readability(), 1);
63210
63325
  var LinkedomProvider = class {
63211
63326
  createDocument(html) {
@@ -63503,6 +63618,118 @@ function buildCachedProvider(config, lastValidated) {
63503
63618
  return new CachedEntitlementProvider(cachedState);
63504
63619
  }
63505
63620
 
63621
+ // ../../src/shared/linkedom-sanitizer.ts
63622
+ var FORBID_CONTENTS = /* @__PURE__ */ new Set([
63623
+ "annotation-xml",
63624
+ "audio",
63625
+ "colgroup",
63626
+ "desc",
63627
+ "foreignobject",
63628
+ "head",
63629
+ "iframe",
63630
+ "math",
63631
+ "mi",
63632
+ "mn",
63633
+ "mo",
63634
+ "ms",
63635
+ "mtext",
63636
+ "noembed",
63637
+ "noframes",
63638
+ "noscript",
63639
+ "plaintext",
63640
+ "script",
63641
+ "selectedcontent",
63642
+ "style",
63643
+ "svg",
63644
+ "template",
63645
+ "thead",
63646
+ "title",
63647
+ "video",
63648
+ "xmp"
63649
+ ]);
63650
+ var DATA_URI_TAGS = /* @__PURE__ */ new Set(["audio", "video", "img", "source", "image", "track"]);
63651
+ var URI_SAFE_ATTRIBUTES = /* @__PURE__ */ new Set([
63652
+ "alt",
63653
+ "class",
63654
+ "for",
63655
+ "id",
63656
+ "label",
63657
+ "name",
63658
+ "pattern",
63659
+ "placeholder",
63660
+ "role",
63661
+ "summary",
63662
+ "title",
63663
+ "value",
63664
+ "style",
63665
+ "xmlns"
63666
+ ]);
63667
+ var IS_ALLOWED_URI = /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i;
63668
+ var ATTR_WHITESPACE = /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g;
63669
+ var ELEMENT_NODE = 1;
63670
+ var COMMENT_NODE = 8;
63671
+ function toSet(list) {
63672
+ return new Set((list ?? []).map((s) => s.toLowerCase()));
63673
+ }
63674
+ function attrAllowed(name, allowed, wildcards) {
63675
+ return allowed.has(name) || wildcards.some((prefix) => name.startsWith(prefix));
63676
+ }
63677
+ function isSafeValue(tag, name, value) {
63678
+ if (URI_SAFE_ATTRIBUTES.has(name) || !value) return true;
63679
+ if (IS_ALLOWED_URI.test(value.replace(ATTR_WHITESPACE, ""))) return true;
63680
+ return (name === "src" || name === "href" || name === "xlink:href") && value.startsWith("data:") && DATA_URI_TAGS.has(tag);
63681
+ }
63682
+ function sanitizeWithLinkedom(html, config, parse) {
63683
+ if (!html) return "";
63684
+ const allowedTags = toSet(config.ALLOWED_TAGS);
63685
+ const forbidTags = toSet(config.FORBID_TAGS);
63686
+ const allowedAttrList = (config.ALLOWED_ATTR ?? []).map((s) => s.toLowerCase());
63687
+ const allowedAttrs = new Set(allowedAttrList.filter((a) => !a.endsWith("*")));
63688
+ const attrWildcards = allowedAttrList.filter((a) => a.endsWith("*")).map((a) => a.slice(0, -1));
63689
+ const forbidAttrs = toSet(config.FORBID_ATTR);
63690
+ const doc = parse(`<!DOCTYPE html><html><head></head><body>${html}</body></html>`);
63691
+ const body = doc.body;
63692
+ if (!body) return "";
63693
+ const toUnwrap = [];
63694
+ const stack = [body];
63695
+ while (stack.length > 0) {
63696
+ const parent = stack.pop();
63697
+ for (const child of Array.from(parent.childNodes)) {
63698
+ if (child.nodeType === COMMENT_NODE) {
63699
+ parent.removeChild(child);
63700
+ continue;
63701
+ }
63702
+ if (child.nodeType !== ELEMENT_NODE) continue;
63703
+ const el = child;
63704
+ const tag = el.nodeName.toLowerCase();
63705
+ if (!allowedTags.has(tag) || forbidTags.has(tag)) {
63706
+ if (FORBID_CONTENTS.has(tag)) {
63707
+ parent.removeChild(el);
63708
+ } else {
63709
+ toUnwrap.push(el);
63710
+ stack.push(el);
63711
+ }
63712
+ continue;
63713
+ }
63714
+ for (const attr of Array.from(el.attributes)) {
63715
+ const name = attr.name.toLowerCase();
63716
+ if (name.startsWith("on") || forbidAttrs.has(name) || !attrAllowed(name, allowedAttrs, attrWildcards) || !isSafeValue(tag, name, attr.value)) {
63717
+ el.removeAttribute(attr.name);
63718
+ }
63719
+ }
63720
+ stack.push(el);
63721
+ }
63722
+ }
63723
+ for (let i = toUnwrap.length - 1; i >= 0; i--) {
63724
+ const el = toUnwrap[i];
63725
+ const parent = el.parentNode;
63726
+ if (!parent) continue;
63727
+ while (el.firstChild) parent.insertBefore(el.firstChild, el);
63728
+ parent.removeChild(el);
63729
+ }
63730
+ return body.innerHTML;
63731
+ }
63732
+
63506
63733
  // ../../src/shared/token-estimator.ts
63507
63734
  var CJK_REGEX3 = /[\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/g;
63508
63735
  var MAX_INPUT_LENGTH = 1e6;
@@ -64052,6 +64279,18 @@ async function binaryToMarkdown(buffer, type, format) {
64052
64279
  }
64053
64280
  }
64054
64281
  }
64282
+ function linkedomDomEnvironment() {
64283
+ const parseHTML2 = globalThis.__FF_parseHTML;
64284
+ if (!parseHTML2) throw new Error("The CLI shim must be loaded before converting a URL");
64285
+ const parseDocument = (html) => parseHTML2(html).document;
64286
+ const { window: window3 } = parseHTML2("<!DOCTYPE html><html><head></head><body></body></html>");
64287
+ return {
64288
+ DOMParser: window3.DOMParser,
64289
+ DOMPurify: {
64290
+ sanitize: (html, config) => sanitizeWithLinkedom(html, config ?? {}, parseDocument)
64291
+ }
64292
+ };
64293
+ }
64055
64294
  var DEFAULT_PROXY_URL = "https://formatferry.vibingfun.com";
64056
64295
  async function urlToMarkdown(url, apiKey, format, proxyBaseUrl = process.env.FORMATFERRY_PROXY_URL ?? DEFAULT_PROXY_URL) {
64057
64296
  const extractEndpoint = `${proxyBaseUrl}/v1/extract`;
@@ -64063,7 +64302,9 @@ async function urlToMarkdown(url, apiKey, format, proxyBaseUrl = process.env.FOR
64063
64302
  "Content-Type": "application/json",
64064
64303
  "X-API-Key": apiKey
64065
64304
  },
64066
- body: JSON.stringify({ url })
64305
+ // Readability and the chosen flavour run locally, so ask for the page's
64306
+ // HTML (the API's default is Markdown).
64307
+ body: JSON.stringify({ url, format: "html" })
64067
64308
  });
64068
64309
  } catch (err) {
64069
64310
  const message = err instanceof Error ? err.message : String(err);
@@ -64074,18 +64315,19 @@ async function urlToMarkdown(url, apiKey, format, proxyBaseUrl = process.env.FOR
64074
64315
  if (!response.ok) {
64075
64316
  let errorMessage = `Proxy returned ${response.status}`;
64076
64317
  try {
64077
- const body = await response.json();
64078
- errorMessage = body.error ?? body.message ?? errorMessage;
64318
+ const body2 = await response.json();
64319
+ errorMessage = body2.error ?? body2.message ?? errorMessage;
64079
64320
  } catch {
64080
64321
  }
64081
64322
  process.stderr.write(`Error: ${errorMessage}
64082
64323
  `);
64083
64324
  process.exit(1);
64084
64325
  }
64085
- const { html } = await response.json();
64326
+ const body = await response.json();
64327
+ const html = body.content ?? body.html ?? "";
64086
64328
  const domProvider = new LinkedomProvider();
64087
64329
  const article = extractArticle(html, domProvider);
64088
- const engine = new ConversionEngine();
64330
+ const engine = new ConversionEngine({ dom: linkedomDomEnvironment() });
64089
64331
  const result = await engine.convert(article.content, {
64090
64332
  format,
64091
64333
  preserveTables: true,
@@ -64589,8 +64831,8 @@ import { readFileSync as readFileSync5 } from "fs";
64589
64831
  import { resolve, dirname as dirname3 } from "path";
64590
64832
  import { fileURLToPath } from "url";
64591
64833
  var getVersion = () => {
64592
- if ("1.0.33") {
64593
- return "1.0.33";
64834
+ if ("1.0.35") {
64835
+ return "1.0.35";
64594
64836
  }
64595
64837
  try {
64596
64838
  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.33",
3
+ "version": "1.0.35",
4
4
  "description": "Format Ferry CLI — Convert HTML, DOCX, PDF, XLSX, CSV, JSON, XML, PPTX to Markdown",
5
5
  "keywords": [
6
6
  "markdown",