markdown-plaintext 0.1.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/index.cjs ADDED
@@ -0,0 +1,564 @@
1
+ 'use strict';
2
+ // Turn markdown into plain text without destroying the text.
3
+ //
4
+ // The popular light option does this with a list of regular expressions, and that is why it strips
5
+ // the `**` out of a fenced code block, turns `` `__init__` `` into `init`, leaves `&` undecoded,
6
+ // drops the target of an autolink, and carries an open ReDoS advisory. The correct options either
7
+ // pull in a full CommonMark toolchain or wrap a whole markdown parser.
8
+ //
9
+ // This does one forward pass. Blocks are recognised first — so a code fence is known to be a code
10
+ // fence before anything inside it is touched — and inline markup is removed only where it is
11
+ // actually markup. No expression here can backtrack, so adversarial input costs linear time.
12
+ //
13
+ // It is not a CommonMark parser and does not try to be. It is a text extractor, and the README says
14
+ // exactly what it does with each construct.
15
+
16
+ const DEFAULTS = {
17
+ links: 'text',
18
+ images: 'alt',
19
+ lists: 'text',
20
+ tables: 'rows',
21
+ html: 'strip',
22
+ frontMatter: 'drop',
23
+ };
24
+
25
+ class MarkdownTextError extends Error {
26
+ constructor(message) {
27
+ super(message);
28
+ this.name = 'MarkdownTextError';
29
+ }
30
+ }
31
+
32
+ // ---- entities ---------------------------------------------------------------------------------
33
+
34
+ const NAMED = {
35
+ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0', copy: '©',
36
+ reg: '®', trade: '™', hellip: '…', mdash: '—', ndash: '–',
37
+ lsquo: '‘', rsquo: '’', ldquo: '“', rdquo: '”', laquo: '«',
38
+ raquo: '»', deg: '°', plusmn: '±', times: '×', divide: '÷',
39
+ frac12: '½', frac14: '¼', frac34: '¾', middot: '·', bull: '•',
40
+ dagger: '†', euro: '€', pound: '£', yen: '¥', cent: '¢',
41
+ sect: '§', para: '¶', micro: 'µ', larr: '←', rarr: '→',
42
+ uarr: '↑', darr: '↓', harr: '↔', ne: '≠', le: '≤', ge: '≥',
43
+ infin: '∞', sum: '∑', prod: '∏', radic: '√', asymp: '≈',
44
+ equiv: '≡', alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ',
45
+ pi: 'π', sigma: 'σ', omega: 'ω', lambda: 'λ', mu: 'μ',
46
+ check: '✓', cross: '✗', star: '★', hearts: '♥',
47
+ };
48
+
49
+ // A single non-backtracking alternation: every branch is anchored and bounded.
50
+ const ENTITY = /&(?:#[xX]([0-9a-fA-F]{1,6})|#([0-9]{1,7})|([a-zA-Z][a-zA-Z0-9]{1,31}));/g;
51
+
52
+ function codePoint(value) {
53
+ if (!Number.isFinite(value) || value < 0 || value > 0x10ffff) return '\ufffd';
54
+ if (value === 0 || (value >= 0xd800 && value <= 0xdfff)) return '\ufffd';
55
+ return String.fromCodePoint(value);
56
+ }
57
+
58
+ /** Decode the HTML entities markdown inherits from HTML. Unknown names are left alone. */
59
+ function decodeEntities(text) {
60
+ if (text.indexOf('&') === -1) return text;
61
+ return text.replace(ENTITY, (whole, hex, decimal, name) => {
62
+ if (hex !== undefined) return codePoint(parseInt(hex, 16));
63
+ if (decimal !== undefined) return codePoint(Number(decimal));
64
+ const known = NAMED[name];
65
+ return known === undefined ? whole : known;
66
+ });
67
+ }
68
+
69
+ // ---- inline ------------------------------------------------------------------------------------
70
+
71
+ const PUNCTUATION = new Set('!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~');
72
+
73
+ const isSpace = (ch) => ch === ' ' || ch === '\t' || ch === '\n';
74
+ const looksLikeUri = (text) => /^[a-zA-Z][a-zA-Z0-9+.-]{1,31}:/.test(text);
75
+ const looksLikeEmail = (text) => /^[^\s<>@]+@[^\s<>@.]+(?:\.[^\s<>@.]+)+$/.test(text);
76
+
77
+ /**
78
+ * Remove inline markup from one block of text. Code spans are copied out untouched, which is the
79
+ * whole point: their contents are not markup and must not be treated as any.
80
+ */
81
+ function inlineText(source, options) {
82
+ let out = '';
83
+ let i = 0;
84
+ const length = source.length;
85
+ const open = [];
86
+ // Once a scan proves nothing closes a bracket or a parenthesis beyond some point, later ones stop
87
+ // looking. Without this, a run of unmatched brackets costs quadratic time.
88
+ const limits = {noBracketCloser: Infinity, noParenCloser: Infinity, noEmphasisCloser: Infinity};
89
+
90
+ while (i < length) {
91
+ const ch = source[i];
92
+
93
+ // A backslash escape covers exactly one punctuation character.
94
+ if (ch === '\\' && i + 1 < length) {
95
+ const next = source[i + 1];
96
+ if (next === '\n') { out += '\n'; i += 2; continue; }
97
+ if (PUNCTUATION.has(next)) { out += next; i += 2; continue; }
98
+ out += ch;
99
+ i++;
100
+ continue;
101
+ }
102
+
103
+ // A code span runs from a run of backticks to the next run of the same length.
104
+ if (ch === '`') {
105
+ let open = 0;
106
+ while (i + open < length && source[i + open] === '`') open++;
107
+ const fence = '`'.repeat(open);
108
+ const close = source.indexOf(fence, i + open);
109
+ const closeIsExact = close !== -1 && source[close + open] !== '`';
110
+ if (close !== -1 && closeIsExact) {
111
+ let content = source.slice(i + open, close);
112
+ // One space of padding on each side is stripping, per CommonMark, but only if both sides
113
+ // have it and the content is not all spaces.
114
+ if (content.length > 2 && content[0] === ' ' && content[content.length - 1] === ' ' && content.trim() !== '') {
115
+ content = content.slice(1, -1);
116
+ }
117
+ out += content.replace(/\n/g, ' ');
118
+ i = close + open;
119
+ continue;
120
+ }
121
+ out += fence;
122
+ i += open;
123
+ continue;
124
+ }
125
+
126
+ // An autolink carries its target as its text, so the target is what survives.
127
+ if (ch === '<') {
128
+ const close = source.indexOf('>', i + 1);
129
+ if (close !== -1) {
130
+ const inner = source.slice(i + 1, close);
131
+ if (inner.length > 0 && inner.indexOf(' ') === -1 && (looksLikeUri(inner) || looksLikeEmail(inner))) {
132
+ out += decodeEntities(inner);
133
+ i = close + 1;
134
+ continue;
135
+ }
136
+ // Otherwise it may be an HTML tag. A tag name is letters, digits and hyphens, and it ends
137
+ // at whitespace or a slash — `<www.openjsf.org>` is neither an autolink nor a tag, so it
138
+ // stays as written.
139
+ if (options.html === 'strip' && /^\/?[a-zA-Z][a-zA-Z0-9-]*(?:[\s/][^<>]*)?$/.test(inner)) {
140
+ i = close + 1;
141
+ continue;
142
+ }
143
+ if (options.html === 'strip' && (inner.startsWith('!--') || inner.startsWith('?') || inner.startsWith('!'))) {
144
+ i = close + 1;
145
+ continue;
146
+ }
147
+ }
148
+ out += ch;
149
+ i++;
150
+ continue;
151
+ }
152
+
153
+ // An image is a link with a leading bang; its alt text is the only text it has.
154
+ if (ch === '!' && source[i + 1] === '[') {
155
+ const parsed = readLink(source, i + 1, options.definitions, limits);
156
+ if (parsed) {
157
+ if (options.images === 'alt') out += inlineText(parsed.label, options);
158
+ i = parsed.end;
159
+ continue;
160
+ }
161
+ out += ch;
162
+ i++;
163
+ continue;
164
+ }
165
+
166
+ if (ch === '[') {
167
+ const parsed = readLink(source, i, options.definitions, limits);
168
+ if (parsed) {
169
+ const label = inlineText(parsed.label, options);
170
+ if (options.links === 'text') out += label;
171
+ else if (options.links === 'url') out += parsed.destination ? decodeEntities(parsed.destination) : label;
172
+ else out += parsed.destination ? `${label} (${decodeEntities(parsed.destination)})` : label;
173
+ i = parsed.end;
174
+ continue;
175
+ }
176
+ out += ch;
177
+ i++;
178
+ continue;
179
+ }
180
+
181
+ // Emphasis delimiters, by CommonMark's flanking rules. The rule that matters most here is the
182
+ // one for `_`: it cannot open or close inside a word, which is why `my_variable_name` and
183
+ // `https://example.com/a_b_c` keep their underscores.
184
+ if (ch === '*' || ch === '_' || ch === '~') {
185
+ let run = 0;
186
+ while (i + run < length && source[i + run] === ch) run++;
187
+ const before = i === 0 ? '\n' : source[i - 1];
188
+ const after = i + run < length ? source[i + run] : '\n';
189
+ const beforeSpace = isSpace(before);
190
+ const afterSpace = isSpace(after);
191
+ const beforePunctuation = PUNCTUATION.has(before);
192
+ const afterPunctuation = PUNCTUATION.has(after);
193
+ const leftFlanking = !afterSpace && (!afterPunctuation || beforeSpace || beforePunctuation);
194
+ const rightFlanking = !beforeSpace && (!beforePunctuation || afterSpace || afterPunctuation);
195
+ const canOpen = ch === '_' ? leftFlanking && (!rightFlanking || beforePunctuation) : leftFlanking;
196
+ const canClose = ch === '_' ? rightFlanking && (!leftFlanking || afterPunctuation) : rightFlanking;
197
+
198
+ if (canClose && open.length > 0 && open[open.length - 1] === ch) {
199
+ open.pop();
200
+ i += run;
201
+ continue;
202
+ }
203
+ if (canOpen && i < limits.noEmphasisCloser && findCloser(source, i + run, ch, run) !== -1) {
204
+ open.push(ch);
205
+ i += run;
206
+ continue;
207
+ }
208
+ if (canOpen) limits.noEmphasisCloser = Math.min(limits.noEmphasisCloser, i);
209
+ out += ch.repeat(run);
210
+ i += run;
211
+ continue;
212
+ }
213
+
214
+ if (ch === '&') {
215
+ const match = matchEntityAt(source, i);
216
+ if (match) { out += match.text; i = match.end; continue; }
217
+ out += ch;
218
+ i++;
219
+ continue;
220
+ }
221
+
222
+ out += ch;
223
+ i++;
224
+ }
225
+
226
+ return out;
227
+ }
228
+
229
+ function matchEntityAt(source, start) {
230
+ const limit = Math.min(source.length, start + 36);
231
+ const semicolon = source.indexOf(';', start + 1);
232
+ if (semicolon === -1 || semicolon > limit) return null;
233
+ const decoded = decodeEntities(source.slice(start, semicolon + 1));
234
+ if (decoded === source.slice(start, semicolon + 1)) return null;
235
+ return {text: decoded, end: semicolon + 1};
236
+ }
237
+
238
+ /** Find the delimiter run that closes an emphasis run, without backtracking. */
239
+ function findCloser(source, from, ch, run) {
240
+ for (let i = from; i < source.length; i++) {
241
+ if (source[i] === '\\') { i++; continue; }
242
+ if (source[i] !== ch) continue;
243
+ let length = 0;
244
+ while (i + length < source.length && source[i + length] === ch) length++;
245
+ const before = source[i - 1];
246
+ if (length >= run && !isSpace(before)) return i;
247
+ i += length - 1;
248
+ }
249
+ return -1;
250
+ }
251
+
252
+ /**
253
+ * Read `[label](destination)` or `[label][reference]` starting at `[`. Returns null when the
254
+ * brackets do not form a link, so the caller can emit them literally.
255
+ */
256
+ function readLink(source, start, definitions, limits) {
257
+ if (start >= limits.noBracketCloser) return null;
258
+ let depth = 0;
259
+ let i = start;
260
+ for (; i < source.length; i++) {
261
+ const ch = source[i];
262
+ if (ch === '\\') { i++; continue; }
263
+ if (ch === '`') {
264
+ // A code span inside a label can contain brackets that are not structure.
265
+ let open = 0;
266
+ while (i + open < source.length && source[i + open] === '`') open++;
267
+ const close = source.indexOf('`'.repeat(open), i + open);
268
+ if (close !== -1) { i = close + open - 1; continue; }
269
+ }
270
+ if (ch === '[') depth++;
271
+ else if (ch === ']') { depth--; if (depth === 0) break; }
272
+ }
273
+ if (depth !== 0 || i >= source.length) {
274
+ // Nothing after this point closes a bracket either, so later brackets need not look.
275
+ limits.noBracketCloser = Math.min(limits.noBracketCloser, start);
276
+ return null;
277
+ }
278
+
279
+ const label = source.slice(start + 1, i);
280
+ let end = i + 1;
281
+
282
+ if (source[end] === '(') {
283
+ if (end >= limits.noParenCloser) return null;
284
+ let parens = 1;
285
+ let j = end + 1;
286
+ for (; j < source.length && parens > 0; j++) {
287
+ const ch = source[j];
288
+ if (ch === '\\') { j++; continue; }
289
+ if (ch === '(') parens++;
290
+ else if (ch === ')') parens--;
291
+ else if (ch === '\n' && source[j - 1] === '\n') return null;
292
+ }
293
+ if (parens !== 0) {
294
+ limits.noParenCloser = Math.min(limits.noParenCloser, end);
295
+ return null;
296
+ }
297
+ const inside = source.slice(end + 1, j - 1).trim();
298
+ const destination = inside.startsWith('<') && inside.indexOf('>') !== -1
299
+ ? inside.slice(1, inside.indexOf('>'))
300
+ : inside.split(/\s+/)[0] ?? '';
301
+ return {label, destination, end: j};
302
+ }
303
+
304
+ if (source[end] === '[') {
305
+ const close = source.indexOf(']', end + 1);
306
+ if (close !== -1) {
307
+ const reference = source.slice(end + 1, close).trim() || label;
308
+ if (definitions.has(normaliseLabel(reference))) return {label, destination: '', end: close + 1};
309
+ return null;
310
+ }
311
+ }
312
+
313
+ // A shortcut reference is a link only when something defines it; otherwise the brackets are text.
314
+ return definitions.has(normaliseLabel(label)) ? {label, destination: '', end} : null;
315
+ }
316
+
317
+ const normaliseLabel = (label) => label.trim().replace(/\s+/g, ' ').toLowerCase();
318
+
319
+ /** Collect the reference definitions a document declares, so `[x]` is only a link when `x` exists. */
320
+ function collectDefinitions(lines) {
321
+ const found = new Set();
322
+ for (const line of lines) {
323
+ const match = /^ {0,3}\[([^\]]{1,999})\]:[ \t]*\S/.exec(line);
324
+ if (match) found.add(normaliseLabel(match[1]));
325
+ }
326
+ return found;
327
+ }
328
+
329
+ // ---- blocks ------------------------------------------------------------------------------------
330
+
331
+ const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
332
+ const ATX = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*#*[ \t]*$/;
333
+ const SETEXT = /^ {0,3}(=+|-+)[ \t]*$/;
334
+ const THEMATIC = /^ {0,3}(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/;
335
+ const BULLET = /^([ \t]*)([-*+])([ \t]+)(.*)$/;
336
+ const ORDERED = /^([ \t]*)(\d{1,9})([.)])([ \t]+)(.*)$/;
337
+ const BLOCKQUOTE = /^ {0,3}> ?(.*)$/;
338
+ const TABLE_DELIMITER = /^ {0,3}\|?[ \t]*:?-{1,}:?[ \t]*(?:\|[ \t]*:?-{1,}:?[ \t]*)*\|?[ \t]*$/;
339
+ const HTML_BLOCK_OPEN = /^ {0,3}<(?:\/?[a-zA-Z][a-zA-Z0-9-]*(?=[\s/>])|!--|\?|![A-Z])/;
340
+ const LINK_DEFINITION = /^ {0,3}\[[^\]]{1,999}\]:[ \t]*\S+.*$/;
341
+ const FOOTNOTE_DEFINITION = /^ {0,3}\[\^[^\]]{1,999}\]:[ \t]*(.*)$/;
342
+
343
+ function stripFrontMatter(lines) {
344
+ if (lines.length === 0) return lines;
345
+ const first = lines[0].trim();
346
+ const fence = first === '---' ? '---' : first === '+++' ? '+++' : null;
347
+ if (!fence) return lines;
348
+ for (let i = 1; i < lines.length; i++) {
349
+ if (lines[i].trim() === fence) return lines.slice(i + 1);
350
+ }
351
+ return lines;
352
+ }
353
+
354
+ function tableCells(line) {
355
+ const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '');
356
+ const cells = [];
357
+ let current = '';
358
+ for (let i = 0; i < trimmed.length; i++) {
359
+ const ch = trimmed[i];
360
+ if (ch === '\\' && trimmed[i + 1] === '|') { current += '|'; i++; continue; }
361
+ if (ch === '|') { cells.push(current.trim()); current = ''; continue; }
362
+ current += ch;
363
+ }
364
+ cells.push(current.trim());
365
+ return cells;
366
+ }
367
+
368
+ /** Turn markdown into plain text. */
369
+ function toText(markdown, options) {
370
+ if (typeof markdown !== 'string') throw new TypeError('markdown must be a string');
371
+ const settings = {...DEFAULTS, ...options};
372
+ for (const [key, allowed] of [
373
+ ['links', ['text', 'url', 'both']],
374
+ ['images', ['alt', 'drop']],
375
+ ['lists', ['text', 'markers']],
376
+ ['tables', ['rows', 'drop']],
377
+ ['html', ['strip', 'keep']],
378
+ ['frontMatter', ['drop', 'keep']],
379
+ ]) {
380
+ if (!allowed.includes(settings[key])) {
381
+ throw new MarkdownTextError(`${key} must be one of ${allowed.join(', ')}, got ${JSON.stringify(settings[key])}`);
382
+ }
383
+ }
384
+
385
+ let source = markdown;
386
+ if (source.charCodeAt(0) === 0xfeff) source = source.slice(1);
387
+ let lines = source.replace(/\r\n?/g, '\n').split('\n');
388
+ if (settings.frontMatter === 'drop') lines = stripFrontMatter(lines);
389
+ if (!settings.definitions) settings.definitions = collectDefinitions(lines);
390
+
391
+ const blocks = [];
392
+ let paragraph = [];
393
+ let inList = false;
394
+
395
+ const flushParagraph = () => {
396
+ if (paragraph.length === 0) return;
397
+ const joined = paragraph.join('\n');
398
+ const text = inlineText(joined, settings).replace(/[ \t]*\n[ \t]*/g, '\n').trim();
399
+ if (text) blocks.push(text);
400
+ paragraph = [];
401
+ };
402
+
403
+ for (let index = 0; index < lines.length; index++) {
404
+ const line = lines[index];
405
+
406
+ const fence = FENCE.exec(line);
407
+ if (fence) {
408
+ flushParagraph();
409
+ const marker = fence[1][0];
410
+ const width = fence[1].length;
411
+ const content = [];
412
+ let closed = false;
413
+ index++;
414
+ for (; index < lines.length; index++) {
415
+ const candidate = lines[index];
416
+ const closing = new RegExp(`^ {0,3}${marker === '`' ? '`' : '~'}{${width},}[ \\t]*$`).exec(candidate);
417
+ if (closing) { closed = true; break; }
418
+ content.push(candidate);
419
+ }
420
+ void closed;
421
+ // Code is the one thing that must survive exactly as written.
422
+ const code = content.join('\n').replace(/\s+$/, '');
423
+ if (code) blocks.push(code);
424
+ continue;
425
+ }
426
+
427
+ if (line.trim() === '') { flushParagraph(); continue; }
428
+
429
+ if (paragraph.length === 0 && !inList && /^ {4,}/.test(line)) {
430
+ // An indented code block: gather the run and keep it verbatim.
431
+ const content = [];
432
+ for (; index < lines.length; index++) {
433
+ const candidate = lines[index];
434
+ if (candidate.trim() === '') { content.push(''); continue; }
435
+ if (!/^ {4,}/.test(candidate)) break;
436
+ content.push(candidate.slice(4));
437
+ }
438
+ index--;
439
+ const code = content.join('\n').replace(/\s+$/, '');
440
+ if (code) blocks.push(code);
441
+ continue;
442
+ }
443
+
444
+ if (inList && !/^[ \t]/.test(line) && !BULLET.test(line) && !ORDERED.test(line)) inList = false;
445
+
446
+ if (THEMATIC.test(line)) { flushParagraph(); inList = false; continue; }
447
+
448
+ const atx = ATX.exec(line);
449
+ if (atx) {
450
+ flushParagraph();
451
+ const text = inlineText(atx[2] ?? '', settings).trim();
452
+ if (text) blocks.push(text);
453
+ continue;
454
+ }
455
+
456
+ const setext = SETEXT.exec(line);
457
+ if (setext && paragraph.length > 0) {
458
+ flushParagraph();
459
+ continue;
460
+ }
461
+
462
+ if (LINK_DEFINITION.test(line) && paragraph.length === 0 && !FOOTNOTE_DEFINITION.test(line)) continue;
463
+
464
+ const footnote = FOOTNOTE_DEFINITION.exec(line);
465
+ if (footnote && paragraph.length === 0) {
466
+ const text = inlineText(footnote[1], settings).trim();
467
+ if (text) blocks.push(text);
468
+ continue;
469
+ }
470
+
471
+ const quote = BLOCKQUOTE.exec(line);
472
+ if (quote) {
473
+ flushParagraph();
474
+ const inner = [quote[1]];
475
+ for (index++; index < lines.length; index++) {
476
+ const next = BLOCKQUOTE.exec(lines[index]);
477
+ if (next) { inner.push(next[1]); continue; }
478
+ if (lines[index].trim() === '') break;
479
+ inner.push(lines[index]);
480
+ }
481
+ index--;
482
+ const nested = toText(inner.join('\n'), settings);
483
+ if (nested) blocks.push(nested);
484
+ continue;
485
+ }
486
+
487
+ const bullet = BULLET.exec(line);
488
+ const ordered = ORDERED.exec(line);
489
+ if ((bullet || ordered) && !THEMATIC.test(line)) {
490
+ flushParagraph();
491
+ const marker = bullet ? bullet[2] : `${ordered[2]}${ordered[3]}`;
492
+ const rest = bullet ? bullet[4] : ordered[5];
493
+ // A task list marker is structure, not text.
494
+ const item = rest.replace(/^\[[ xX]\][ \t]+/, '');
495
+ const text = inlineText(item, settings).trim();
496
+ if (text) blocks.push(settings.lists === 'markers' ? `${marker} ${text}` : text);
497
+ inList = true;
498
+ continue;
499
+ }
500
+
501
+ if (line.includes('|') && index + 1 < lines.length && TABLE_DELIMITER.test(lines[index + 1])) {
502
+ flushParagraph();
503
+ const rows = [tableCells(line)];
504
+ index += 2;
505
+ for (; index < lines.length; index++) {
506
+ if (lines[index].trim() === '' || !lines[index].includes('|')) break;
507
+ rows.push(tableCells(lines[index]));
508
+ }
509
+ index--;
510
+ if (settings.tables === 'rows') {
511
+ for (const row of rows) {
512
+ const text = row.map((cell) => inlineText(cell, settings).trim()).filter(Boolean).join('\t');
513
+ if (text) blocks.push(text);
514
+ }
515
+ }
516
+ continue;
517
+ }
518
+
519
+ if (settings.html === 'strip' && paragraph.length === 0 && HTML_BLOCK_OPEN.test(line)) {
520
+ const content = [];
521
+ for (; index < lines.length; index++) {
522
+ if (lines[index].trim() === '') break;
523
+ content.push(lines[index]);
524
+ }
525
+ index--;
526
+ const text = stripHtmlBlock(content.join('\n'), settings);
527
+ if (text) blocks.push(text);
528
+ continue;
529
+ }
530
+
531
+ paragraph.push(line);
532
+ }
533
+
534
+ flushParagraph();
535
+ return blocks.join('\n\n');
536
+ }
537
+
538
+ /** Remove tags from an HTML block, dropping the contents of script and style outright. */
539
+ function stripHtmlBlock(html, options) {
540
+ let out = '';
541
+ let i = 0;
542
+ while (i < html.length) {
543
+ const lt = html.indexOf('<', i);
544
+ if (lt === -1) { out += html.slice(i); break; }
545
+ out += html.slice(i, lt);
546
+ if (html.startsWith('<!--', lt)) {
547
+ const close = html.indexOf('-->', lt + 4);
548
+ i = close === -1 ? html.length : close + 3;
549
+ continue;
550
+ }
551
+ const name = /^<\s*(\/?)([a-zA-Z][a-zA-Z0-9-]*)/.exec(html.slice(lt, lt + 40));
552
+ const close = html.indexOf('>', lt);
553
+ if (close === -1) { out += html.slice(lt); break; }
554
+ if (name && !name[1] && (name[2].toLowerCase() === 'script' || name[2].toLowerCase() === 'style')) {
555
+ const endTag = new RegExp(`</\\s*${name[2]}\\s*>`, 'i').exec(html.slice(close));
556
+ i = endTag ? close + endTag.index + endTag[0].length : html.length;
557
+ continue;
558
+ }
559
+ i = close + 1;
560
+ }
561
+ return inlineText(out, options).replace(/[ \t]*\n[ \t]*/g, '\n').trim();
562
+ }
563
+
564
+ module.exports = {MarkdownTextError, decodeEntities, toText};
@@ -0,0 +1,45 @@
1
+ export interface TextOptions {
2
+ /**
3
+ * What a link becomes.
4
+ * `'text'` (default) keeps the label, `'url'` keeps the destination, `'both'` writes
5
+ * `label (destination)`.
6
+ */
7
+ links?: 'text' | 'url' | 'both';
8
+ /** `'alt'` (default) keeps an image's alt text; `'drop'` removes the image entirely. */
9
+ images?: 'alt' | 'drop';
10
+ /** `'text'` (default) drops list markers; `'markers'` keeps `-` and `1.` in front of each item. */
11
+ lists?: 'text' | 'markers';
12
+ /** `'rows'` (default) writes each table row as tab-separated cells; `'drop'` removes tables. */
13
+ tables?: 'rows' | 'drop';
14
+ /**
15
+ * `'strip'` (default) removes HTML tags and keeps the text between them, dropping `script` and
16
+ * `style` contents outright. `'keep'` leaves HTML as written.
17
+ */
18
+ html?: 'strip' | 'keep';
19
+ /** `'drop'` (default) removes YAML or TOML front matter; `'keep'` treats it as content. */
20
+ frontMatter?: 'drop' | 'keep';
21
+ }
22
+
23
+ /** An option outside its allowed set. */
24
+ export declare class MarkdownTextError extends Error {
25
+ readonly name: 'MarkdownTextError';
26
+ constructor(message: string);
27
+ }
28
+
29
+ /**
30
+ * Turn markdown into plain text.
31
+ *
32
+ * Block structure becomes blank-line-separated paragraphs. Code blocks and code spans keep their
33
+ * contents exactly as written, escapes are resolved, HTML entities are decoded, and an autolink
34
+ * keeps its target — which is the text it displays.
35
+ *
36
+ * This is a text extractor, not a CommonMark parser. The README lists what it does with each
37
+ * construct.
38
+ */
39
+ export declare function toText(markdown: string, options?: TextOptions): string;
40
+
41
+ /**
42
+ * Decode the HTML entities markdown inherits from HTML — `&amp;`, `&#233;`, `&#x41;` and the named
43
+ * entities in common use. Unknown names are left as written.
44
+ */
45
+ export declare function decodeEntities(text: string): string;
@@ -0,0 +1,45 @@
1
+ export interface TextOptions {
2
+ /**
3
+ * What a link becomes.
4
+ * `'text'` (default) keeps the label, `'url'` keeps the destination, `'both'` writes
5
+ * `label (destination)`.
6
+ */
7
+ links?: 'text' | 'url' | 'both';
8
+ /** `'alt'` (default) keeps an image's alt text; `'drop'` removes the image entirely. */
9
+ images?: 'alt' | 'drop';
10
+ /** `'text'` (default) drops list markers; `'markers'` keeps `-` and `1.` in front of each item. */
11
+ lists?: 'text' | 'markers';
12
+ /** `'rows'` (default) writes each table row as tab-separated cells; `'drop'` removes tables. */
13
+ tables?: 'rows' | 'drop';
14
+ /**
15
+ * `'strip'` (default) removes HTML tags and keeps the text between them, dropping `script` and
16
+ * `style` contents outright. `'keep'` leaves HTML as written.
17
+ */
18
+ html?: 'strip' | 'keep';
19
+ /** `'drop'` (default) removes YAML or TOML front matter; `'keep'` treats it as content. */
20
+ frontMatter?: 'drop' | 'keep';
21
+ }
22
+
23
+ /** An option outside its allowed set. */
24
+ export declare class MarkdownTextError extends Error {
25
+ readonly name: 'MarkdownTextError';
26
+ constructor(message: string);
27
+ }
28
+
29
+ /**
30
+ * Turn markdown into plain text.
31
+ *
32
+ * Block structure becomes blank-line-separated paragraphs. Code blocks and code spans keep their
33
+ * contents exactly as written, escapes are resolved, HTML entities are decoded, and an autolink
34
+ * keeps its target — which is the text it displays.
35
+ *
36
+ * This is a text extractor, not a CommonMark parser. The README lists what it does with each
37
+ * construct.
38
+ */
39
+ export declare function toText(markdown: string, options?: TextOptions): string;
40
+
41
+ /**
42
+ * Decode the HTML entities markdown inherits from HTML — `&amp;`, `&#233;`, `&#x41;` and the named
43
+ * entities in common use. Unknown names are left as written.
44
+ */
45
+ export declare function decodeEntities(text: string): string;