@knowcode/doc-builder 1.9.31 → 1.10.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.
@@ -919,10 +919,10 @@ function buildNavigationStructure(files, currentFile, config = {}) {
919
919
  return title;
920
920
  };
921
921
 
922
- // Helper function to render a section
922
+ // OPTIMIZATION: Helper function to render a section using array joins instead of string concatenation
923
923
  const renderSection = (folderName, folderData, level = 0, parentPath = '') => {
924
924
  const icons = {
925
- 'root': 'ph ph-caret-down', // Changed to caret for collapse/expand functionality
925
+ 'root': 'ph ph-caret-down',
926
926
  'product-roadmap': 'ph ph-road-horizon',
927
927
  'product-requirements': 'ph ph-list-checks',
928
928
  'architecture': 'ph ph-tree-structure',
@@ -947,102 +947,95 @@ function buildNavigationStructure(files, currentFile, config = {}) {
947
947
  'launch': 'ph ph-rocket-launch',
948
948
  'prompts': 'ph ph-chat-circle-dots'
949
949
  };
950
-
951
- const displayName = folderName === 'root' ? 'Documentation' :
950
+
951
+ const displayName = folderName === 'root' ? 'Documentation' :
952
952
  smartCapitalize(folderName);
953
953
  const icon = icons[folderName] || 'ph ph-folder';
954
-
954
+
955
955
  if (!folderData.files.length && !Object.keys(folderData.folders).length) {
956
956
  return '';
957
957
  }
958
-
958
+
959
959
  // Include parent path in section ID to make it unique
960
960
  const pathParts = parentPath ? [parentPath, folderName].join('-') : folderName;
961
961
  const sectionId = `nav-${pathParts}-${level}`;
962
962
  const isCollapsible = level > 0 || folderName !== 'root';
963
963
  const collapseIcon = isCollapsible ? '<i class="ph ph-caret-right collapse-icon"></i>' : '';
964
-
964
+
965
965
  // Check if this folder has a README.md file to link to
966
966
  const readmeFile = folderData.files.find(f => f.displayName === 'README');
967
- const folderLink = readmeFile ?
968
- `href="${config.isStaticOutput ? readmeFile.urlPath : '/' + readmeFile.urlPath}"` :
967
+ const folderLink = readmeFile ?
968
+ `href="${config.isStaticOutput ? readmeFile.urlPath : '/' + readmeFile.urlPath}"` :
969
969
  'href="#"';
970
-
970
+
971
971
  // Get folder description for tooltip
972
972
  const folderDescription = folderDescriptions[folderName] || '';
973
973
  const tooltipAttr = folderDescription ? `data-tooltip="${escapeHtml(folderDescription)}"` : '';
974
-
974
+
975
975
  // Check if this folder has active child or if we're on index page
976
976
  const hasActiveChild = checkActiveChild(folderData, currentFile);
977
- // Expand all folders by default on initial page load (index.html or root README)
978
977
  const shouldExpand = hasActiveChild || currentFile === 'index.html' || (currentFile === 'README.html' && level === 1);
979
-
978
+
980
979
  // Check if this is a private folder
981
980
  const isPrivateFolder = folderName === 'private' || parentPath.includes('private');
982
981
  const privateClass = isPrivateFolder ? ' private-nav' : '';
983
-
984
- // Special handling for root folder - make it a toggle-all button
982
+
983
+ // OPTIMIZATION: Use array instead of string concatenation
984
+ const htmlParts = [];
985
985
  const isRoot = folderName === 'root' && level === 0;
986
- let html = '';
987
-
986
+
988
987
  if (isRoot) {
989
- // Root gets special toggle-all functionality
990
- html = `
988
+ htmlParts.push(`
991
989
  <div class="nav-section${privateClass}" data-level="${level}">
992
990
  <a class="nav-title toggle-all-nav expanded" href="#" id="nav-toggle-all" title="Collapse/Expand All">
993
991
  <i class="ph ph-caret-down" id="toggle-all-icon"></i> ${displayName}
994
992
  </a>
995
- <div class="nav-content">`;
993
+ <div class="nav-content">`);
996
994
  } else {
997
- // Normal folder rendering
998
- html = `
995
+ htmlParts.push(`
999
996
  <div class="nav-section${privateClass}" data-level="${level}">
1000
997
  <a class="nav-title${isCollapsible ? ' collapsible' : ''}${shouldExpand ? ' expanded' : ''}" ${folderLink} ${isCollapsible ? `data-target="${sectionId}"` : ''} ${tooltipAttr}>
1001
998
  ${collapseIcon}<i class="${icon}"></i> ${displayName}
1002
999
  </a>
1003
- <div class="nav-content${isCollapsible ? (shouldExpand ? '' : ' collapsed') : ''}" ${isCollapsible ? `id="${sectionId}"` : ''}>`;
1000
+ <div class="nav-content${isCollapsible ? (shouldExpand ? '' : ' collapsed') : ''}" ${isCollapsible ? `id="${sectionId}"` : ''}>`);
1004
1001
  }
1005
-
1002
+
1006
1003
  // Sort and render files
1007
1004
  const sortedFiles = [...folderData.files].sort((a, b) => {
1008
1005
  if (a.displayName === 'README') return -1;
1009
1006
  if (b.displayName === 'README') return 1;
1010
1007
  return a.displayName.localeCompare(b.displayName);
1011
1008
  });
1012
-
1009
+
1013
1010
  sortedFiles.forEach(file => {
1014
1011
  const title = generateFileTitle(file, displayName, level);
1015
-
1016
- // Check if this file is active
1012
+
1017
1013
  let isActive = '';
1018
1014
  if (currentFile === file.urlPath) {
1019
1015
  isActive = ' active';
1020
1016
  } else if (currentFile === 'index.html' && file.displayName === 'README' && folderName === 'root') {
1021
- // Mark root README as active when viewing index.html
1022
1017
  isActive = ' active';
1023
1018
  }
1024
-
1025
- // Use relative paths for static output
1019
+
1026
1020
  const linkPath = config.isStaticOutput ? file.urlPath : '/' + file.urlPath;
1027
1021
  const tooltip = file.summary ? ` data-tooltip="${escapeHtml(file.summary)}"` : '';
1028
1022
  const icon = getIconForStatus(file.status || 'default', false, config);
1029
-
1030
- html += `
1031
- <a href="${linkPath}" class="nav-item${isActive}"${tooltip}>${icon} ${title}</a>`;
1023
+
1024
+ htmlParts.push(`
1025
+ <a href="${linkPath}" class="nav-item${isActive}"${tooltip}>${icon} ${title}</a>`);
1032
1026
  });
1033
-
1034
- html += `</div></div>`;
1035
-
1027
+
1028
+ htmlParts.push(`</div></div>`);
1029
+
1036
1030
  // Render subfolders AFTER closing the parent section
1037
1031
  Object.keys(folderData.folders)
1038
1032
  .sort()
1039
1033
  .forEach(subFolder => {
1040
- // Build the path for the subfolder including current folder
1041
1034
  const currentPath = parentPath ? `${parentPath}-${folderName}` : folderName;
1042
- html += renderSection(subFolder, folderData.folders[subFolder], level + 1, currentPath);
1035
+ htmlParts.push(renderSection(subFolder, folderData.folders[subFolder], level + 1, currentPath));
1043
1036
  });
1044
-
1045
- return html;
1037
+
1038
+ return htmlParts.join('');
1046
1039
  };
1047
1040
 
1048
1041
  // Check if this is a flat structure
@@ -1052,58 +1045,57 @@ function buildNavigationStructure(files, currentFile, config = {}) {
1052
1045
  // Generate simple flat navigation for all files in root
1053
1046
  return renderSection('root', { files: tree.files, folders: {} }, 0);
1054
1047
  } else {
1055
- // Generate hierarchical navigation
1056
- let nav = '';
1057
-
1048
+ // OPTIMIZATION: Use array for hierarchical navigation building
1049
+ const navParts = [];
1050
+
1058
1051
  // 1. First render the root Documentation section with Overview
1059
1052
  const readmeFile = tree.files.find(f => f.displayName === 'README');
1060
1053
  if (readmeFile || tree.files.length > 0) {
1061
1054
  const rootFiles = readmeFile ? [readmeFile] : [];
1062
- nav += renderSection('root', { files: rootFiles, folders: {} }, 0);
1055
+ navParts.push(renderSection('root', { files: rootFiles, folders: {} }, 0));
1063
1056
  }
1064
-
1057
+
1065
1058
  // 2. Then render all folders alphabetically
1066
1059
  Object.keys(tree.folders)
1067
1060
  .sort()
1068
1061
  .forEach(folderName => {
1069
- nav += renderSection(folderName, tree.folders[folderName], 1);
1062
+ navParts.push(renderSection(folderName, tree.folders[folderName], 1));
1070
1063
  });
1071
-
1064
+
1072
1065
  // 3. Finally, add remaining root files to the Documentation section
1073
1066
  const otherRootFiles = tree.files.filter(f => f.displayName !== 'README');
1074
1067
  if (otherRootFiles.length > 0) {
1068
+ const nav = navParts.join('');
1075
1069
  // Find the closing </div></div> of the first nav-section (Documentation)
1076
1070
  const navSections = nav.split('<div class="nav-section"');
1077
-
1071
+
1078
1072
  if (navSections.length > 1) {
1079
- // Find the end of the first section's content div
1080
1073
  const firstSection = navSections[1];
1081
1074
  const contentDivEnd = firstSection.indexOf('</div></div>');
1082
-
1075
+
1083
1076
  if (contentDivEnd !== -1) {
1084
- let additionalFiles = '';
1077
+ const additionalFilesParts = [];
1085
1078
  otherRootFiles.forEach(file => {
1086
1079
  const title = smartCapitalize(file.displayName);
1087
1080
  let isActive = '';
1088
1081
  if (currentFile === file.urlPath) {
1089
1082
  isActive = ' active';
1090
1083
  }
1091
- // Use relative paths for static output
1092
1084
  const linkPath = config.isStaticOutput ? file.urlPath : '/' + file.urlPath;
1093
1085
  const tooltip = file.summary ? ` data-tooltip="${escapeHtml(file.summary)}"` : '';
1094
1086
  const icon = getIconForStatus(file.status || 'default', false, config);
1095
- additionalFiles += `
1096
- <a href="${linkPath}" class="nav-item${isActive}"${tooltip}>${icon} ${title}</a>`;
1087
+ additionalFilesParts.push(`
1088
+ <a href="${linkPath}" class="nav-item${isActive}"${tooltip}>${icon} ${title}</a>`);
1097
1089
  });
1098
-
1090
+
1099
1091
  // Reconstruct with additional files inserted
1100
- navSections[1] = firstSection.slice(0, contentDivEnd) + additionalFiles + firstSection.slice(contentDivEnd);
1101
- nav = navSections.join('<div class="nav-section"');
1092
+ navSections[1] = firstSection.slice(0, contentDivEnd) + additionalFilesParts.join('') + firstSection.slice(contentDivEnd);
1093
+ return navSections.join('<div class="nav-section"');
1102
1094
  }
1103
1095
  }
1104
1096
  }
1105
-
1106
- return nav;
1097
+
1098
+ return navParts.join('');
1107
1099
  }
1108
1100
  }
1109
1101
 
@@ -1119,20 +1111,32 @@ async function processMarkdownFile(filePath, outputPath, allFiles, config, useSt
1119
1111
  .split('/')
1120
1112
  .map(segment => encodeURIComponent(segment))
1121
1113
  .join('/');
1122
-
1114
+
1123
1115
  // Parse front matter
1124
1116
  const { data: frontMatter, content } = matter(rawContent);
1125
-
1117
+
1126
1118
  // Extract title - priority: front matter > H1 > filename
1127
1119
  const h1Match = content.match(/^#\s+(.+)$/m);
1128
1120
  const h1Title = h1Match ? h1Match[1] : null;
1129
-
1121
+
1130
1122
  // Normalize title if needed (e.g., convert all-caps to title case)
1131
1123
  const rawTitle = frontMatter.title || h1Title || fileName;
1132
1124
  const title = config.features?.normalizeTitle !== false ? normalizeTitle(rawTitle) : rawTitle;
1133
-
1125
+
1134
1126
  // Extract summary for tooltip - priority: front matter > auto-extract
1135
1127
  const summary = frontMatter.description || extractSummary(content);
1128
+
1129
+ // OPTIMIZATION: Update the file entry in allFiles with computed summary and status
1130
+ // This allows navigation tooltips to work even though we defer content loading
1131
+ const fileEntry = allFiles.find(f => f.path === filePath);
1132
+ if (fileEntry) {
1133
+ if (!fileEntry.summary) {
1134
+ fileEntry.summary = summary;
1135
+ }
1136
+ if (!fileEntry.status) {
1137
+ fileEntry.status = detectDocumentStatus(content, frontMatter);
1138
+ }
1139
+ }
1136
1140
 
1137
1141
  // Process content
1138
1142
  const htmlContent = processMarkdownContent(content, config);
@@ -1154,32 +1158,35 @@ async function processMarkdownFile(filePath, outputPath, allFiles, config, useSt
1154
1158
  return { title, urlPath, summary, frontMatter };
1155
1159
  }
1156
1160
 
1157
- // Get all markdown files
1158
- async function getAllMarkdownFiles(dir, baseDir = dir, options = {}) {
1159
- const files = [];
1161
+ // OPTIMIZATION: Unified directory scanner - scans once for both markdown and attachments
1162
+ async function scanAllFiles(dir, baseDir = dir, options = {}) {
1163
+ const markdownFiles = [];
1164
+ const attachmentFiles = [];
1165
+ const attachmentTypes = options.attachmentTypes || [];
1160
1166
  const items = await fs.readdir(dir);
1161
-
1167
+
1162
1168
  for (const item of items) {
1163
1169
  // Skip files with non-printable characters in their names
1164
1170
  if (hasNonPrintableChars(item)) {
1165
1171
  console.log(chalk.yellow(`⚠️ Skipping file with non-printable characters: ${sanitizeFilename(item)}`));
1166
1172
  continue;
1167
1173
  }
1168
-
1174
+
1169
1175
  const fullPath = path.join(dir, item);
1170
1176
  const stat = await fs.stat(fullPath);
1171
-
1177
+
1172
1178
  // Skip private directories if excludePrivate is true
1173
1179
  if (stat.isDirectory() && options.excludePrivate && item === 'private') {
1174
1180
  continue;
1175
1181
  }
1176
-
1182
+
1177
1183
  if (stat.isDirectory() && !item.startsWith('.') && !item.startsWith('_')) {
1178
- const subFiles = await getAllMarkdownFiles(fullPath, baseDir, options);
1179
- files.push(...subFiles);
1184
+ const subResult = await scanAllFiles(fullPath, baseDir, options);
1185
+ markdownFiles.push(...subResult.markdownFiles);
1186
+ attachmentFiles.push(...subResult.attachmentFiles);
1180
1187
  } else if (item.endsWith('.md') && !item.startsWith('_')) {
1188
+ // Process markdown files
1181
1189
  const relativePath = path.relative(baseDir, fullPath);
1182
- // Encode special characters in URL but keep slashes
1183
1190
  const urlPath = relativePath
1184
1191
  .replace(/\.md$/, '.html')
1185
1192
  .replace(/\\/g, '/')
@@ -1187,25 +1194,25 @@ async function getAllMarkdownFiles(dir, baseDir = dir, options = {}) {
1187
1194
  .map(segment => encodeURIComponent(segment))
1188
1195
  .join('/');
1189
1196
  const displayName = smartCapitalize(path.basename(item, '.md'));
1190
-
1191
- // Read file to extract summary and status
1197
+
1198
+ // OPTIMIZATION: Only read frontmatter, not full content
1199
+ // Read file but only parse frontmatter, defer content processing
1192
1200
  const rawContent = await fs.readFile(fullPath, 'utf-8');
1193
- const { data: frontMatter, content } = matter(rawContent);
1194
- const summary = frontMatter.description || extractSummary(content);
1195
- const status = detectDocumentStatus(content, frontMatter);
1196
-
1197
-
1198
- // Check if this file is in the private directory
1199
- const isPrivate = relativePath.split(path.sep)[0] === 'private' ||
1200
- relativePath.startsWith('private/') ||
1201
+ const { data: frontMatter } = matter(rawContent, { excerpt: false });
1202
+
1203
+ // Use frontmatter description if available, otherwise defer summary extraction
1204
+ const summary = frontMatter.description || '';
1205
+ const status = frontMatter.status || null;
1206
+
1207
+ const isPrivate = relativePath.split(path.sep)[0] === 'private' ||
1208
+ relativePath.startsWith('private/') ||
1201
1209
  relativePath.startsWith('private\\');
1202
-
1203
- // Skip private files if excludePrivate is true
1210
+
1204
1211
  if (options.excludePrivate && isPrivate) {
1205
1212
  continue;
1206
1213
  }
1207
-
1208
- files.push({
1214
+
1215
+ markdownFiles.push({
1209
1216
  path: fullPath,
1210
1217
  relativePath,
1211
1218
  urlPath,
@@ -1214,39 +1221,21 @@ async function getAllMarkdownFiles(dir, baseDir = dir, options = {}) {
1214
1221
  isPrivate,
1215
1222
  status,
1216
1223
  frontMatter,
1217
- content
1218
1224
  });
1219
- }
1220
- }
1221
-
1222
- return files;
1223
- }
1224
-
1225
- // Get all attachment files
1226
- async function getAllAttachmentFiles(dir, baseDir = dir, attachmentTypes) {
1227
- const files = [];
1228
- const items = await fs.readdir(dir);
1229
-
1230
- for (const item of items) {
1231
- // Skip files with non-printable characters in their names
1232
- if (hasNonPrintableChars(item)) {
1233
- console.log(chalk.yellow(`⚠️ Skipping attachment with non-printable characters: ${sanitizeFilename(item)}`));
1234
- continue;
1235
- }
1236
-
1237
- const fullPath = path.join(dir, item);
1238
- const stat = await fs.stat(fullPath);
1239
-
1240
- if (stat.isDirectory() && !item.startsWith('.')) {
1241
- // Recursively scan subdirectories
1242
- const subFiles = await getAllAttachmentFiles(fullPath, baseDir, attachmentTypes);
1243
- files.push(...subFiles);
1244
- } else {
1245
- // Check if file is an attachment type
1225
+ } else if (attachmentTypes.length > 0) {
1226
+ // Process attachment files
1246
1227
  const ext = path.extname(item).toLowerCase();
1247
1228
  if (attachmentTypes.includes(ext) && !item.startsWith('.')) {
1248
1229
  const relativePath = path.relative(baseDir, fullPath);
1249
- files.push({
1230
+ const isPrivate = relativePath.split(path.sep)[0] === 'private' ||
1231
+ relativePath.startsWith('private/') ||
1232
+ relativePath.startsWith('private\\');
1233
+
1234
+ if (options.excludePrivate && isPrivate) {
1235
+ continue;
1236
+ }
1237
+
1238
+ attachmentFiles.push({
1250
1239
  path: fullPath,
1251
1240
  relativePath,
1252
1241
  size: stat.size
@@ -1254,8 +1243,20 @@ async function getAllAttachmentFiles(dir, baseDir = dir, attachmentTypes) {
1254
1243
  }
1255
1244
  }
1256
1245
  }
1257
-
1258
- return files;
1246
+
1247
+ return { markdownFiles, attachmentFiles };
1248
+ }
1249
+
1250
+ // Get all markdown files (backward compatibility wrapper)
1251
+ async function getAllMarkdownFiles(dir, baseDir = dir, options = {}) {
1252
+ const result = await scanAllFiles(dir, baseDir, options);
1253
+ return result.markdownFiles;
1254
+ }
1255
+
1256
+ // Get all attachment files (backward compatibility wrapper)
1257
+ async function getAllAttachmentFiles(dir, baseDir = dir, attachmentTypes) {
1258
+ const result = await scanAllFiles(dir, baseDir, { attachmentTypes });
1259
+ return result.attachmentFiles;
1259
1260
  }
1260
1261
 
1261
1262
  // Copy attachment files to output directory
@@ -1300,10 +1301,26 @@ async function buildDocumentation(config) {
1300
1301
  console.log(chalk.blue('📋 Checking documentation structure...'));
1301
1302
  const readmeGenerated = await createPlaceholderReadme(docsDir, config);
1302
1303
 
1303
- console.log(chalk.blue('📄 Scanning for markdown files...'));
1304
- const files = await getAllMarkdownFiles(docsDir);
1304
+ // OPTIMIZATION: Single unified scan for both markdown and attachments
1305
+ console.log(chalk.blue('📄 Scanning documentation directory...'));
1306
+ const attachmentTypes = config.attachmentTypes || [
1307
+ '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.csv', '.ppt', '.pptx', '.txt', '.rtf',
1308
+ '.html', '.htm',
1309
+ '.zip', '.tar', '.gz', '.7z', '.rar',
1310
+ '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico', '.bmp',
1311
+ '.json', '.xml', '.yaml', '.yml', '.toml',
1312
+ '.mp4', '.mp3', '.wav', '.avi', '.mov'
1313
+ ];
1314
+
1315
+ const { markdownFiles: files, attachmentFiles } = await scanAllFiles(docsDir, docsDir, {
1316
+ attachmentTypes: config.features?.attachments !== false ? attachmentTypes : []
1317
+ });
1318
+
1305
1319
  console.log(chalk.green(`✅ Found ${files.length} markdown files${readmeGenerated ? ' (including auto-generated README)' : ''}`));
1306
-
1320
+ if (config.features?.attachments !== false && attachmentFiles.length > 0) {
1321
+ console.log(chalk.gray(` Found ${attachmentFiles.length} attachments`));
1322
+ }
1323
+
1307
1324
  // Log the files found
1308
1325
  if (files.length > 0) {
1309
1326
  console.log(chalk.gray(' Found files:'));
@@ -1482,52 +1499,39 @@ async function buildDocumentation(config) {
1482
1499
  }
1483
1500
  }
1484
1501
 
1485
- // Copy attachment files if feature is enabled
1486
- if (config.features?.attachments !== false) {
1502
+ // OPTIMIZATION: Use cached attachment files from initial scan
1503
+ if (config.features?.attachments !== false && attachmentFiles.length > 0) {
1487
1504
  console.log(chalk.blue('\n📎 Processing attachments...'));
1488
-
1489
- const attachmentTypes = config.attachmentTypes || [
1490
- '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.csv', '.ppt', '.pptx', '.txt', '.rtf',
1491
- '.html', '.htm',
1492
- '.zip', '.tar', '.gz', '.7z', '.rar',
1493
- '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico', '.bmp',
1494
- '.json', '.xml', '.yaml', '.yml', '.toml',
1495
- '.mp4', '.mp3', '.wav', '.avi', '.mov'
1496
- ];
1497
-
1505
+
1498
1506
  try {
1499
- const attachmentFiles = await getAllAttachmentFiles(docsDir, docsDir, attachmentTypes);
1500
-
1501
- if (attachmentFiles.length > 0) {
1502
- const { copiedCount, totalSize } = await copyAttachmentFiles(attachmentFiles, docsDir, outputDir);
1503
-
1504
- // Format file size
1505
- const formatSize = (bytes) => {
1506
- if (bytes < 1024) return bytes + ' B';
1507
- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
1508
- return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
1509
- };
1510
-
1511
- console.log(chalk.green(`✅ Copied ${copiedCount} attachments (${formatSize(totalSize)} total)`));
1512
- } else {
1513
- console.log(chalk.gray(' No attachments found to copy'));
1514
- }
1507
+ const { copiedCount, totalSize } = await copyAttachmentFiles(attachmentFiles, docsDir, outputDir);
1508
+
1509
+ // Format file size
1510
+ const formatSize = (bytes) => {
1511
+ if (bytes < 1024) return bytes + ' B';
1512
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
1513
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
1514
+ };
1515
+
1516
+ console.log(chalk.green(`✅ Copied ${copiedCount} attachments (${formatSize(totalSize)} total)`));
1515
1517
  } catch (error) {
1516
1518
  console.warn(chalk.yellow(`Warning: Error processing attachments: ${error.message}`));
1517
1519
  }
1520
+ } else if (config.features?.attachments !== false) {
1521
+ console.log(chalk.gray(' No attachments found to copy'));
1518
1522
  }
1519
1523
 
1520
1524
  // Generate static version if enabled
1521
1525
  if (config.features?.staticOutput !== false) {
1522
1526
  console.log(chalk.blue('\n🌐 Generating static version (no auth, no private content)...'));
1523
-
1527
+
1524
1528
  const staticOutputDir = path.join(process.cwd(), config.staticOutputDir || 'html-static');
1525
-
1529
+
1526
1530
  // Ensure static output directory exists
1527
1531
  await fs.ensureDir(staticOutputDir);
1528
-
1529
- // Get files excluding private directories
1530
- const staticFiles = await getAllMarkdownFiles(docsDir, docsDir, { excludePrivate: true });
1532
+
1533
+ // OPTIMIZATION: Filter already-scanned files instead of re-scanning
1534
+ const staticFiles = files.filter(f => !f.isPrivate);
1531
1535
  console.log(chalk.gray(` Found ${staticFiles.length} public files (private files excluded)`));
1532
1536
 
1533
1537
  // Process files for static output
@@ -1568,27 +1572,13 @@ async function buildDocumentation(config) {
1568
1572
  await fs.writeFile(staticIndexPath, defaultIndex);
1569
1573
  }
1570
1574
 
1571
- // Copy attachments to static directory if enabled
1572
- if (config.features?.attachments !== false) {
1573
- const attachmentTypes = config.attachmentTypes || [
1574
- '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.csv', '.ppt', '.pptx', '.txt', '.rtf',
1575
- '.html', '.htm',
1576
- '.zip', '.tar', '.gz', '.7z', '.rar',
1577
- '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico', '.bmp',
1578
- '.json', '.xml', '.yaml', '.yml', '.toml',
1579
- '.mp4', '.mp3', '.wav', '.avi', '.mov'
1580
- ];
1581
-
1575
+ // OPTIMIZATION: Filter already-scanned attachments instead of re-scanning
1576
+ if (config.features?.attachments !== false && attachmentFiles.length > 0) {
1582
1577
  try {
1583
- // Get attachment files excluding private directories
1584
- const allAttachmentFiles = await getAllAttachmentFiles(docsDir, docsDir, attachmentTypes);
1585
- const staticAttachmentFiles = [];
1586
- for (const file of allAttachmentFiles) {
1587
- if (!file.relativePath.startsWith('private/') && !file.relativePath.startsWith('private\\')) {
1588
- staticAttachmentFiles.push(file);
1589
- }
1590
- }
1591
-
1578
+ const staticAttachmentFiles = attachmentFiles.filter(file =>
1579
+ !file.relativePath.startsWith('private/') && !file.relativePath.startsWith('private\\')
1580
+ );
1581
+
1592
1582
  if (staticAttachmentFiles.length > 0) {
1593
1583
  await copyAttachmentFiles(staticAttachmentFiles, docsDir, staticOutputDir);
1594
1584
  }
@@ -280,6 +280,28 @@ const emojiToPhosphor = {
280
280
  '🐌': '<i class="ph ph-spiral" aria-label="snail"></i>',
281
281
  };
282
282
 
283
+ // OPTIMIZATION: Cache the compiled regex pattern
284
+ let cachedEmojiPattern = null;
285
+
286
+ /**
287
+ * Get or build the cached emoji pattern
288
+ * @returns {RegExp} - Compiled emoji matching regex
289
+ */
290
+ function getEmojiPattern() {
291
+ if (!cachedEmojiPattern) {
292
+ // Build regex pattern once with all emoji patterns
293
+ // Sort by length to match longer emojis first (e.g., ⚠️ before ⚠)
294
+ cachedEmojiPattern = new RegExp(
295
+ Object.keys(emojiToPhosphor)
296
+ .sort((a, b) => b.length - a.length)
297
+ .map(emoji => emoji.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
298
+ .join('|'),
299
+ 'g'
300
+ );
301
+ }
302
+ return cachedEmojiPattern;
303
+ }
304
+
283
305
  /**
284
306
  * Replace emojis with Phosphor icons in HTML
285
307
  * @param {string} html - The HTML content to process
@@ -289,27 +311,20 @@ const emojiToPhosphor = {
289
311
  function replaceEmojisWithIcons(html, config = {}) {
290
312
  // Check if feature is enabled - default to true if not explicitly set to false
291
313
  if (config.features?.phosphorIcons === false) return html;
292
-
314
+
293
315
  // First, protect code blocks and inline code from emoji replacement
294
316
  const codeBlocks = [];
295
317
  const codePattern = /(<code[^>]*>[\s\S]*?<\/code>|<pre[^>]*>[\s\S]*?<\/pre>)/g;
296
-
318
+
297
319
  // Replace code blocks with placeholders
298
320
  html = html.replace(codePattern, (match, p1) => {
299
321
  const placeholder = `CODE_BLOCK_${codeBlocks.length}`;
300
322
  codeBlocks.push(match);
301
323
  return placeholder;
302
324
  });
303
-
304
- // Build regex pattern once with all emoji patterns
305
- // Sort by length to match longer emojis first (e.g., ⚠️ before ⚠)
306
- const emojiPattern = new RegExp(
307
- Object.keys(emojiToPhosphor)
308
- .sort((a, b) => b.length - a.length)
309
- .map(emoji => emoji.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
310
- .join('|'),
311
- 'g'
312
- );
325
+
326
+ // OPTIMIZATION: Use cached emoji pattern
327
+ const emojiPattern = getEmojiPattern();
313
328
 
314
329
  // Get weight class if custom weight is specified
315
330
  const weightClass = config.features?.phosphorWeight && config.features.phosphorWeight !== 'regular'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knowcode/doc-builder",
3
- "version": "1.9.31",
3
+ "version": "1.10.0",
4
4
  "description": "Reusable documentation builder for markdown-based sites with Vercel deployment support",
5
5
  "main": "index.js",
6
6
  "bin": {