seo-intel 1.4.3 → 1.4.5

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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.5 (2026-04-09)
4
+
5
+ ### Export: actionable summaries only
6
+ - Technical export: per-page issue summary (own site only) — lists specific problems per URL
7
+ - Links export: per-page link issue summary (own site only) — orphan pages, missing anchors, excessive external links
8
+ - No more raw data dumps in profile exports — every row is an action item
9
+
10
+ ## 1.4.4 (2026-04-08)
11
+
12
+ ### Export Profiles
13
+ - New profile-based export: Developer, Content, and AI Pipeline profiles
14
+ - Each profile filters to actionable data only — no raw database dumps
15
+ - Developer profile: technical issues, heading problems (own site only), orphan links, schema gaps
16
+ - Content profile: keyword gaps, long-tail opportunities, citability issues, content gaps
17
+ - AI Pipeline profile: structured JSON with all actionable sections for LLM consumption
18
+ - Heading export collapsed to per-page issue summaries (missing H1, duplicate H1, skipped levels)
19
+ - Empty sections automatically skipped in exports
20
+ - Profile picker UI in dashboard sidebar with format selector (MD, JSON, CSV, ZIP)
21
+
3
22
  ## 1.4.3 (2026-04-07)
4
23
 
5
24
  ### Dashboard: Export & Download
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-intel",
3
- "version": "1.4.3",
3
+ "version": "1.4.5",
4
4
  "description": "Local Ahrefs-style SEO competitor intelligence. Crawl → SQLite → cloud analysis.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -1525,6 +1525,20 @@ function buildHtmlTemplate(data, opts = {}) {
1525
1525
  .export-btn:hover { border-color: var(--accent-gold); color: var(--accent-gold); }
1526
1526
  .export-btn i { margin-right: 5px; font-size: 0.6rem; }
1527
1527
  .export-btn.active { border-color: var(--accent-gold); color: var(--accent-gold); background: rgba(232,213,163,0.06); }
1528
+ .profile-export-picker { position: relative; }
1529
+ .profile-export-trigger { display: flex; align-items: center; width: 100%; }
1530
+ .profile-export-menu {
1531
+ display: none;
1532
+ position: absolute;
1533
+ bottom: calc(100% + 4px);
1534
+ left: 0; right: 0;
1535
+ background: #1a1a1a;
1536
+ border: 1px solid var(--accent-gold);
1537
+ border-radius: var(--radius);
1538
+ padding: 10px;
1539
+ z-index: 50;
1540
+ box-shadow: 0 8px 24px rgba(0,0,0,0.5);
1541
+ }
1528
1542
  .draft-dropdown { position: relative; }
1529
1543
  .draft-trigger { display: flex; align-items: center; width: 100%; }
1530
1544
  .draft-menu {
@@ -2193,7 +2207,24 @@ function buildHtmlTemplate(data, opts = {}) {
2193
2207
  <i class="fa-solid fa-download"></i> Download
2194
2208
  </div>
2195
2209
  <div class="export-sidebar-btns">
2196
- <button class="export-btn download-all-btn" data-project="${project}"><i class="fa-solid fa-file-zipper"></i> Download All Reports (ZIP)</button>
2210
+ <div class="profile-export-picker" id="profilePicker${suffix}">
2211
+ <button class="export-btn profile-export-trigger"><i class="fa-solid fa-download"></i> Export Report <i class="fa-solid fa-chevron-down" style="font-size:0.55rem;margin-left:auto;opacity:0.5;"></i></button>
2212
+ <div class="profile-export-menu">
2213
+ <div class="draft-menu-section">Profile</div>
2214
+ <label class="draft-option"><input type="radio" name="exportProfile${suffix}" value="dev" /> <i class="fa-solid fa-wrench"></i> Developer <span style="font-size:0.5rem;opacity:0.45;margin-left:2px;">technical fixes, schema gaps</span></label>
2215
+ <label class="draft-option"><input type="radio" name="exportProfile${suffix}" value="content" checked /> <i class="fa-solid fa-pen-fancy"></i> Content <span style="font-size:0.5rem;opacity:0.45;margin-left:2px;">keyword gaps, opportunities</span></label>
2216
+ <label class="draft-option"><input type="radio" name="exportProfile${suffix}" value="ai-pipeline" /> <i class="fa-solid fa-robot"></i> AI Pipeline <span style="font-size:0.5rem;opacity:0.45;margin-left:2px;">structured JSON for LLMs</span></label>
2217
+ <div class="draft-menu-section" style="margin-top:8px;">Format</div>
2218
+ <div style="display:flex;gap:6px;flex-wrap:wrap;">
2219
+ <label class="draft-option" style="flex:1;min-width:60px;"><input type="radio" name="exportFmt${suffix}" value="md" checked /> <i class="fa-solid fa-file-lines"></i> MD</label>
2220
+ <label class="draft-option" style="flex:1;min-width:60px;"><input type="radio" name="exportFmt${suffix}" value="json" /> <i class="fa-solid fa-code"></i> JSON</label>
2221
+ <label class="draft-option" style="flex:1;min-width:60px;"><input type="radio" name="exportFmt${suffix}" value="csv" /> <i class="fa-solid fa-table"></i> CSV</label>
2222
+ <label class="draft-option" style="flex:1;min-width:60px;"><input type="radio" name="exportFmt${suffix}" value="zip" /> <i class="fa-solid fa-file-zipper"></i> ZIP</label>
2223
+ </div>
2224
+ <button class="draft-generate-btn profile-download-btn" data-project="${project}" style="margin-top:10px;"><i class="fa-solid fa-download"></i> Download</button>
2225
+ </div>
2226
+ </div>
2227
+ <button class="export-btn download-all-btn" data-project="${project}" style="font-size:0.58rem;opacity:0.6;"><i class="fa-solid fa-file-zipper"></i> Raw Full Export (ZIP)</button>
2197
2228
  </div>
2198
2229
  <div style="position:relative;">
2199
2230
  <div id="exportSaveStatus${suffix}" style="display:none;padding:4px 10px;font-size:.6rem;color:var(--color-success);background:rgba(80,200,120,0.06);border-bottom:1px solid rgba(80,200,120,0.15);font-family:'SF Mono',monospace;">
@@ -2592,8 +2623,40 @@ function buildHtmlTemplate(data, opts = {}) {
2592
2623
  }
2593
2624
  return;
2594
2625
  }
2626
+ // Profile export picker toggle
2627
+ var profTrigger = e.target.closest('.profile-export-trigger');
2628
+ if (profTrigger) {
2629
+ var picker = profTrigger.closest('.profile-export-picker');
2630
+ if (picker) {
2631
+ e.stopImmediatePropagation();
2632
+ var menu = picker.querySelector('.profile-export-menu');
2633
+ var wasVis = menu.style.display === 'block';
2634
+ document.querySelectorAll('.profile-export-menu').forEach(function(m) { m.style.display = 'none'; });
2635
+ menu.style.display = wasVis ? 'none' : 'block';
2636
+ return;
2637
+ }
2638
+ }
2639
+ // Profile download button
2640
+ var profDl = e.target.closest('.profile-download-btn');
2641
+ if (profDl) {
2642
+ var picker2 = profDl.closest('.profile-export-picker');
2643
+ if (picker2) {
2644
+ e.stopImmediatePropagation();
2645
+ var projP = profDl.getAttribute('data-project');
2646
+ var profVal = picker2.querySelector('input[name^="exportProfile"]:checked');
2647
+ var fmtVal = picker2.querySelector('input[name^="exportFmt"]:checked');
2648
+ var prof = profVal ? profVal.value : 'content';
2649
+ var fmt2 = fmtVal ? fmtVal.value : 'md';
2650
+ picker2.querySelector('.profile-export-menu').style.display = 'none';
2651
+ if (window.location.protocol.startsWith('http')) {
2652
+ window.location = '/api/export/download?project=' + encodeURIComponent(projP) + '&profile=' + encodeURIComponent(prof) + '&format=' + encodeURIComponent(fmt2);
2653
+ }
2654
+ return;
2655
+ }
2656
+ }
2595
2657
  // Outside click — close all open dropdowns
2596
2658
  document.querySelectorAll('.card-export.open').forEach(function(el) { el.classList.remove('open'); });
2659
+ document.querySelectorAll('.profile-export-menu').forEach(function(m) { m.style.display = 'none'; });
2597
2660
  }, true);
2598
2661
  }
2599
2662
 
package/server.js CHANGED
@@ -594,6 +594,7 @@ async function handleRequest(req, res) {
594
594
  const project = url.searchParams.get('project');
595
595
  const section = url.searchParams.get('section') || 'all';
596
596
  const format = url.searchParams.get('format') || 'json';
597
+ const profile = url.searchParams.get('profile'); // dev | content | ai-pipeline
597
598
 
598
599
  if (!project) { json(res, 400, { error: 'Missing project' }); return; }
599
600
 
@@ -607,6 +608,25 @@ async function handleRequest(req, res) {
607
608
 
608
609
  const SECTIONS = ['aeo', 'insights', 'technical', 'keywords', 'pages', 'watch', 'schemas', 'headings', 'links'];
609
610
 
611
+ // ── Profile definitions: which sections + which insight types matter ──
612
+ const PROFILES = {
613
+ dev: {
614
+ sections: ['technical', 'schemas', 'links', 'headings', 'watch', 'insights'],
615
+ insightTypes: ['technical_gap', 'quick_win', 'site_watch'],
616
+ label: 'Developer',
617
+ },
618
+ content: {
619
+ sections: ['insights', 'keywords', 'aeo'],
620
+ insightTypes: ['keyword_gap', 'long_tail', 'content_gap', 'new_page', 'keyword_inventor', 'citability_gap', 'positioning'],
621
+ label: 'Content',
622
+ },
623
+ 'ai-pipeline': {
624
+ sections: ['insights', 'aeo', 'technical', 'keywords', 'watch'],
625
+ insightTypes: null, // all types
626
+ label: 'AI Pipeline',
627
+ },
628
+ };
629
+
610
630
  function querySection(sec) {
611
631
  switch (sec) {
612
632
  case 'aeo': {
@@ -726,6 +746,128 @@ async function handleRequest(req, res) {
726
746
  }
727
747
  }
728
748
 
749
+ // ── Profile-aware filtering: strip raw dumps, keep actionable items ──
750
+ function filterForProfile(sec, data, prof) {
751
+ if (!prof || !data) return data;
752
+ const p = PROFILES[prof];
753
+ if (!p) return data;
754
+
755
+ switch (sec) {
756
+ case 'insights': {
757
+ if (!Array.isArray(data)) return data;
758
+ return p.insightTypes ? data.filter(r => p.insightTypes.includes(r._type)) : data;
759
+ }
760
+ case 'technical': {
761
+ if (!Array.isArray(data) || prof === 'ai-pipeline') return data;
762
+ // Own site only, per-page issue summary
763
+ const own = data.filter(r => r.role === 'target' || r.role === 'owned');
764
+ const issues = [];
765
+ for (const r of own) {
766
+ const problems = [];
767
+ if (r.status_code >= 400) problems.push(`HTTP ${r.status_code}`);
768
+ if (!r.has_canonical) problems.push('no canonical');
769
+ if (!r.has_og_tags) problems.push('no OG tags');
770
+ if (!r.has_schema) problems.push('no schema');
771
+ if (!r.has_robots) problems.push('no robots meta');
772
+ if (!r.is_mobile_ok) problems.push('not mobile-friendly');
773
+ if (r.load_ms && r.load_ms > 3000) problems.push(`slow (${r.load_ms}ms)`);
774
+ if (r.word_count != null && r.word_count < 100) problems.push(`thin content (${r.word_count} words)`);
775
+ if (problems.length) {
776
+ issues.push({ url: r.url, domain: r.domain, issues: problems.join(', '), status: r.status_code, load_ms: r.load_ms, word_count: r.word_count });
777
+ }
778
+ }
779
+ return issues;
780
+ }
781
+ case 'headings': {
782
+ if (!Array.isArray(data)) return data;
783
+ // Own site only — group by page, return per-page issue summary
784
+ const ownOnly = data.filter(r => r.role === 'target' || r.role === 'owned');
785
+ const byPage = {};
786
+ for (const r of ownOnly) (byPage[r.url] ||= []).push(r);
787
+ const issues = [];
788
+ for (const [url, headings] of Object.entries(byPage)) {
789
+ const h1s = headings.filter(h => h.level === 1);
790
+ const levels = headings.map(h => h.level);
791
+ const problems = [];
792
+ if (h1s.length === 0) problems.push('missing H1');
793
+ else if (h1s.length > 1) problems.push(`${h1s.length}× H1`);
794
+ // Check for skipped levels (e.g. H1→H3 skips H2)
795
+ const unique = [...new Set(levels)].sort((a, b) => a - b);
796
+ for (let i = 1; i < unique.length; i++) {
797
+ if (unique[i] - unique[i - 1] > 1) {
798
+ problems.push(`skips H${unique[i - 1]}→H${unique[i]}`);
799
+ }
800
+ }
801
+ if (problems.length) {
802
+ const sequence = levels.map(l => `H${l}`).join(' → ');
803
+ issues.push({ url, domain: headings[0].domain, issues: problems.join(', '), sequence, heading_count: headings.length });
804
+ }
805
+ }
806
+ return issues;
807
+ }
808
+ case 'links': {
809
+ if (!Array.isArray(data)) return data;
810
+ // Own site only, summarize to per-page link issues
811
+ const ownLinks = data.filter(r => r.role === 'target' || r.role === 'owned');
812
+ const internalTargets = new Set(ownLinks.filter(l => l.is_internal).map(l => l.target_url));
813
+ const byPage = {};
814
+ for (const r of ownLinks) (byPage[r.source_url] ||= []).push(r);
815
+ const issues = [];
816
+ for (const [url, links] of Object.entries(byPage)) {
817
+ const problems = [];
818
+ const noAnchor = links.filter(l => !l.anchor_text);
819
+ if (noAnchor.length) problems.push(`${noAnchor.length} links missing anchor text`);
820
+ if (!internalTargets.has(url)) problems.push('orphan page (no internal links point here)');
821
+ const extLinks = links.filter(l => !l.is_internal);
822
+ // flag if page has excessive external links
823
+ if (extLinks.length > 20) problems.push(`${extLinks.length} external links`);
824
+ if (problems.length) {
825
+ issues.push({ url, domain: links[0].domain, issues: problems.join(', '), total_links: links.length, internal: links.filter(l => l.is_internal).length, external: extLinks.length });
826
+ }
827
+ }
828
+ return issues;
829
+ }
830
+ case 'schemas': {
831
+ if (!Array.isArray(data) || prof !== 'dev') return data;
832
+ // Dev: pages missing schema are more useful — but we only have pages WITH schema here
833
+ // So return all (schema gaps come from technical section's has_schema=false)
834
+ return data;
835
+ }
836
+ case 'aeo': {
837
+ if (!Array.isArray(data)) return data;
838
+ if (prof === 'content') {
839
+ // Content: only low-scoring pages (needs improvement)
840
+ return data.filter(r => r.score < 60);
841
+ }
842
+ return data;
843
+ }
844
+ case 'keywords': {
845
+ if (!Array.isArray(data)) return data;
846
+ if (prof === 'content') {
847
+ // Content: only competitor-dominated keywords (role != target/owned)
848
+ const byKw = {};
849
+ for (const r of data) { (byKw[r.keyword] ||= []).push(r); }
850
+ const gapKws = new Set();
851
+ for (const [kw, rows] of Object.entries(byKw)) {
852
+ const hasTarget = rows.some(r => r.role === 'target' || r.role === 'owned');
853
+ const hasCompetitor = rows.some(r => r.role === 'competitor');
854
+ if (!hasTarget && hasCompetitor) gapKws.add(kw);
855
+ }
856
+ return data.filter(r => gapKws.has(r.keyword));
857
+ }
858
+ return data;
859
+ }
860
+ case 'watch': {
861
+ // Keep only errors + warnings, drop notices
862
+ if (data && data.events) {
863
+ return { ...data, events: data.events.filter(e => e.severity === 'error' || e.severity === 'warning') };
864
+ }
865
+ return data;
866
+ }
867
+ default: return data;
868
+ }
869
+ }
870
+
729
871
  function toCSV(rows) {
730
872
  if (!rows || (Array.isArray(rows) && !rows.length)) return '';
731
873
  const arr = Array.isArray(rows) ? rows : (rows.events || rows.pages || []);
@@ -834,26 +976,56 @@ async function handleRequest(req, res) {
834
976
  }
835
977
  }
836
978
 
837
- // Build response based on section + format
838
- const sections = section === 'all' ? SECTIONS : [section];
839
- if (section !== 'all' && !SECTIONS.includes(section)) {
979
+ // ── Resolve sections: profile overrides section=all ──
980
+ const validProfiles = Object.keys(PROFILES);
981
+ if (profile && !validProfiles.includes(profile)) {
982
+ json(res, 400, { error: `Invalid profile. Allowed: ${validProfiles.join(', ')}` });
983
+ return;
984
+ }
985
+ const resolvedSections = profile
986
+ ? PROFILES[profile].sections
987
+ : (section === 'all' ? SECTIONS : [section]);
988
+
989
+ if (!profile && section !== 'all' && !SECTIONS.includes(section)) {
840
990
  json(res, 400, { error: `Invalid section. Allowed: ${SECTIONS.join(', ')}, all` });
841
991
  return;
842
992
  }
843
993
 
994
+ // Helper: query + filter for profile
995
+ function getData(sec) {
996
+ const raw = querySection(sec);
997
+ return profile ? filterForProfile(sec, raw, profile) : raw;
998
+ }
999
+
1000
+ function isEmpty(data) {
1001
+ if (!data) return true;
1002
+ if (Array.isArray(data)) return data.length === 0;
1003
+ if (data.events) return data.events.length === 0;
1004
+ return false;
1005
+ }
1006
+
1007
+ const profileTag = profile ? `-${profile}` : '';
1008
+ const profileLabel = profile ? PROFILES[profile].label : '';
1009
+
844
1010
  if (format === 'zip') {
845
- // ZIP: bundle all requested sections in all formats
846
1011
  const entries = [];
847
- for (const sec of sections) {
848
- const data = querySection(sec);
849
- const baseName = `${project}-${sec}-${dateStr}`;
1012
+ for (const sec of resolvedSections) {
1013
+ const data = getData(sec);
1014
+ if (isEmpty(data)) continue; // skip empty sections
1015
+ const baseName = `${project}${profileTag}-${sec}-${dateStr}`;
850
1016
  entries.push({ name: `${baseName}.json`, content: JSON.stringify(data, null, 2) });
851
1017
  entries.push({ name: `${baseName}.md`, content: toMarkdown(sec, data, project) });
852
1018
  const csv = toCSV(data);
853
1019
  if (csv) entries.push({ name: `${baseName}.csv`, content: csv });
854
1020
  }
1021
+ if (!entries.length) {
1022
+ json(res, 200, { message: 'No actionable data to export.' });
1023
+ return;
1024
+ }
855
1025
  const zipBuf = createZip(entries);
856
- const zipName = section === 'all' ? `${project}-full-export-${dateStr}.zip` : `${project}-${section}-${dateStr}.zip`;
1026
+ const zipName = profile
1027
+ ? `${project}-${profile}-export-${dateStr}.zip`
1028
+ : (section === 'all' ? `${project}-full-export-${dateStr}.zip` : `${project}-${section}-${dateStr}.zip`);
857
1029
  res.writeHead(200, {
858
1030
  'Content-Type': 'application/zip',
859
1031
  'Content-Disposition': `attachment; filename="${zipName}"`,
@@ -861,30 +1033,77 @@ async function handleRequest(req, res) {
861
1033
  });
862
1034
  res.end(zipBuf);
863
1035
  } else if (format === 'json') {
864
- const data = querySection(sections[0]);
865
- const fileName = `${project}-${sections[0]}-${dateStr}.json`;
866
- const content = JSON.stringify(data, null, 2);
867
- res.writeHead(200, {
868
- 'Content-Type': 'application/json',
869
- 'Content-Disposition': `attachment; filename="${fileName}"`,
870
- });
871
- res.end(content);
1036
+ if (profile) {
1037
+ // Profile JSON: merged object with all profile sections
1038
+ const result = { profile: profileLabel, project, date: dateStr, sections: {} };
1039
+ for (const sec of resolvedSections) {
1040
+ const data = getData(sec);
1041
+ if (!isEmpty(data)) result.sections[sec] = data;
1042
+ }
1043
+ const fileName = `${project}-${profile}-${dateStr}.json`;
1044
+ res.writeHead(200, {
1045
+ 'Content-Type': 'application/json',
1046
+ 'Content-Disposition': `attachment; filename="${fileName}"`,
1047
+ });
1048
+ res.end(JSON.stringify(result, null, 2));
1049
+ } else {
1050
+ const data = getData(resolvedSections[0]);
1051
+ const fileName = `${project}-${resolvedSections[0]}-${dateStr}.json`;
1052
+ res.writeHead(200, {
1053
+ 'Content-Type': 'application/json',
1054
+ 'Content-Disposition': `attachment; filename="${fileName}"`,
1055
+ });
1056
+ res.end(JSON.stringify(data, null, 2));
1057
+ }
872
1058
  } else if (format === 'csv') {
873
- const data = querySection(sections[0]);
874
- const fileName = `${project}-${sections[0]}-${dateStr}.csv`;
875
- res.writeHead(200, {
876
- 'Content-Type': 'text/csv; charset=utf-8',
877
- 'Content-Disposition': `attachment; filename="${fileName}"`,
878
- });
879
- res.end(toCSV(data));
1059
+ if (profile) {
1060
+ // Profile CSV: concatenate sections with headers
1061
+ let csv = '';
1062
+ for (const sec of resolvedSections) {
1063
+ const data = getData(sec);
1064
+ const secCsv = toCSV(data);
1065
+ if (secCsv) csv += `# ${sec}\n${secCsv}\n\n`;
1066
+ }
1067
+ const fileName = `${project}-${profile}-${dateStr}.csv`;
1068
+ res.writeHead(200, {
1069
+ 'Content-Type': 'text/csv; charset=utf-8',
1070
+ 'Content-Disposition': `attachment; filename="${fileName}"`,
1071
+ });
1072
+ res.end(csv || 'No actionable data.');
1073
+ } else {
1074
+ const data = getData(resolvedSections[0]);
1075
+ const fileName = `${project}-${resolvedSections[0]}-${dateStr}.csv`;
1076
+ res.writeHead(200, {
1077
+ 'Content-Type': 'text/csv; charset=utf-8',
1078
+ 'Content-Disposition': `attachment; filename="${fileName}"`,
1079
+ });
1080
+ res.end(toCSV(data));
1081
+ }
880
1082
  } else if (format === 'md') {
881
- const data = querySection(sections[0]);
882
- const fileName = `${project}-${sections[0]}-${dateStr}.md`;
883
- res.writeHead(200, {
884
- 'Content-Type': 'text/markdown; charset=utf-8',
885
- 'Content-Disposition': `attachment; filename="${fileName}"`,
886
- });
887
- res.end(toMarkdown(sections[0], data, project));
1083
+ if (profile) {
1084
+ // Profile Markdown: combined report
1085
+ let md = `# SEO Intel — ${profileLabel} Report\n\n- Project: ${project}\n- Date: ${dateStr}\n- Profile: ${profileLabel}\n\n`;
1086
+ for (const sec of resolvedSections) {
1087
+ const data = getData(sec);
1088
+ if (isEmpty(data)) continue;
1089
+ md += toMarkdown(sec, data, project).replace(/^# .+\n\n- Project:.+\n- Date:.+\n\n/, ''); // strip per-section header
1090
+ }
1091
+ if (md.split('\n').length < 8) md += '_No actionable data found. Run crawl + extract + analyze first._\n';
1092
+ const fileName = `${project}-${profile}-${dateStr}.md`;
1093
+ res.writeHead(200, {
1094
+ 'Content-Type': 'text/markdown; charset=utf-8',
1095
+ 'Content-Disposition': `attachment; filename="${fileName}"`,
1096
+ });
1097
+ res.end(md);
1098
+ } else {
1099
+ const data = getData(resolvedSections[0]);
1100
+ const fileName = `${project}-${resolvedSections[0]}-${dateStr}.md`;
1101
+ res.writeHead(200, {
1102
+ 'Content-Type': 'text/markdown; charset=utf-8',
1103
+ 'Content-Disposition': `attachment; filename="${fileName}"`,
1104
+ });
1105
+ res.end(toMarkdown(resolvedSections[0], data, project));
1106
+ }
888
1107
  } else {
889
1108
  json(res, 400, { error: 'Invalid format. Allowed: json, csv, md, zip' });
890
1109
  }