scanex
Version:
A powerful CLI tool that automatically discovers and bundles related source code into a single markdown file, perfect for sharing with LLMs like ChatGPT, Claude, or Cursor AI.
242 lines (208 loc) • 7.57 kB
JavaScript
// lib/core.js
import { readFileSync, statSync, readdirSync, existsSync } from 'node:fs';
import { join, resolve, extname, dirname, relative } from 'node:path';
import chalk from 'chalk';
/* small coloured logger -------------------------------------------------- */
export const log = (...args) => console.error(chalk.gray('[scanex]'), ...args);
/* async plug-in loader ---------------------------------------------------- */
export async function loadPlugins(dir) {
const scanners = new Map(), resolvers = [], exts = [];
for (const f of readdirSync(dir)) {
if (!f.endsWith('.js')) continue;
const plug = await import(join(dir, f));
plug.exts.forEach(e => scanners.set(e, plug));
if (plug.resolve) resolvers.push(plug);
exts.push(...plug.exts);
log('plugin', plug.name, 'ready');
}
return { scanners, resolvers, ALL_EXT: exts };
}
/* recursive walk, honouring IGNORE --------------------------------------- */
export function walk(start, IGNORE, projectRoot) {
const files = [];
function parseGitignoreInDir(dirPath) {
const gitignorePath = join(dirPath, '.gitignore');
if (!existsSync(gitignorePath)) return [];
try {
const content = readFileSync(gitignorePath, 'utf8');
const patterns = content.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('#') && !line.startsWith('!'));
if (patterns.length > 0) {
const relativeDir = relative(projectRoot, dirPath).replace(/\\/g, '/');
log(`Loading ${patterns.length} patterns from ${relativeDir}/.gitignore`);
}
const relativeDir = relative(projectRoot, dirPath).replace(/\\/g, '/');
return patterns.map(pattern => {
let originalPattern = pattern;
let isDir = pattern.endsWith('/');
if (isDir) pattern = pattern.slice(0, -1);
let rooted = pattern.startsWith('/');
if (rooted) pattern = pattern.slice(1);
// Handle wildcards BEFORE escaping other special characters
let re = pattern;
re = re.replace(/\*\*/g, '___DOUBLESTAR___'); // Temporarily replace **
re = re.replace(/\*/g, '___STAR___'); // Temporarily replace *
// Now escape regex special characters
re = re.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
// Restore wildcards with proper regex
re = re.replace(/___DOUBLESTAR___/g, '.*'); // ** matches anything including /
re = re.replace(/___STAR___/g, '[^/]*'); // * matches anything except /
let regexString;
if (rooted) {
// Pattern is relative to the .gitignore's directory
regexString = relativeDir ? `${relativeDir}/${re}` : re;
} else {
// Pattern can match anywhere under the .gitignore's directory
if (relativeDir) {
regexString = `${relativeDir}/(.*/)?(${re})`;
} else {
regexString = `(.*/)?(${re})`;
}
}
if (isDir) {
return {
pattern: new RegExp(`^${regexString}(/.*)?$`),
original: originalPattern,
dir: relativeDir
};
}
return {
pattern: new RegExp(`^${regexString}$`),
original: originalPattern,
dir: relativeDir
};
});
} catch (e) {
console.warn(`Warning: Could not read ${gitignorePath}: ${e.message}`);
return [];
}
}
function isIgnored(path, gitignorePatterns) {
const relativePath = relative(projectRoot, path).replace(/\\/g, '/');
// Check global IGNORE pattern
if (IGNORE.test(relativePath)) return true;
// Check local gitignore patterns
for (const patternObj of gitignorePatterns) {
if (patternObj.pattern.test(relativePath)) {
return true;
}
}
return false;
}
(function dive(p, parentGitignorePatterns = []) {
let st;
try {
st = statSync(p);
} catch (e) {
if (e.code === 'ENOENT') {
console.error(`❌ File or directory not found: ${p}`);
console.error(` Please check the path and try again.`);
return;
} else {
console.error(`❌ Error accessing ${p}: ${e.message}`);
return;
}
}
if (st.isFile()) {
files.push(p);
return;
}
if (st.isDirectory()) {
// Parse .gitignore in current directory
const localPatterns = parseGitignoreInDir(p);
const allPatterns = [...parentGitignorePatterns, ...localPatterns];
try {
for (const c of readdirSync(p)) {
const n = join(p, c);
if (!isIgnored(n, allPatterns)) {
dive(n, allPatterns);
}
}
} catch (e) {
console.warn(`⚠️ Warning: Could not read directory ${p}: ${e.message}`);
}
}
})(start);
return files;
}
/* turn ["a/b.js","a/c/d.rb"] into a proper ASCII tree ------------------ */
export function makeTree(relPaths) {
const root = { name: '.', children: new Map() };
for (const path of relPaths) {
const parts = path.split('/');
let current = root;
for (const part of parts) {
if (!current.children.has(part)) {
current.children.set(part, { name: part, children: new Map() });
}
current = current.children.get(part);
}
}
function generateTree(node, prefix = '') {
const entries = Array.from(node.children.entries());
let treeString = '';
entries.forEach(([name, child], index) => {
const isLast = index === entries.length - 1;
const connector = isLast ? '└──' : '├──';
treeString += `${prefix}${connector} ${name}\n`;
if (child.children.size > 0) {
const newPrefix = prefix + (isLast ? ' ' : '│ ');
treeString += generateTree(child, newPrefix);
}
});
return treeString;
}
return `.\n${generateTree(root).trim()}`;
}
/* final Markdown bundler -------------------------------------------------- */
export function bundle(files, root, treeStr) {
const langMap = {
'.rb': 'ruby',
'.html.erb': 'erb',
'.html': 'html',
'.htm': 'html',
'.yml': 'yaml',
'.yaml': 'yaml',
'.txt': 'text',
'.sh': 'shell',
'.bash': 'bash',
'.zsh': 'shell',
'.fish': 'shell',
'.ksh': 'shell',
'.csh': 'shell',
'Dockerfile': 'dockerfile',
'.dockerfile': 'dockerfile',
'.ts': 'typescript',
'.tsx': 'tsx',
'.jsx': 'jsx',
'.js': 'javascript',
'.mjs': 'javascript',
'.cjs': 'javascript',
'.json': 'json',
'.md': 'markdown',
'.py': 'python',
};
let out = '<directory_tree>\n' + treeStr + '\n</directory_tree>\n\n';
out += '<codebase>\n\n';
for (const f of files) {
const rel = f.slice(root.length + 1);
let fileExt = extname(f);
// Handle composite extensions like .html.erb
if (fileExt === '.erb' && f.endsWith('.html.erb')) {
fileExt = '.html.erb';
} else if (fileExt === '.yaml' && f.endsWith('.yml')) {
fileExt = '.yml';
}
// Handle Dockerfiles (which often don't have traditional extensions)
const fileName = f.split('/').pop();
if (fileName === 'Dockerfile' || fileName.startsWith('Dockerfile.')) {
fileExt = 'Dockerfile';
}
const lang = langMap[fileExt] || fileExt.slice(1);
out += `#### \`${rel}\`\n\`\`\`${lang}\n`;
out += readFileSync(f, 'utf8').trimEnd() + '\n```\n\n';
}
out += '</codebase>\n';
return out;
}