@link-assistant/web-capture 1.1.2 → 1.7.1

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 (60) hide show
  1. package/CHANGELOG.md +57 -13
  2. package/Dockerfile +26 -21
  3. package/README.md +290 -160
  4. package/bin/web-capture.js +622 -17
  5. package/package.json +15 -4
  6. package/src/animation.js +283 -0
  7. package/src/archive.js +225 -0
  8. package/src/batch.js +180 -0
  9. package/src/browser.js +150 -111
  10. package/src/docx.js +182 -0
  11. package/src/extract-images.js +120 -0
  12. package/src/figures.js +208 -0
  13. package/src/gdocs.js +335 -0
  14. package/src/image.js +65 -8
  15. package/src/index.js +15 -0
  16. package/src/latex.js +127 -0
  17. package/src/lib.js +281 -1
  18. package/src/localize-images.js +202 -0
  19. package/src/markdown.js +7 -2
  20. package/src/metadata.js +333 -0
  21. package/src/pdf.js +76 -0
  22. package/src/popups.js +171 -0
  23. package/src/postprocess.js +263 -0
  24. package/src/retry.js +48 -0
  25. package/src/themed-image.js +167 -0
  26. package/src/verify.js +312 -0
  27. package/tests/e2e/docker.test.js +51 -12
  28. package/tests/e2e/process.test.js +34 -10
  29. package/tests/fixtures/habr-articles.js +45 -0
  30. package/tests/integration/api-endpoints.test.js +231 -0
  31. package/tests/integration/habr-article.test.js +337 -0
  32. package/tests/mock/index.test.js +5 -3
  33. package/tests/unit/animation.test.js +33 -0
  34. package/tests/unit/batch.test.js +114 -0
  35. package/tests/unit/browser.test.js +38 -2
  36. package/tests/unit/extract-images.test.js +233 -0
  37. package/tests/unit/figures.test.js +74 -0
  38. package/tests/unit/gdocs.test.js +278 -0
  39. package/tests/unit/heading-image.test.js +49 -0
  40. package/tests/unit/latex.test.js +123 -0
  41. package/tests/unit/localize-images.test.js +78 -0
  42. package/tests/unit/metadata.test.js +140 -0
  43. package/tests/unit/postprocess.test.js +110 -0
  44. package/tests/unit/retry.test.js +111 -0
  45. package/tests/unit/verify.test.js +182 -0
  46. package/.github/workflows/release.yml +0 -294
  47. package/ARCHITECTURE.md +0 -982
  48. package/LICENSE +0 -24
  49. package/apply.sh +0 -166
  50. package/docs/market-analysis-2025-05-13.md +0 -64
  51. package/scripts/changeset-version.mjs +0 -38
  52. package/scripts/create-github-release.mjs +0 -93
  53. package/scripts/create-manual-changeset.mjs +0 -88
  54. package/scripts/format-github-release.mjs +0 -83
  55. package/scripts/format-release-notes.mjs +0 -218
  56. package/scripts/instant-version-bump.mjs +0 -121
  57. package/scripts/publish-to-npm.mjs +0 -128
  58. package/scripts/setup-npm.mjs +0 -37
  59. package/scripts/validate-changeset.mjs +0 -106
  60. package/scripts/version-and-commit.mjs +0 -237
package/src/image.js CHANGED
@@ -1,15 +1,46 @@
1
1
  import { createBrowser, getBrowserEngine } from './browser.js';
2
+ import { dismissPopups, scrollToLoadContent } from './popups.js';
2
3
 
4
+ /**
5
+ * Image screenshot handler.
6
+ *
7
+ * Query parameters:
8
+ * url (required) - URL to capture
9
+ * engine - 'puppeteer' or 'playwright'
10
+ * format - 'png' (default, lossless) or 'jpeg'
11
+ * quality - JPEG quality 0-100 (default 80, ignored for PNG)
12
+ * width - viewport width in px (default 1280)
13
+ * height - viewport height in px (default 800)
14
+ * fullPage - 'true' to capture full scrollable page (default 'false')
15
+ * theme - 'light', 'dark', or 'no-preference' (default: browser default)
16
+ * dismissPopups - 'true' to auto-close cookie/consent popups before capture (default 'true')
17
+ */
3
18
  export async function imageHandler(req, res) {
4
19
  const url = req.query.url;
5
20
  if (!url) {
6
21
  return res.status(400).send('Missing `url` parameter');
7
22
  }
23
+
24
+ // Parse options from query parameters
25
+ const format = (req.query.format || 'png').toLowerCase();
26
+ if (format !== 'png' && format !== 'jpeg') {
27
+ return res.status(400).send('Invalid `format`: must be "png" or "jpeg"');
28
+ }
29
+ const quality =
30
+ format === 'jpeg'
31
+ ? Math.min(100, Math.max(0, parseInt(req.query.quality, 10) || 80))
32
+ : undefined;
33
+ const width = parseInt(req.query.width, 10) || 1280;
34
+ const height = parseInt(req.query.height, 10) || 800;
35
+ const fullPage = req.query.fullPage === 'true';
36
+ const theme = req.query.theme; // 'light', 'dark', or undefined
37
+ const shouldDismissPopups = req.query.dismissPopups !== 'false'; // default true
38
+
8
39
  try {
9
- // Ensure URL is absolute
10
40
  const absoluteUrl = url.startsWith('http') ? url : `https://${url}`;
11
41
  const engine = getBrowserEngine(req);
12
- const browser = await createBrowser(engine);
42
+ const browserOpts = theme ? { colorScheme: theme } : {};
43
+ const browser = await createBrowser(engine, browserOpts);
13
44
  try {
14
45
  const page = await browser.newPage();
15
46
  await page.setExtraHTTPHeaders({
@@ -19,17 +50,43 @@ export async function imageHandler(req, res) {
19
50
  await page.setUserAgent(
20
51
  'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
21
52
  );
22
- await page.setViewport({ width: 1280, height: 800 });
53
+ await page.setViewport({ width, height });
23
54
  await page.goto(absoluteUrl, {
24
55
  waitUntil: 'networkidle0',
25
56
  timeout: 30000,
26
57
  });
27
- // Wait for 5 seconds after page load
58
+
59
+ // Wait for dynamic content
28
60
  await new Promise((resolve) => setTimeout(resolve, 5000));
29
- // Take a screenshot of just the viewport (not the full page)
30
- const buffer = await page.screenshot({ type: 'png' });
31
- res.set('Content-Type', 'image/png');
32
- res.set('Content-Disposition', 'inline; filename="screenshot.png"');
61
+
62
+ if (fullPage) {
63
+ // Scroll to trigger lazy-loaded images
64
+ await scrollToLoadContent(page);
65
+ }
66
+
67
+ if (shouldDismissPopups) {
68
+ await dismissPopups(page);
69
+ // Scroll back to top after dismissing popups using browser-commander's evaluate API
70
+ // eslint-disable-next-line no-undef
71
+ await page.evaluate(() => window.scrollTo(0, 0));
72
+ await new Promise((resolve) => setTimeout(resolve, 500));
73
+ }
74
+
75
+ // Build screenshot options
76
+ const screenshotOpts = {
77
+ type: format,
78
+ fullPage,
79
+ };
80
+ if (format === 'jpeg') {
81
+ screenshotOpts.quality = quality;
82
+ }
83
+
84
+ const buffer = await page.screenshot(screenshotOpts);
85
+
86
+ const contentType = format === 'jpeg' ? 'image/jpeg' : 'image/png';
87
+ const ext = format === 'jpeg' ? 'jpg' : 'png';
88
+ res.set('Content-Type', contentType);
89
+ res.set('Content-Disposition', `inline; filename="screenshot.${ext}"`);
33
90
  res.end(buffer);
34
91
  } finally {
35
92
  await browser.close();
package/src/index.js CHANGED
@@ -5,15 +5,30 @@ import { markdownHandler } from './markdown.js';
5
5
  import { imageHandler } from './image.js';
6
6
  import { streamHandler } from './stream.js';
7
7
  import { fetchHandler } from './fetch.js';
8
+ import { archiveHandler } from './archive.js';
9
+ import { pdfHandler } from './pdf.js';
10
+ import { docxHandler } from './docx.js';
11
+ import { animationHandler } from './animation.js';
12
+ import { figuresHandler } from './figures.js';
13
+ import { themedImageHandler } from './themed-image.js';
14
+ import { gdocsHandler } from './gdocs.js';
8
15
 
9
16
  const app = express();
10
17
  const port = process.env.PORT || 3000;
11
18
 
19
+ app.get('/health', (req, res) => res.json({ status: 'ok' }));
12
20
  app.get('/html', htmlHandler);
13
21
  app.get('/markdown', markdownHandler);
14
22
  app.get('/image', imageHandler);
15
23
  app.get('/stream', streamHandler);
16
24
  app.get('/fetch', fetchHandler);
25
+ app.get('/archive', archiveHandler);
26
+ app.get('/pdf', pdfHandler);
27
+ app.get('/docx', docxHandler);
28
+ app.get('/animation', animationHandler);
29
+ app.get('/figures', figuresHandler);
30
+ app.get('/themed-image', themedImageHandler);
31
+ app.get('/gdocs', gdocsHandler);
17
32
 
18
33
  // Start the server if this is the main module
19
34
  const isMainModule =
package/src/latex.js ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * LaTeX formula extraction module.
3
+ *
4
+ * Extracts LaTeX formulas from HTML content, handling multiple sources:
5
+ * - Habr: img.formula elements with `source` attribute
6
+ * - KaTeX: .katex elements with annotation[encoding="application/x-tex"]
7
+ * - MathJax: mjx-container elements with data-tex/data-latex attributes
8
+ *
9
+ * Based on reference implementation from:
10
+ * https://github.com/link-foundation/meta-theory/blob/main/scripts/download-article.mjs
11
+ */
12
+
13
+ /**
14
+ * Extract LaTeX source from a formula image element (Habr-specific).
15
+ * Habr renders formulas as SVG/PNG images with class "formula".
16
+ * The original LaTeX source is in the `source` attribute.
17
+ *
18
+ * @param {Object} $ - Cheerio instance
19
+ * @param {Object} el - Cheerio element (img.formula)
20
+ * @returns {string|null} LaTeX source or null
21
+ */
22
+ export function extractHabrFormula($, el) {
23
+ const source = $(el).attr('source');
24
+ if (source) {
25
+ return source.trim();
26
+ }
27
+ const alt = $(el).attr('alt');
28
+ if (alt) {
29
+ return alt.trim();
30
+ }
31
+ return null;
32
+ }
33
+
34
+ /**
35
+ * Extract LaTeX from KaTeX elements.
36
+ * KaTeX stores the TeX source in annotation[encoding="application/x-tex"].
37
+ *
38
+ * @param {Object} $ - Cheerio instance
39
+ * @param {Object} el - Cheerio element (.katex or .math)
40
+ * @returns {string|null} LaTeX source or null
41
+ */
42
+ export function extractKatexFormula($, el) {
43
+ const annotation = $(el).find('annotation[encoding="application/x-tex"]');
44
+ if (annotation.length > 0) {
45
+ return annotation.text().trim();
46
+ }
47
+ const dataTex = $(el).attr('data-tex') || $(el).attr('data-latex');
48
+ if (dataTex) {
49
+ return dataTex.trim();
50
+ }
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * Extract LaTeX from MathJax elements.
56
+ * MathJax stores TeX in data-tex attribute or annotation elements.
57
+ *
58
+ * @param {Object} $ - Cheerio instance
59
+ * @param {Object} el - Cheerio element (mjx-container)
60
+ * @returns {string|null} LaTeX source or null
61
+ */
62
+ export function extractMathJaxFormula($, el) {
63
+ const dataTex = $(el).attr('data-tex') || $(el).attr('data-latex');
64
+ if (dataTex) {
65
+ return dataTex.trim();
66
+ }
67
+ const annotation = $(el).find('annotation[encoding="application/x-tex"]');
68
+ if (annotation.length > 0) {
69
+ return annotation.text().trim();
70
+ }
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * Check if an element is a formula image (Habr-specific).
76
+ *
77
+ * @param {Object} $ - Cheerio instance
78
+ * @param {Object} el - Cheerio element
79
+ * @returns {boolean}
80
+ */
81
+ export function isFormulaImage($, el) {
82
+ return (
83
+ $(el).is('img') &&
84
+ ($(el).hasClass('formula') ||
85
+ $(el).attr('source') !== undefined ||
86
+ ($(el).attr('class') || '').includes('formula'))
87
+ );
88
+ }
89
+
90
+ /**
91
+ * Check if an element is a math element (KaTeX, MathJax, or generic math class).
92
+ *
93
+ * @param {Object} $ - Cheerio instance
94
+ * @param {Object} el - Cheerio element
95
+ * @returns {boolean}
96
+ */
97
+ export function isMathElement($, el) {
98
+ const tag = (el.tagName || el.name || '').toLowerCase();
99
+ const classes = $(el).attr('class') || '';
100
+ return (
101
+ classes.includes('katex') ||
102
+ classes.includes('math') ||
103
+ classes.includes('MathJax') ||
104
+ tag === 'mjx-container'
105
+ );
106
+ }
107
+
108
+ /**
109
+ * Extract formula from any supported element type.
110
+ *
111
+ * @param {Object} $ - Cheerio instance
112
+ * @param {Object} el - Cheerio element
113
+ * @returns {string|null} LaTeX source or null
114
+ */
115
+ export function extractFormula($, el) {
116
+ if (isFormulaImage($, el)) {
117
+ return extractHabrFormula($, el);
118
+ }
119
+ const tag = (el.tagName || el.name || '').toLowerCase();
120
+ if (tag === 'mjx-container') {
121
+ return extractMathJaxFormula($, el);
122
+ }
123
+ if (isMathElement($, el)) {
124
+ return extractKatexFormula($, el);
125
+ }
126
+ return null;
127
+ }
package/src/lib.js CHANGED
@@ -6,6 +6,10 @@ import TurndownService from 'turndown';
6
6
  import iconv from 'iconv-lite';
7
7
  import { URL } from 'url';
8
8
  import turndownPluginGfm from 'turndown-plugin-gfm';
9
+ import he from 'he';
10
+ import { isFormulaImage, isMathElement, extractFormula } from './latex.js';
11
+ import { extractMetadata } from './metadata.js';
12
+ import { postProcessMarkdown } from './postprocess.js';
9
13
 
10
14
  export async function fetchHtml(url) {
11
15
  if (!url) {
@@ -162,7 +166,9 @@ export function convertHtmlToMarkdown(html, baseUrl) {
162
166
  style: false,
163
167
  });
164
168
  turndown.use(turndownPluginGfm.gfm);
165
- return turndown.turndown($.html());
169
+ // Decode HTML entities to unicode after markdown conversion
170
+ // Preserve non-breaking spaces as   entities for clear marking
171
+ return he.decode(turndown.turndown($.html())).replace(/\u00A0/g, ' ');
166
172
  }
167
173
 
168
174
  // Convert relative URLs to absolute URLs in HTML content
@@ -287,6 +293,227 @@ export function convertRelativeUrls(html, baseUrl) {
287
293
  return result;
288
294
  }
289
295
 
296
+ /**
297
+ * Enhanced HTML to Markdown conversion with LaTeX formula extraction,
298
+ * metadata extraction, code language detection, and post-processing.
299
+ *
300
+ * This is the enhanced version of convertHtmlToMarkdown that handles:
301
+ * - LaTeX formulas from img.formula (Habr), KaTeX, MathJax
302
+ * - Article metadata (author, date, views, hubs, tags)
303
+ * - Code language detection with content-based correction
304
+ * - Blockquote math grouping with \displaystyle
305
+ * - Unicode normalization, quote straightening, dash normalization
306
+ * - LaTeX spacing fixes for GitHub rendering
307
+ *
308
+ * @param {string} html - HTML content
309
+ * @param {string} [baseUrl] - Base URL for resolving relative URLs
310
+ * @param {Object} [options] - Enhanced options
311
+ * @param {boolean} [options.extractLatex=true] - Extract LaTeX formulas
312
+ * @param {boolean} [options.extractMetadata=true] - Extract article metadata
313
+ * @param {boolean} [options.postProcess=true] - Apply post-processing pipeline
314
+ * @param {boolean} [options.detectCodeLanguage=true] - Detect/correct code languages
315
+ * @returns {Object} Result with { markdown, metadata }
316
+ */
317
+ export function convertHtmlToMarkdownEnhanced(html, baseUrl, options = {}) {
318
+ const {
319
+ extractLatex = true,
320
+ extractMetadata: shouldExtractMetadata = true,
321
+ postProcess = true,
322
+ detectCodeLanguage = true,
323
+ } = options;
324
+
325
+ // Ensure all URLs are absolute before Markdown conversion
326
+ if (baseUrl) {
327
+ html = convertRelativeUrls(html, baseUrl);
328
+ }
329
+
330
+ const $ = cheerio.load(html);
331
+
332
+ // Extract metadata before cleaning
333
+ let metadata = null;
334
+ if (shouldExtractMetadata) {
335
+ metadata = extractMetadata($);
336
+ }
337
+
338
+ // Remove unwanted elements
339
+ $('style, script, noscript').remove();
340
+ $('*').each(function () {
341
+ const attribs = this.attribs || {};
342
+ Object.keys(attribs).forEach((attr) => {
343
+ if (attr.toLowerCase().startsWith('on')) {
344
+ $(this).removeAttr(attr);
345
+ }
346
+ });
347
+ });
348
+ $('a[href^="javascript:"]').remove();
349
+ $('[style]').removeAttr('style');
350
+
351
+ // Remove empty headings
352
+ $('h1, h2, h3, h4, h5, h6').each(function () {
353
+ if (!$(this).text().trim()) {
354
+ $(this).remove();
355
+ }
356
+ });
357
+
358
+ // Remove empty links (same logic as convertHtmlToMarkdown)
359
+ $('a').each(function () {
360
+ const directText = $(this)
361
+ .contents()
362
+ .filter(function () {
363
+ return this.type === 'text';
364
+ })
365
+ .text();
366
+ if (!directText.trim()) {
367
+ let allChildrenEmpty = true;
368
+ $(this)
369
+ .children()
370
+ .each(function () {
371
+ if (
372
+ $(this).text().trim() ||
373
+ (this.tagName === 'img' && $(this).attr('alt'))
374
+ ) {
375
+ allChildrenEmpty = false;
376
+ }
377
+ });
378
+ if (allChildrenEmpty) {
379
+ $(this).remove();
380
+ }
381
+ }
382
+ });
383
+ $('a').each(function () {
384
+ const children = $(this).children();
385
+ if (
386
+ children.length === 1 &&
387
+ children[0].tagName === 'img' &&
388
+ !$(children[0]).attr('alt')
389
+ ) {
390
+ $(this).remove();
391
+ }
392
+ });
393
+ $('a').each(function () {
394
+ if (!$(this).text().trim()) {
395
+ $(this).remove();
396
+ }
397
+ });
398
+
399
+ // Handle LaTeX formulas before Turndown conversion
400
+ if (extractLatex) {
401
+ // Replace Habr formula images with LaTeX text
402
+ $('img.formula, img[source]').each(function () {
403
+ if (isFormulaImage($, this)) {
404
+ const latex = extractFormula($, this);
405
+ if (latex) {
406
+ $(this).replaceWith(`$${latex}$`);
407
+ }
408
+ }
409
+ });
410
+
411
+ // Replace KaTeX/MathJax elements
412
+ $('.katex, .math, mjx-container, .MathJax').each(function () {
413
+ if (isMathElement($, this)) {
414
+ const latex = extractFormula($, this);
415
+ if (latex) {
416
+ $(this).replaceWith(`$${latex}$`);
417
+ }
418
+ }
419
+ });
420
+ }
421
+
422
+ // Handle code language detection/correction
423
+ if (detectCodeLanguage) {
424
+ $('pre code').each(function () {
425
+ const codeText = $(this).text().trim();
426
+ const language =
427
+ $(this)
428
+ .attr('class')
429
+ ?.match(/language-(\w+)/)?.[1] ||
430
+ $(this)
431
+ .attr('class')
432
+ ?.match(/^(\w+)$/)?.[1] ||
433
+ '';
434
+
435
+ // Content-based language correction
436
+ if (
437
+ language === 'matlab' &&
438
+ /\b(Require\s+Import|Definition|Fixpoint|Lemma|Theorem|Proof|Qed|Notation|Inductive)\b/.test(
439
+ codeText
440
+ )
441
+ ) {
442
+ $(this).removeClass(`language-${language}`).addClass('language-coq');
443
+ }
444
+ });
445
+ }
446
+
447
+ // Preprocess ARIA tables (same as original)
448
+ $('[role="table"]').each(function () {
449
+ const $table = $(this);
450
+ const $newTable = $('<table></table>');
451
+ const label = $table.attr('aria-label');
452
+ const descId = $table.attr('aria-describedby');
453
+ if (label) {
454
+ $newTable.append(`<caption>${label}</caption>`);
455
+ } else if (descId && $(`#${descId}`).length) {
456
+ $newTable.append(`<caption>${$(`#${descId}`).text()}</caption>`);
457
+ }
458
+ const rowgroups = $table.find('> [role="rowgroup"]');
459
+ if (rowgroups.length > 0) {
460
+ const $thead = $('<thead></thead>');
461
+ const $tbody = $('<tbody></tbody>');
462
+ rowgroups.each(function (i) {
463
+ $(this)
464
+ .find('> [role="row"]')
465
+ .each(function () {
466
+ const $row = $(this);
467
+ const $tr = $('<tr></tr>');
468
+ $row.children('[role="columnheader"]').each(function () {
469
+ $tr.append($('<th></th>').text($(this).text()));
470
+ });
471
+ $row.children('[role="cell"]').each(function () {
472
+ $tr.append($('<td></td>').text($(this).text()));
473
+ });
474
+ if (i === 0 && $tr.children('th').length) {
475
+ $thead.append($tr);
476
+ } else {
477
+ $tbody.append($tr);
478
+ }
479
+ });
480
+ });
481
+ if ($thead.children().length) {
482
+ $newTable.append($thead);
483
+ }
484
+ if ($tbody.children().length) {
485
+ $newTable.append($tbody);
486
+ }
487
+ }
488
+ $table.replaceWith($newTable);
489
+ });
490
+
491
+ // Convert to Markdown using Turndown
492
+ const turndown = new TurndownService({
493
+ headingStyle: 'atx',
494
+ codeBlockStyle: 'fenced',
495
+ emDelimiter: '*',
496
+ bulletListMarker: '-',
497
+ strongDelimiter: '**',
498
+ linkStyle: 'inlined',
499
+ linkReferenceStyle: 'full',
500
+ hr: '---',
501
+ style: false,
502
+ });
503
+ turndown.use(turndownPluginGfm.gfm);
504
+
505
+ // Decode HTML entities to unicode after markdown conversion
506
+ // Normalize non-breaking spaces to regular spaces in Markdown text
507
+ let markdown = he.decode(turndown.turndown($.html())).replace(/\u00A0/g, ' ');
508
+
509
+ // Apply post-processing
510
+ if (postProcess) {
511
+ markdown = postProcessMarkdown(markdown);
512
+ }
513
+
514
+ return { markdown, metadata };
515
+ }
516
+
290
517
  // Convert HTML content to UTF-8 if it's not already
291
518
  export function convertToUtf8(html) {
292
519
  // First, try to detect the current encoding from meta tag
@@ -328,3 +555,56 @@ export function ensureUtf8(html) {
328
555
  }
329
556
  return html;
330
557
  }
558
+
559
+ const VOID_TAGS = new Set([
560
+ 'area',
561
+ 'base',
562
+ 'br',
563
+ 'col',
564
+ 'embed',
565
+ 'hr',
566
+ 'img',
567
+ 'input',
568
+ 'link',
569
+ 'meta',
570
+ 'param',
571
+ 'source',
572
+ 'track',
573
+ 'wbr',
574
+ ]);
575
+
576
+ export function prettyPrintHtml(html) {
577
+ const tagRe = /(<\/?[a-zA-Z][^>]*?>)/g;
578
+ const parts = html.split(tagRe).filter(Boolean);
579
+ let indent = 0;
580
+ const indentStr = ' ';
581
+ const lines = [];
582
+
583
+ for (const part of parts) {
584
+ const isTag = part.startsWith('<');
585
+ if (!isTag) {
586
+ const text = part.trim();
587
+ if (text) {
588
+ lines.push(indentStr.repeat(indent) + text);
589
+ }
590
+ continue;
591
+ }
592
+ const isClosing = part.startsWith('</');
593
+ const tagMatch = part.match(/^<\/?([a-zA-Z][a-zA-Z0-9]*)/);
594
+ const tagName = tagMatch ? tagMatch[1].toLowerCase() : '';
595
+ const isVoid = VOID_TAGS.has(tagName);
596
+ const isSelfClosing = part.endsWith('/>');
597
+
598
+ if (isClosing) {
599
+ indent = Math.max(0, indent - 1);
600
+ lines.push(indentStr.repeat(indent) + part);
601
+ } else if (isVoid || isSelfClosing) {
602
+ lines.push(indentStr.repeat(indent) + part);
603
+ } else {
604
+ lines.push(indentStr.repeat(indent) + part);
605
+ indent++;
606
+ }
607
+ }
608
+
609
+ return `${lines.join('\n')}\n`;
610
+ }