@ryangarber/markitdown-ts 0.1.5 → 0.2.0

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.
package/dist/cli.mjs CHANGED
@@ -1,18 +1,28 @@
1
+ #!/usr/bin/env node
1
2
  import { readFile } from "node:fs/promises";
2
- import { Command } from "@commander-js/extra-typings";
3
- import turndownPluginGfm from "@joplin/turndown-plugin-gfm";
4
- import TurndownService from "turndown";
3
+ import { basename } from "node:path";
4
+ import { argument, commandLine, flag, message, object, option, optional, string, withDefault } from "@optique/core";
5
+ import { url } from "@optique/core/message";
6
+ import { defineProgram } from "@optique/core/program";
7
+ import { path, runSync } from "@optique/run";
5
8
  import { parseHTML } from "linkedom";
9
+ import TurndownPluginGfm from "@truto/turndown-plugin-gfm";
10
+ import TurndownService from "turndown";
11
+ import { fileTypeFromBuffer } from "file-type";
12
+ import { Mime } from "mime";
13
+ import otherTypes from "mime/types/other.js";
14
+ import standardTypes from "mime/types/standard.js";
6
15
  import mammoth from "mammoth";
7
16
  import { extractText, getDocumentProxy, getMeta } from "unpdf";
8
17
  import { pptxToHtml } from "@jvmr/pptx-to-html";
9
18
  import { DOMParser, Document, Element, Node } from "@xmldom/xmldom";
10
19
  import readExcelFile from "read-excel-file/universal";
11
20
  import { unzipSync } from "fflate";
12
- //#region package.json
21
+ // package.json
13
22
  var version = "0.1.5";
14
- //#endregion
15
- //#region src/bytes.ts
23
+ var bugs = { "url": "https://github.com/ryangarber/markitdown-ts/issues" };
24
+ var author = "Ryan Garber";
25
+ // src/utils/bytes.ts
16
26
  async function toUint8Array(source) {
17
27
  if (source instanceof Uint8Array) return source;
18
28
  if (isBlob(source)) return new Uint8Array(await source.arrayBuffer());
@@ -28,12 +38,11 @@ function decodeText(source) {
28
38
  function isBlob(value) {
29
39
  return typeof Blob !== "undefined" && value instanceof Blob;
30
40
  }
31
- //#endregion
32
- //#region src/custom-turndown.ts
33
- var CustomTurnDown = class {
41
+ // src/utils/turndown.ts
42
+ var Turndown = class {
34
43
  convertSoup(document) {
35
44
  const service = new TurndownService({ headingStyle: "atx" });
36
- service.use(turndownPluginGfm.gfm);
45
+ service.use(TurndownPluginGfm);
37
46
  service.addRule("anchor tags", {
38
47
  filter: ["a"],
39
48
  replacement(content, node) {
@@ -68,48 +77,166 @@ var CustomTurnDown = class {
68
77
  return `![${alt}](${source}${title ? ` "${title}"` : ""})`;
69
78
  }
70
79
  });
71
- return service.turndown(document);
80
+ return service.turndown(serializeNode(document));
72
81
  }
73
82
  };
74
- //#endregion
75
- //#region src/html-document.ts
76
- function parseHtmlDocument(html) {
77
- return parseHTML(html).document;
83
+ /**
84
+ * Turndown and its GFM plugin rely on DOM APIs (e.g. `HTMLTableElement.rows`, `Element.closest`)
85
+ * that linkedom does not implement, which silently drops content such as tables. Serializing
86
+ * lets Turndown re-parse the markup with its own spec-compliant DOM.
87
+ */
88
+ function serializeNode(node) {
89
+ if (typeof node === "string") return node;
90
+ if (node.nodeType === node.DOCUMENT_NODE) return node.documentElement?.outerHTML ?? "";
91
+ if (node.nodeType === node.ELEMENT_NODE) return node.outerHTML;
92
+ if (node.nodeType === node.TEXT_NODE) return escapeHtml$1(node.textContent ?? "");
93
+ return [...node.childNodes].map(serializeNode).join("");
94
+ }
95
+ function escapeHtml$1(value) {
96
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
97
+ }
98
+ // src/utils/html.ts
99
+ function parseHtmlDocument(html, transform) {
100
+ const document = parseHTML(wrapFragment(html)).document;
101
+ return Object.fromEntries(Object.entries(transform(document)).map(([key, value]) => {
102
+ if (value.type === "node") return [key, (Array.isArray(value.value) ? value.value : [value.value]).map((node) => new Turndown().convertSoup(node).split(/\n+/).map((line) => line.trim()).filter(Boolean).join("\n\n")).join("\n\n")];
103
+ return [key, value.value];
104
+ }));
105
+ }
106
+ /**
107
+ * linkedom only parses complete documents: given a fragment (e.g. mammoth output or an RSS
108
+ * description), it promotes the first element to `documentElement` and drops its siblings
109
+ * from the tree, so wrap fragments in a full document first.
110
+ */
111
+ function wrapFragment(html) {
112
+ if (/<html[\s>]/i.test(html)) return html;
113
+ if (/<(head|body)[\s>]/i.test(html)) return `<html>${html}</html>`;
114
+ return `<html><head></head><body>${html}</body></html>`;
115
+ }
116
+ // src/utils/mime.ts
117
+ const mime = new Mime(standardTypes, otherTypes);
118
+ function extensionFromMime(contentType) {
119
+ if (!contentType) return null;
120
+ return extensionNormalized(mime.getExtension(contentType));
121
+ }
122
+ async function extensionFromBuffer(buffer) {
123
+ const type = await fileTypeFromBuffer(buffer);
124
+ if (!type) return null;
125
+ return extensionNormalized(type.ext);
126
+ }
127
+ /**
128
+ * Sniffs text formats that have no magic number. Returns null for binary content, and `txt`
129
+ * for text that is not recognized as anything more specific.
130
+ */
131
+ function extensionFromText(buffer) {
132
+ let text;
133
+ try {
134
+ text = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
135
+ } catch {
136
+ return null;
137
+ }
138
+ if (hasBinaryControlCharacters(text)) return null;
139
+ const start = text.trimStart().slice(0, 1024).toLowerCase();
140
+ if (start.startsWith("<")) {
141
+ if (/<rss[\s>]/.test(start)) return "rss";
142
+ if (/<feed[\s>]/.test(start)) return "atom";
143
+ if (/^<!doctype html|<(html|head|body)[\s>]/.test(start)) return "html";
144
+ if (start.startsWith("<?xml")) return "xml";
145
+ }
146
+ if (start.startsWith("{") && /"nbformat"\s*:/.test(text) && /"cells"\s*:/.test(text)) return "ipynb";
147
+ return "txt";
148
+ }
149
+ function hasBinaryControlCharacters(text) {
150
+ for (let index = 0; index < text.length; index++) {
151
+ const code = text.charCodeAt(index);
152
+ if (code < 32 && (code < 9 || code > 13) && code !== 27) return true;
153
+ }
154
+ return false;
155
+ }
156
+ function extensionFromPathname(pathname) {
157
+ const filename = pathname.split("/").pop() ?? "";
158
+ const dot = filename.lastIndexOf(".");
159
+ return dot > 0 ? extensionNormalized(filename.slice(dot)) : null;
160
+ }
161
+ /** Reads the filename from a Content-Disposition header, preferring the RFC 5987 form. */
162
+ function filenameFromDisposition(disposition) {
163
+ if (!disposition) return null;
164
+ const extended = disposition.match(/filename\*\s*=\s*[\w-]*'[^']*'([^;]+)/i)?.[1]?.trim();
165
+ if (extended) try {
166
+ return decodeURIComponent(extended);
167
+ } catch {
168
+ return extended;
169
+ }
170
+ const plain = disposition.match(/filename\s*=\s*(?:"([^"]*)"|([^;]+))/i);
171
+ return (plain?.[1] ?? plain?.[2])?.trim() || null;
172
+ }
173
+ /**
174
+ * Lists candidate extensions for the content, most reliable first: explicit extensions, then
175
+ * names, then declared MIME types, then the content itself.
176
+ */
177
+ async function detectExtensions(buffer, { extension, filename, urls = [], contentType } = {}) {
178
+ const candidates = [
179
+ extensionNormalized(extension),
180
+ filename ? extensionFromPathname(filename) : null,
181
+ ...urls.map((url) => url ? extensionFromUrl(url) : null),
182
+ extensionFromMime(contentType),
183
+ await extensionFromBuffer(buffer) ?? extensionFromText(buffer)
184
+ ];
185
+ return [...new Set(candidates.filter((candidate) => !!candidate))];
186
+ }
187
+ function extensionFromUrl(url) {
188
+ try {
189
+ return extensionFromPathname(decodeURIComponent(new URL(url).pathname));
190
+ } catch {
191
+ return null;
192
+ }
193
+ }
194
+ function extensionNormalized(extension, withDot = false) {
195
+ if (!extension) return "";
196
+ const normalized = extension.trim().toLowerCase().replace(/^[.-]+/, "");
197
+ return withDot ? `.${normalized}` : normalized;
198
+ }
199
+ function isTextExtension(extension) {
200
+ const type = mime.getType(extension);
201
+ if (!type) return false;
202
+ return type.startsWith("text/");
78
203
  }
79
- //#endregion
80
- //#region src/converters/bingserp.ts
204
+ // src/converters/bingserp.ts
81
205
  const BING_SEARCH_URL = /^https:\/\/www\.bing\.com\/search\?q=/i;
82
206
  const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
83
207
  var BingSerpConverter = class {
84
208
  async convert(source, options = {}) {
85
- const extension = options.file_extension?.toLowerCase() ?? "";
86
- const url = options.url ?? "";
87
- if (extension !== ".html" && extension !== ".htm" || !BING_SEARCH_URL.test(url)) return null;
88
- const document = parseHtmlDocument(decodeText(source));
89
- const query = new URL(url).searchParams.get("q") ?? "";
90
- document.querySelectorAll(".tptt").forEach((element) => {
91
- if (element.textContent) element.textContent += " ";
92
- });
93
- document.querySelectorAll(".algoSlug_icon").forEach((element) => {
94
- element.remove();
95
- });
96
- const turndown = new CustomTurnDown();
97
- const markdown = `## A Bing search for '${query}' found the following results:\n\n${[...document.querySelectorAll(".b_algo")].map((result) => {
98
- result.querySelectorAll("a[href]").forEach((anchor) => {
99
- const href = anchor.getAttribute("href");
100
- if (!href) return;
101
- try {
102
- const encoded = new URL(href, url).searchParams.get("u");
103
- if (encoded) anchor.setAttribute("href", decodeBingUrl(encoded));
104
- } catch {}
209
+ if (options.extension !== "html" && options.extension !== "htm" || !BING_SEARCH_URL.test(options.url ?? "")) return null;
210
+ let query = new URL(options.url ?? "").searchParams.get("q") || null;
211
+ if (query) query = `Search results for "${query}"`;
212
+ return parseHtmlDocument(decodeText(source), (document) => {
213
+ document.querySelectorAll(".tptt").forEach((element) => {
214
+ if (element.textContent) element.textContent += " ";
105
215
  });
106
- return turndown.convertSoup(result).split(/\n+/).map((line) => line.trim()).filter(Boolean).join("\n");
107
- }).join("\n\n")}`;
108
- return {
109
- title: document.title || null,
110
- markdown,
111
- text_content: markdown
112
- };
216
+ document.querySelectorAll(".algoSlug_icon").forEach((element) => {
217
+ element.remove();
218
+ });
219
+ return {
220
+ title: {
221
+ type: "metadata",
222
+ value: query || document.title || null
223
+ },
224
+ markdown: {
225
+ type: "node",
226
+ value: [...document.querySelectorAll(".b_algo")].map((result) => {
227
+ result.querySelectorAll("a[href]").forEach((anchor) => {
228
+ const href = anchor.getAttribute("href");
229
+ if (!href) return;
230
+ try {
231
+ const encoded = new URL(href, options.url ?? "").searchParams.get("u");
232
+ if (encoded) anchor.setAttribute("href", decodeBingUrl(encoded));
233
+ } catch {}
234
+ });
235
+ return result;
236
+ })
237
+ }
238
+ };
239
+ });
113
240
  }
114
241
  };
115
242
  function decodeBingUrl(encoded) {
@@ -129,52 +256,46 @@ function decodeBingUrl(encoded) {
129
256
  }
130
257
  return new TextDecoder().decode(new Uint8Array(bytes));
131
258
  }
132
- //#endregion
133
- //#region src/converters/html.ts
259
+ // src/converters/html.ts
134
260
  var HtmlConverter = class {
135
261
  async convert(source, options) {
136
- const extension = options.file_extension?.toLowerCase() ?? "";
137
- if (extension !== ".html" && extension !== ".htm") return null;
262
+ if (options.extension !== "html" && options.extension !== "htm") return null;
138
263
  return this.convertHtml(decodeText(source));
139
264
  }
140
265
  async convertHtml(html) {
141
- const document = parseHtmlDocument(html);
142
- const title = document.title;
143
- document.querySelectorAll("script, style, title").forEach((element) => {
144
- element.remove();
266
+ return parseHtmlDocument(html, (document) => {
267
+ const title = document.title || null;
268
+ document.querySelectorAll("script, style, title").forEach((element) => {
269
+ element.remove();
270
+ });
271
+ return {
272
+ title: {
273
+ type: "metadata",
274
+ value: title
275
+ },
276
+ markdown: {
277
+ type: "node",
278
+ value: document
279
+ }
280
+ };
145
281
  });
146
- const markdown = new CustomTurnDown().convertSoup(document);
147
- return {
148
- title: title || null,
149
- markdown,
150
- text_content: markdown
151
- };
152
282
  }
153
283
  };
154
- //#endregion
155
- //#region src/converters/docx.ts
284
+ // src/converters/docx.ts
156
285
  var DocxConverter = class extends HtmlConverter {
157
286
  async convert(source, options) {
158
- if (options.file_extension?.toLowerCase() !== ".docx") return null;
287
+ if (options.extension !== "docx") return null;
159
288
  let mammothInput = { arrayBuffer: toArrayBuffer(source) };
160
289
  if (typeof process !== "undefined" && process.versions !== null && process.versions.node !== null) mammothInput = { buffer: Buffer.from(source) };
161
- const mammothOptions = {
162
- styleMap: options.styleMap,
163
- includeEmbeddedStyleMap: options.includeEmbeddedStyleMap,
164
- includeDefaultStyleMap: options.includeDefaultStyleMap,
165
- ignoreEmptyParagraphs: options.ignoreEmptyParagraphs,
166
- idPrefix: options.idPrefix
167
- };
168
- if (options.transformDocument) mammothOptions.transformDocument = options.transformDocument;
290
+ const mammothOptions = Object.fromEntries(Object.entries(options.mammoth ?? {}).flatMap(([key, value]) => value !== void 0 ? [[key, value]] : []));
169
291
  const html = await mammoth.convertToHtml(mammothInput, mammothOptions);
170
292
  return this.convertHtml(html.value);
171
293
  }
172
294
  };
173
- //#endregion
174
- //#region src/converters/ipynb.ts
295
+ // src/converters/ipynb.ts
175
296
  var IpynbConverter = class {
176
297
  async convert(source, options = {}) {
177
- if (options.file_extension?.toLowerCase() !== ".ipynb") return null;
298
+ if (options.extension !== "ipynb") return null;
178
299
  const notebook = JSON.parse(decodeText(source));
179
300
  const output = [];
180
301
  let title = notebook.metadata?.title ?? null;
@@ -189,99 +310,49 @@ var IpynbConverter = class {
189
310
  const markdown = output.join("\n\n");
190
311
  return {
191
312
  title,
192
- markdown,
193
- text_content: markdown
313
+ markdown
194
314
  };
195
315
  }
196
316
  };
197
- //#endregion
198
- //#region src/converters/pdf.ts
317
+ // src/converters/pdf.ts
199
318
  var PdfConverter = class {
200
319
  async convert(source, options) {
201
- if (options.file_extension?.toLowerCase() !== ".pdf") return null;
320
+ if (options.extension !== "pdf") return null;
202
321
  const pdf = await getDocumentProxy(source.slice());
203
322
  try {
204
323
  const [{ text }, { info }] = await Promise.all([extractText(pdf, { mergePages: true }), getMeta(pdf)]);
205
324
  return {
206
325
  title: typeof info.Title === "string" && info.Title.trim() ? info.Title : null,
207
- markdown: text,
208
- text_content: text
326
+ markdown: text
209
327
  };
210
328
  } finally {
211
329
  await pdf.loadingTask.destroy();
212
330
  }
213
331
  }
214
332
  };
215
- //#endregion
216
- //#region src/mime.ts
217
- const MIME_EXTENSIONS = {
218
- "application/atom+xml": ".atom",
219
- "application/json": ".json",
220
- "application/pdf": ".pdf",
221
- "application/zip": ".zip",
222
- "application/rss+xml": ".rss",
223
- "application/x-pdf": ".pdf",
224
- "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
225
- "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
226
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
227
- "application/xml": ".xml",
228
- "text/csv": ".csv",
229
- "text/html": ".html",
230
- "text/markdown": ".md",
231
- "text/plain": ".txt",
232
- "text/xml": ".xml"
233
- };
234
- const TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
235
- ".csv",
236
- ".json",
237
- ".log",
238
- ".markdown",
239
- ".md",
240
- ".text",
241
- ".txt"
242
- ]);
243
- function extensionFromMime(contentType) {
244
- if (!contentType) return null;
245
- const mime = contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
246
- return MIME_EXTENSIONS[mime] ?? (mime.startsWith("text/") ? ".txt" : null);
247
- }
248
- function isTextExtension(extension) {
249
- return TEXT_EXTENSIONS.has(extension.toLowerCase());
250
- }
251
- function extensionFromPathname(pathname) {
252
- const filename = pathname.split("/").pop() ?? "";
253
- const dot = filename.lastIndexOf(".");
254
- return dot > 0 ? filename.slice(dot).toLowerCase() : null;
255
- }
256
- //#endregion
257
- //#region src/converters/plain-text.ts
333
+ // src/converters/plain-text.ts
258
334
  var PlainTextConverter = class {
259
335
  async convert(source, options = {}) {
260
- if (!isTextExtension(options.file_extension ?? "")) return null;
261
- const content = decodeText(source);
336
+ if (!isTextExtension(options.extension ?? "")) return null;
262
337
  return {
263
338
  title: null,
264
- markdown: content,
265
- text_content: content
339
+ markdown: decodeText(source)
266
340
  };
267
341
  }
268
342
  };
269
- //#endregion
270
- //#region src/converters/pptx.ts
343
+ // src/converters/pptx.ts
271
344
  var PptxConverter = class extends HtmlConverter {
272
345
  async convert(source, options) {
273
- if (options.file_extension?.toLowerCase() !== ".pptx") return null;
346
+ if (options.extension !== "pptx") return null;
274
347
  const slides = await pptxToHtml(toArrayBuffer(source), { domParserFactory: createPptxDomParser });
275
348
  const sections = [];
276
349
  for (const [index, html] of slides.entries()) {
277
350
  const result = await this.convertHtml(`<html><body>${html}</body></html>`);
278
351
  sections.push(`<!-- Slide number: ${index + 1} -->\n${result?.markdown.trim() ?? ""}`);
279
352
  }
280
- const markdown = sections.join("\n\n");
281
353
  return {
282
354
  title: null,
283
- markdown,
284
- text_content: markdown
355
+ markdown: sections.join("\n\n")
285
356
  };
286
357
  }
287
358
  };
@@ -345,32 +416,33 @@ function defineGetter(target, name, get) {
345
416
  get
346
417
  });
347
418
  }
348
- //#endregion
349
- //#region src/converters/wikipedia.ts
419
+ // src/converters/wikipedia.ts
350
420
  const WIKIPEDIA_URL = /^https?:\/\/[a-z]{2,3}\.wikipedia\.org\//i;
351
421
  var WikipediaConverter = class {
352
422
  async convert(source, options = {}) {
353
- const extension = options.file_extension?.toLowerCase() ?? "";
354
- if (extension !== ".html" && extension !== ".htm" || !WIKIPEDIA_URL.test(options.url ?? "")) return null;
355
- const document = parseHtmlDocument(decodeText(source));
356
- document.querySelectorAll("script, style").forEach((element) => {
357
- element.remove();
423
+ if (options.extension !== "html" && options.extension !== "htm" || !WIKIPEDIA_URL.test(options.url ?? "")) return null;
424
+ return parseHtmlDocument(decodeText(source), (document) => {
425
+ document.querySelectorAll("script, style").forEach((element) => {
426
+ element.remove();
427
+ });
428
+ const body = document.querySelector("div#mw-content-text");
429
+ return {
430
+ title: {
431
+ type: "metadata",
432
+ value: document.querySelector("span.mw-page-title-main")?.textContent?.trim() || document.title || null
433
+ },
434
+ markdown: {
435
+ type: "node",
436
+ value: body || document
437
+ }
438
+ };
358
439
  });
359
- const body = document.querySelector("div#mw-content-text");
360
- const title = document.querySelector("span.mw-page-title-main")?.textContent?.trim() || document.title || null;
361
- const markdown = body ? `# ${title ?? "Wikipedia"}\n\n${new CustomTurnDown().convertSoup(body)}` : new CustomTurnDown().convertSoup(document);
362
- return {
363
- title,
364
- markdown,
365
- text_content: markdown
366
- };
367
440
  }
368
441
  };
369
- //#endregion
370
- //#region src/converters/xlsx.ts
442
+ // src/converters/xlsx.ts
371
443
  var XlsxConverter = class extends HtmlConverter {
372
444
  async convert(source, options) {
373
- if (options.file_extension?.toLowerCase() !== ".xlsx") return null;
445
+ if (options.extension !== "xlsx") return null;
374
446
  const sheets = await readExcelFile(toArrayBuffer(source));
375
447
  const sections = [];
376
448
  for (const { sheet, data } of sheets) {
@@ -379,11 +451,9 @@ var XlsxConverter = class extends HtmlConverter {
379
451
  const result = await this.convertHtml(html);
380
452
  sections.push(`## ${sheet}\n${result?.markdown.trim() ?? ""}`);
381
453
  }
382
- const markdown = sections.join("\n\n");
383
454
  return {
384
455
  title: null,
385
- markdown,
386
- text_content: markdown
456
+ markdown: sections.join("\n\n")
387
457
  };
388
458
  }
389
459
  };
@@ -404,129 +474,126 @@ function cellText(value) {
404
474
  function escapeHtml(value) {
405
475
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
406
476
  }
407
- //#endregion
408
- //#region src/converters/xml-rss-atom.ts
477
+ // src/converters/xml-rss-atom.ts
409
478
  var RSSConverter = class {
410
479
  async convert(source, options = {}) {
411
- const extension = options.file_extension?.toLowerCase() ?? "";
412
- if (![
413
- ".xml",
414
- ".rss",
415
- ".atom"
416
- ].includes(extension)) return null;
480
+ if (options.extension !== "xml" && options.extension !== "rss" && options.extension !== "atom") return null;
417
481
  const document = new DOMParser().parseFromString(decodeText(source), "text/xml");
418
- if (document.getElementsByTagName("rss").length > 0) return this.parseRss(document);
419
- if (document.getElementsByTagName("feed")[0]?.getElementsByTagName("entry").length) return this.parseAtom(document);
482
+ if (document.getElementsByTagName("rss").length > 0) return parseRss(document);
483
+ if (document.getElementsByTagName("feed")[0]?.getElementsByTagName("entry").length) return parseAtom(document);
420
484
  return null;
421
485
  }
422
- parseAtom(document) {
423
- const root = document.getElementsByTagName("feed")[0];
424
- if (!root) return null;
425
- const title = getText(root, "title");
426
- const subtitle = getText(root, "subtitle");
427
- const sections = [`# ${title ?? "Feed"}`];
428
- if (subtitle) sections.push(subtitle);
429
- for (const entry of [...root.getElementsByTagName("entry")]) {
430
- const entryTitle = getText(entry, "title");
431
- const updated = getText(entry, "updated");
432
- const summary = getText(entry, "summary");
433
- const content = getText(entry, "content");
434
- const lines = [];
435
- if (entryTitle) lines.push(`## ${entryTitle}`);
436
- if (updated) lines.push(`Updated on: ${updated}`);
437
- if (summary) lines.push(parseContent(summary));
438
- if (content) lines.push(parseContent(content));
439
- sections.push(lines.join("\n"));
440
- }
441
- const markdown = sections.join("\n\n");
442
- return {
443
- title,
444
- markdown,
445
- text_content: markdown
446
- };
486
+ };
487
+ function parseAtom(document) {
488
+ const root = document.getElementsByTagName("feed")[0];
489
+ if (!root) return null;
490
+ const title = getText(root, "title");
491
+ const subtitle = getText(root, "subtitle");
492
+ const sections = [`# ${title ?? "Feed"}`];
493
+ if (subtitle) sections.push(subtitle);
494
+ for (const entry of [...root.getElementsByTagName("entry")]) {
495
+ const entryTitle = getText(entry, "title");
496
+ const updated = getText(entry, "updated");
497
+ const summary = getText(entry, "summary");
498
+ const content = getText(entry, "content");
499
+ const lines = [];
500
+ if (entryTitle) lines.push(`## ${entryTitle}`);
501
+ if (updated) lines.push(`Updated on: ${updated}`);
502
+ const node = summary || content;
503
+ if (node) lines.push(parseHtmlDocument(node, (document) => ({ markdown: {
504
+ type: "node",
505
+ value: document.documentElement
506
+ } })).markdown);
507
+ sections.push(lines.join("\n\n"));
447
508
  }
448
- parseRss(document) {
449
- const channel = document.getElementsByTagName("rss")[0]?.getElementsByTagName("channel")[0];
450
- if (!channel) return null;
451
- const title = getText(channel, "title");
452
- const description = getText(channel, "description");
453
- const sections = title ? [`# ${title}`] : [];
454
- if (description) sections.push(description);
455
- for (const item of [...channel.getElementsByTagName("item")]) {
456
- const itemTitle = getText(item, "title");
457
- const published = getText(item, "pubDate");
458
- const itemDescription = getText(item, "description");
459
- const content = getText(item, "content:encoded");
460
- const lines = [];
461
- if (itemTitle) lines.push(`## ${itemTitle}`);
462
- if (published) lines.push(`Published on: ${published}`);
463
- if (itemDescription) lines.push(parseContent(itemDescription));
464
- if (content) lines.push(parseContent(content));
465
- sections.push(lines.join("\n"));
466
- }
467
- const markdown = sections.join("\n\n");
468
- return {
469
- title,
470
- markdown,
471
- text_content: markdown
472
- };
509
+ return {
510
+ title,
511
+ markdown: sections.join("\n\n")
512
+ };
513
+ }
514
+ function parseRss(document) {
515
+ const channel = document.getElementsByTagName("rss")[0]?.getElementsByTagName("channel")[0];
516
+ if (!channel) return null;
517
+ const title = getText(channel, "title");
518
+ const description = getText(channel, "description");
519
+ const sections = title ? [`# ${title}`] : [];
520
+ if (description) sections.push(description);
521
+ for (const item of [...channel.getElementsByTagName("item")]) {
522
+ const itemTitle = getText(item, "title");
523
+ const published = getText(item, "pubDate");
524
+ const itemDescription = getText(item, "description");
525
+ const content = getText(item, "content:encoded");
526
+ const lines = [];
527
+ if (itemTitle) lines.push(`## ${itemTitle}`);
528
+ if (published) lines.push(`Published on: ${published}`);
529
+ const node = itemDescription || content;
530
+ if (node) lines.push(parseHtmlDocument(node, (document) => ({ markdown: {
531
+ type: "node",
532
+ value: document.documentElement
533
+ } })).markdown);
534
+ sections.push(lines.join("\n\n"));
473
535
  }
474
- };
475
- function parseContent(content) {
476
- return new CustomTurnDown().convertSoup(parseHtmlDocument(content).documentElement);
536
+ return {
537
+ title,
538
+ markdown: sections.join("\n\n")
539
+ };
477
540
  }
478
541
  function getText(element, tagName) {
479
542
  return element.getElementsByTagName(tagName)[0]?.textContent?.trim() || null;
480
543
  }
481
- //#endregion
482
- //#region src/converters/youtube.ts
544
+ // src/converters/youtube.ts
545
+ const YOUTUBE_URL = /^https?:\/\/(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch\?v=|shorts\/)?([a-zA-Z0-9_-]+)/i;
483
546
  var YouTubeConverter = class {
484
547
  async convert(source, options = {}) {
485
- const extension = options.file_extension?.toLowerCase() ?? "";
486
- if (extension !== ".html" && extension !== ".htm" || !options.url?.startsWith("https://www.youtube.com/watch?")) return null;
487
- const document = parseHtmlDocument(decodeText(source));
488
- const metadata = { title: document.title };
489
- document.querySelectorAll("meta").forEach((meta) => {
490
- const content = meta.getAttribute("content");
491
- const key = meta.getAttribute("itemprop") ?? meta.getAttribute("property") ?? meta.getAttribute("name");
492
- if (key && content) metadata[key] = content;
493
- });
494
- try {
495
- for (const script of document.querySelectorAll("script")) {
496
- const content = script.textContent ?? "";
497
- if (!content.includes("ytInitialData")) continue;
498
- const start = content.indexOf("{");
499
- const end = content.lastIndexOf("}");
500
- if (start >= 0 && end > start) {
501
- const description = findKey(JSON.parse(content.slice(start, end + 1)), "attributedDescriptionBodyText");
502
- if (isRecord(description) && typeof description.content === "string") metadata.description = description.content;
548
+ if (options.extension !== "html" && options.extension !== "htm" || !YOUTUBE_URL.test(options.url ?? "")) return null;
549
+ return parseHtmlDocument(decodeText(source), (document) => {
550
+ const metadata = {};
551
+ document.querySelectorAll("meta").forEach((meta) => {
552
+ const content = meta.getAttribute("content");
553
+ const key = meta.getAttribute("itemprop") ?? meta.getAttribute("property") ?? meta.getAttribute("name");
554
+ if (key && content) metadata[key] = content;
555
+ });
556
+ try {
557
+ for (const script of document.querySelectorAll("script")) {
558
+ const content = script.textContent ?? "";
559
+ if (!content.includes("ytInitialData")) continue;
560
+ const start = content.indexOf("{");
561
+ const end = content.lastIndexOf("}");
562
+ if (start >= 0 && end > start) {
563
+ const description = findKey(JSON.parse(content.slice(start, end + 1)), "attributedDescriptionBodyText");
564
+ if (isRecord(description) && typeof description.content === "string") metadata.description = description.content;
565
+ }
566
+ break;
503
567
  }
504
- break;
505
- }
506
- } catch {}
507
- const title = first(metadata, [
508
- "title",
509
- "og:title",
510
- "name"
511
- ]) || document.title || null;
512
- const lines = ["# YouTube"];
513
- if (title) lines.push(`## ${title}`);
514
- const stats = [];
515
- const views = first(metadata, ["interactionCount"]);
516
- const keywords = first(metadata, ["keywords"]);
517
- const runtime = first(metadata, ["duration"]);
518
- if (views) stats.push(`- **Views:** ${views}`);
519
- if (keywords) stats.push(`- **Keywords:** ${keywords}`);
520
- if (runtime) stats.push(`- **Runtime:** ${runtime}`);
521
- if (stats.length > 0) lines.push(`### Video Metadata\n${stats.join("\n")}`);
522
- const description = first(metadata, ["description", "og:description"]);
523
- if (description) lines.push(`### Description\n${description}`);
524
- const markdown = lines.join("\n\n");
525
- return {
526
- title,
527
- markdown,
528
- text_content: markdown
529
- };
568
+ } catch {}
569
+ const title = first(metadata, [
570
+ "title",
571
+ "og:title",
572
+ "name"
573
+ ]) || document.title || null;
574
+ const lines = ["# YouTube"];
575
+ if (title) lines.push(`## ${title}`);
576
+ const stats = [];
577
+ const views = first(metadata, ["interactionCount"]);
578
+ const keywords = first(metadata, ["keywords"]);
579
+ const runtime = first(metadata, ["duration"]);
580
+ if (views) stats.push(`- **Views:** ${views}`);
581
+ if (keywords) stats.push(`- **Keywords:** ${keywords}`);
582
+ if (runtime) stats.push(`- **Runtime:** ${runtime}`);
583
+ if (stats.length > 0) lines.push(`### Video Metadata\n\n${stats.join("\n")}`);
584
+ const description = first(metadata, ["description", "og:description"]);
585
+ if (description) lines.push(`### Description\n\n${description}`);
586
+ return {
587
+ title: {
588
+ type: "metadata",
589
+ value: title
590
+ },
591
+ markdown: {
592
+ type: "metadata",
593
+ value: lines.join("\n\n")
594
+ }
595
+ };
596
+ });
530
597
  }
531
598
  };
532
599
  function first(metadata, keys) {
@@ -549,23 +616,20 @@ function findKey(value, key) {
549
616
  function isRecord(value) {
550
617
  return typeof value === "object" && value !== null;
551
618
  }
552
- //#endregion
553
- //#region src/converters/zip.ts
619
+ // src/converters/zip.ts
554
620
  var ZipConverter = class {
555
621
  async convert(source, options = {}) {
556
- if (options.file_extension?.toLowerCase() !== ".zip") return null;
622
+ if (options.extension !== "zip") return null;
557
623
  const files = unzipSync(source, { filter: (file) => !file.name.endsWith("/") });
558
- const converters = options._parent_converters ?? [];
624
+ const converters = options._converters ?? [];
559
625
  const sections = [`Content from the zip file \`${options.url ?? "archive.zip"}\`:`];
560
626
  for (const [name, bytes] of Object.entries(files)) {
561
627
  const result = await convertEntry(bytes, name, converters, options);
562
628
  if (result) sections.push(`## File: ${name}\n\n${result.markdown}`);
563
629
  }
564
- const markdown = sections.join("\n\n");
565
630
  return {
566
631
  title: null,
567
- markdown,
568
- text_content: markdown
632
+ markdown: sections.join("\n\n")
569
633
  };
570
634
  }
571
635
  };
@@ -574,9 +638,9 @@ async function convertEntry(source, name, converters, options) {
574
638
  if (!extension) return null;
575
639
  const entryOptions = {
576
640
  ...options,
577
- file_extension: extension,
641
+ extension,
578
642
  url: void 0,
579
- _parent_converters: converters
643
+ _converters: converters
580
644
  };
581
645
  for (const converter of converters) try {
582
646
  const result = await converter.convert(source, entryOptions);
@@ -584,42 +648,38 @@ async function convertEntry(source, name, converters, options) {
584
648
  } catch {}
585
649
  return null;
586
650
  }
587
- //#endregion
588
- //#region src/markitdown.ts
651
+ // src/markitdown.ts
589
652
  var MarkItDown = class {
653
+ converters = [];
590
654
  constructor() {
591
- this.converters = [];
592
- this.registerConverter(new PlainTextConverter());
593
- this.registerConverter(new HtmlConverter());
594
- this.registerConverter(new ZipConverter());
595
- this.registerConverter(new RSSConverter());
596
- this.registerConverter(new WikipediaConverter());
597
- this.registerConverter(new YouTubeConverter());
598
- this.registerConverter(new BingSerpConverter());
599
- this.registerConverter(new DocxConverter());
600
- this.registerConverter(new XlsxConverter());
601
- this.registerConverter(new PptxConverter());
602
- this.registerConverter(new PdfConverter());
603
- this.registerConverter(new IpynbConverter());
655
+ const ordered = [
656
+ PlainTextConverter,
657
+ HtmlConverter,
658
+ ZipConverter,
659
+ RSSConverter,
660
+ WikipediaConverter,
661
+ YouTubeConverter,
662
+ BingSerpConverter,
663
+ DocxConverter,
664
+ XlsxConverter,
665
+ PptxConverter,
666
+ PdfConverter,
667
+ IpynbConverter
668
+ ];
669
+ for (const converter of ordered) this.registerConverter(new converter());
604
670
  }
605
671
  /** Convert a URL, response, blob, array buffer, data view, or typed array. */
606
672
  async convert(source, options = {}) {
607
673
  if (typeof source === "string" || source instanceof URL) return this.convertUrl(source.toString(), options);
608
674
  if (isResponse(source)) return this.convertResponse(source, options);
609
- const extensions = /* @__PURE__ */ new Set();
610
- this.addExtension(extensions, options.file_extension?.replace(/^\./, ""));
611
- if (isBlob(source)) {
612
- this.addExtension(extensions, extensionFromMime(source.type));
613
- if ("name" in source && typeof source.name === "string") this.addExtension(extensions, extensionFromPathname(source.name));
614
- }
615
- if (extensions.size === 0) throw new Error("Could not determine the file type. Provide `file_extension` when converting bytes.");
616
- return this.convertBytes(await toUint8Array(source), extensions, options);
617
- }
618
- /**
619
- * @deprecated Pass the byte source directly to `convert`.
620
- */
621
- async convertBuffer(source, options) {
622
- return this.convert(source, options);
675
+ const bytes = await toUint8Array(source);
676
+ const extensions = await detectExtensions(bytes, {
677
+ extension: options.extension,
678
+ filename: isBlob(source) && "name" in source ? String(source.name) : null,
679
+ urls: [options.url],
680
+ contentType: isBlob(source) ? source.type : null
681
+ });
682
+ return this.convertBytes(bytes, extensions, options);
623
683
  }
624
684
  async convertUrl(source, { fetch: fetchImplementation = globalThis.fetch, ...options }) {
625
685
  const url = new URL(source);
@@ -633,44 +693,39 @@ var MarkItDown = class {
633
693
  });
634
694
  }
635
695
  async convertResponse(response, options) {
636
- const extensions = /* @__PURE__ */ new Set();
637
- this.addExtension(extensions, options.file_extension);
638
- this.addExtension(extensions, extensionFromMime(response.headers.get("content-type")));
639
- const filename = (response.headers.get("content-disposition") ?? "").match(/filename\*?=(?:UTF-8''|["']?)([^;"']+)/i)?.[1];
640
- if (filename) this.addExtension(extensions, extensionFromPathname(decodeURIComponent(filename.trim())));
641
- const responseUrl = options.url || response.url;
642
- if (responseUrl) this.addExtension(extensions, extensionFromPathname(new URL(responseUrl).pathname));
643
- if (extensions.size === 0) throw new Error("Could not determine the file type. Provide `file_extension` or a Content-Type header.");
644
- return this.convertBytes(new Uint8Array(await response.arrayBuffer()), extensions, {
696
+ const bytes = new Uint8Array(await response.arrayBuffer());
697
+ const extensions = await detectExtensions(bytes, {
698
+ extension: options.extension,
699
+ filename: filenameFromDisposition(response.headers.get("content-disposition")),
700
+ urls: [options.url, response.url],
701
+ contentType: response.headers.get("content-type")
702
+ });
703
+ return this.convertBytes(bytes, extensions, {
645
704
  ...options,
646
- url: responseUrl
705
+ url: options.url || response.url || void 0
647
706
  });
648
707
  }
649
708
  async convertBytes(source, extensions, options) {
709
+ if (extensions.length === 0) throw new Error("Could not determine the file type. Provide `extension` for better results.");
650
710
  let lastError;
651
711
  for (const extension of extensions) for (const converter of this.converters) try {
652
712
  const result = await converter.convert(source, {
653
713
  ...options,
654
- file_extension: extension,
655
- _parent_converters: this.converters
714
+ extension,
715
+ _converters: this.converters
656
716
  });
657
717
  if (result) {
658
718
  result.markdown = result.markdown.replace(/\r\n|\r|\n/g, "\n").trim().replace(/\n{3,}/g, "\n\n");
659
- result.text_content = result.markdown;
660
719
  return result;
661
720
  }
662
721
  } catch (error) {
663
722
  if (options.debug) console.error("Converter threw error:", error);
664
723
  lastError = error;
665
724
  }
666
- const formats = [...extensions].join(", ");
725
+ const formats = extensions.join(", ");
667
726
  if (lastError) throw new Error(`Could not convert ${formats} to Markdown: ${String(lastError)}`);
668
727
  throw new Error(`Unsupported file type: ${formats}.`);
669
728
  }
670
- addExtension(extensions, extension) {
671
- if (!extension) return;
672
- extensions.add(extension.startsWith(".") ? extension.toLowerCase() : `.${extension.toLowerCase()}`);
673
- }
674
729
  registerConverter(converter) {
675
730
  this.converters.unshift(converter);
676
731
  }
@@ -678,13 +733,53 @@ var MarkItDown = class {
678
733
  function isResponse(value) {
679
734
  return typeof Response !== "undefined" && value instanceof Response;
680
735
  }
681
- //#endregion
682
- //#region src/cli.ts
683
- new Command().name("markitdown").version(version).argument("<file>", "The file to convert to Markdown").action(async (file) => {
684
- const extension = file.split(".").pop()?.toLowerCase();
685
- const bytes = await readFile(file);
686
- const result = await new MarkItDown().convert(new Uint8Array(bytes), { file_extension: extension });
687
- console.log(result);
688
- }).parse();
689
- //#endregion
690
- export {};
736
+ // src/cli.ts
737
+ const parser = object({
738
+ file: argument(path({
739
+ mustExist: true,
740
+ type: "file",
741
+ metavar: "FILE"
742
+ }), { description: message`File to convert.` }),
743
+ extension: optional(option("-x", "--extension", string({ metavar: "EXT" }), { description: message`File type to convert from, when it can't be detected from the file.` })),
744
+ json: withDefault(flag("--json", { description: message`Print the title and Markdown as JSON instead of only the Markdown.` }), false)
745
+ });
746
+ const program = defineProgram({
747
+ parser,
748
+ metadata: {
749
+ name: "markitdown",
750
+ version,
751
+ brief: message`Convert files to Markdown`,
752
+ description: message`Converts PDF, Word, Excel, PowerPoint, HTML, notebooks, feeds, zip archives and text files to Markdown. The file type is detected from the file's name and contents.`,
753
+ examples: message`Convert a document:
754
+
755
+ ${commandLine("markitdown report.docx")}
756
+
757
+ Convert a file without an extension as HTML:
758
+
759
+ ${commandLine("markitdown --extension html page")}`,
760
+ author: message`${author}`,
761
+ bugs: message`Report bugs at ${url(bugs.url)}.`
762
+ }
763
+ });
764
+ if (import.meta.main) {
765
+ const argv = process.argv.slice(2);
766
+ const parsed = runSync(program, {
767
+ args: argv.length ? argv : ["--help"],
768
+ aboveError: "none",
769
+ help: "both",
770
+ version: {
771
+ value: version,
772
+ option: true
773
+ },
774
+ completion: "both"
775
+ });
776
+ try {
777
+ const bytes = await readFile(parsed.file);
778
+ const result = await new MarkItDown().convert(new File([bytes], basename(parsed.file)), parsed.extension ? { extension: parsed.extension } : {});
779
+ console.log(parsed.json ? JSON.stringify(result, null, 2) : result?.markdown);
780
+ } catch (error) {
781
+ console.error(`Error: ${error.message}`);
782
+ process.exitCode = 1;
783
+ }
784
+ }
785
+ export { program };