explicode 1.0.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/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # Explicode
2
+
3
+ > Turn your codebase into documentation.
4
+
5
+ **Explicode** lets you write rich Markdown documentation directly inside your code comments, turning a single source file into both runnable code and beautifully rendered documentation.
6
+
7
+ The [VS Code Extension](https://marketplace.visualstudio.com/items?itemName=Explicode.explicode) lets you preview the render in your IDE. The npm package converts supported source files into `.md` Markdown, or generates a GitHub Pages-ready `docs/` folder styled after GitHub's own Markdown renderer.
8
+
9
+ [![Download](https://img.shields.io/badge/Download-0078D4?style=for-the-badge&logo=visualstudiocode&logoColor=white)](https://marketplace.visualstudio.com/items?itemName=Explicode.explicode)
10
+ [![VS Code Extension](https://img.shields.io/badge/VS_Code_Extension-005a9e?style=for-the-badge)](https://marketplace.visualstudio.com/items?itemName=Explicode.explicode)
11
+ [![View on GitHub](https://img.shields.io/badge/View_on_GitHub-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/benatfroemming/explicode)
12
+
13
+ ---
14
+
15
+ ## Usage
16
+
17
+ No install needed — run from the root of your project:
18
+
19
+ ```bash
20
+ npx explicode build
21
+ npx explicode build --dark
22
+ npx explicode convert <file>
23
+ ```
24
+
25
+ ---
26
+
27
+ ## Commands
28
+
29
+ ### `convert`
30
+
31
+ Converts a single file to Markdown without building the whole site.
32
+
33
+ ```bash
34
+ npx explicode convert src/utils.py
35
+ ```
36
+
37
+ Outputs `src/utils.py.md` alongside the original file.
38
+
39
+ ### `build`
40
+
41
+ Scans the current directory and generates a `docs/` folder.
42
+
43
+ ```bash
44
+ npx explicode build # light theme
45
+ npx explicode build --dark # dark theme
46
+ ```
47
+
48
+ - Recursively finds all supported source files
49
+ - Extracts docstrings and block comments as Markdown prose
50
+ - Wraps code in syntax-highlighted fenced blocks
51
+ - Copies your `README.md` as the docs home page
52
+ - Generates a `_sidebar.md` with your full file tree
53
+ - Writes an `index.html` ready for Docsify + GitHub Pages
54
+ - Adds "View on GitHub" source links if a GitHub remote is detected
55
+
56
+ **Skipped directories:** `node_modules`, `.git`, `dist`, `build`, `out`, `docs`, `.next`, `.nuxt`, `.cache`, `.venv`, `venv`, `__pycache__`
57
+
58
+ ---
59
+
60
+ ## Supported Languages
61
+
62
+ Python, JavaScript, TypeScript, JSX, TSX, Java, C, C++, C#, CUDA, Rust, Go, Swift, Kotlin, Scala, Dart, PHP, Objective-C, SQL, Markdown
63
+
64
+ ---
65
+
66
+ ## How It Works
67
+
68
+ - **Python** — Triple-quoted docstrings (`"""` / `'''`) in docstring position become prose. Everything else becomes a code block.
69
+ - **C-style languages** — Block comments (`/* ... */`) become prose. Everything else becomes a code block.
70
+ - **Markdown** — Passed through as-is.
71
+
72
+ Consecutive segments of the same type are merged, producing clean alternating prose/code sections rather than fragmented blocks.
73
+
74
+ ---
75
+
76
+ ## GitHub Pages
77
+
78
+ After running `build`, push the `docs/` folder and enable GitHub Pages from the `docs/` directory in your repo settings. Your site will be live at:
79
+
80
+ ```
81
+ https://<user>.github.io/<repo>
82
+ ```
83
+
84
+ ### Automatic Deployment with GitHub Actions
85
+
86
+ Add the following file to your repository to automatically build and deploy docs on every push:
87
+
88
+ `.github/workflows/<any_name>.yml`
89
+
90
+ ```yml
91
+ name: Deploy Docs
92
+
93
+ on:
94
+ push:
95
+ branches: [main] # change to your target branch
96
+ workflow_dispatch: # allows manual trigger from GitHub UI
97
+
98
+ jobs:
99
+ deploy:
100
+ runs-on: ubuntu-latest
101
+ permissions:
102
+ contents: write # needed to push to gh-pages branch
103
+
104
+ steps:
105
+ - name: Checkout repo
106
+ uses: actions/checkout@v4
107
+
108
+ - name: Setup Node
109
+ uses: actions/setup-node@v4
110
+ with:
111
+ node-version: 20
112
+
113
+ - name: Build docs
114
+ run: node explicode build # add --dark to use dark theme
115
+
116
+ - name: Deploy to GitHub Pages
117
+ uses: peaceiris/actions-gh-pages@v4
118
+ with:
119
+ github_token: ${{ secrets.GITHUB_TOKEN }}
120
+ publish_dir: ./docs
121
+ ```
122
+
123
+ This publishes to a `gh-pages` branch. Enable GitHub Pages from the `/(root)` of that branch in your repo settings.
124
+
125
+ ---
126
+
127
+ ## Themes
128
+
129
+ | Flag | Style |
130
+ |------|-------|
131
+ | _(none)_ | GitHub Light |
132
+ | `--dark` | GitHub Dark / One Dark Pro |
133
+
134
+ The theme is baked into `docs/ghmd.css` at build time. Re-run with or without `--dark` to switch. You can further customize `index.html` or `ghmd.css` as needed.
135
+
136
+ ---
137
+
138
+ ## Output Structure
139
+
140
+ After a build, your `docs/` folder will look like this:
141
+
142
+ ```
143
+ docs/
144
+ index.html # Docsify entry point
145
+ README.md # your project readme (home page)
146
+ _sidebar.md # auto-generated file tree navigation
147
+ ghmd.css # theme stylesheet
148
+ .nojekyll # disables Jekyll on GitHub Pages
149
+ <your files>.md # rendered source files
150
+ ```
151
+
152
+ ---
153
+
154
+ ## License
155
+
156
+ MIT
package/cli.js ADDED
@@ -0,0 +1,456 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Explicode CLI
6
+ * Scans the current directory, renders supported source files to Markdown,
7
+ * and outputs a Docsify-ready docs/ folder for GitHub Pages.
8
+ *
9
+ * Usage:
10
+ * node cli.js build [--dark]
11
+ * node cli.js convert <file>
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+
17
+ // Language detection
18
+ const EXT_TO_LANG = {
19
+ md: 'markdown', mdx: 'markdown',
20
+ py: 'python',
21
+ js: 'javascript', ts: 'typescript',
22
+ jsx: 'javascriptreact', tsx: 'typescriptreact',
23
+ java: 'java',
24
+ cpp: 'cpp', cc: 'cpp', cxx: 'cpp',
25
+ c: 'c', h: 'c',
26
+ cs: 'csharp',
27
+ cu: 'cuda', cuh: 'cuda',
28
+ rs: 'rust',
29
+ go: 'go',
30
+ swift: 'swift',
31
+ kt: 'kotlin', kts: 'kotlin',
32
+ dart: 'dart',
33
+ php: 'php',
34
+ scala: 'scala', sbt: 'scala',
35
+ sql: 'sql',
36
+ };
37
+
38
+ const PRISM_LANG = {
39
+ javascriptreact: 'jsx',
40
+ typescriptreact: 'tsx',
41
+ cuda: 'c',
42
+ csharp: 'csharp',
43
+ 'objective-c': 'objectivec',
44
+ };
45
+
46
+ const C_STYLE_LANGUAGES = new Set([
47
+ 'c', 'cpp', 'csharp', 'cuda', 'java', 'javascript', 'typescript',
48
+ 'javascriptreact', 'typescriptreact', 'go', 'rust', 'php',
49
+ 'swift', 'kotlin', 'scala', 'dart', 'objective-c', 'sql',
50
+ ]);
51
+
52
+ const SUPPORTED_LANGUAGES = new Set([
53
+ ...C_STYLE_LANGUAGES,
54
+ 'python',
55
+ 'markdown',
56
+ ]);
57
+
58
+ // Directories to always skip when scanning
59
+ const SKIP_DIRS = new Set([
60
+ 'node_modules', '.git', '.svn', '.hg',
61
+ 'dist', 'build', 'out', 'docs',
62
+ '.next', '.nuxt', '.cache', '.venv', 'venv', '__pycache__',
63
+ ]);
64
+
65
+ function getLanguage(filePath) {
66
+ const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
67
+ return EXT_TO_LANG[ext] ?? 'plaintext';
68
+ }
69
+
70
+ // Parsers
71
+ function mergeSegments(raw) {
72
+ return raw.reduce((acc, seg) => {
73
+ if (!seg.content.trim()) return acc;
74
+ const last = acc[acc.length - 1];
75
+ if (last && last.type === seg.type) {
76
+ last.content += '\n\n' + seg.content;
77
+ } else {
78
+ acc.push({ ...seg });
79
+ }
80
+ return acc;
81
+ }, []);
82
+ }
83
+
84
+ function parsePython(src) {
85
+ const raw = [];
86
+ let i = 0;
87
+ const n = src.length;
88
+
89
+ function isDocContext(pos) {
90
+ let j = pos - 1;
91
+ while (j >= 0 && src[j] !== '\n') {
92
+ if (src[j] !== ' ' && src[j] !== '\t') return false;
93
+ j--;
94
+ }
95
+ return true;
96
+ }
97
+
98
+ let codeStart = 0;
99
+
100
+ function flushCode(end) {
101
+ const chunk = src.slice(codeStart, end).trim();
102
+ if (chunk) raw.push({ type: 'code', content: chunk });
103
+ }
104
+
105
+ while (i < n) {
106
+ const ch = src[i];
107
+
108
+ if ((ch === '"' || ch === "'") &&
109
+ src.slice(i, i + 3) !== '"""' && src.slice(i, i + 3) !== "'''") {
110
+ i++;
111
+ const q = ch;
112
+ while (i < n && src[i] !== q && src[i] !== '\n') {
113
+ if (src[i] === '\\') i++;
114
+ i++;
115
+ }
116
+ i++;
117
+ continue;
118
+ }
119
+
120
+ if (src.slice(i, i + 3) === '"""' || src.slice(i, i + 3) === "'''") {
121
+ const q3 = src.slice(i, i + 3);
122
+ const isDoc = isDocContext(i);
123
+
124
+ if (isDoc) {
125
+ flushCode(i);
126
+ i += 3;
127
+ const closeIdx = src.indexOf(q3, i);
128
+ const inner = (closeIdx === -1 ? src.slice(i) : src.slice(i, closeIdx)).trim();
129
+ if (inner) raw.push({ type: 'doc', content: inner });
130
+ i = closeIdx === -1 ? n : closeIdx + 3;
131
+ codeStart = i;
132
+ } else {
133
+ i += 3;
134
+ const closeIdx = src.indexOf(q3, i);
135
+ i = closeIdx === -1 ? n : closeIdx + 3;
136
+ }
137
+ continue;
138
+ }
139
+
140
+ if (ch === '#') {
141
+ while (i < n && src[i] !== '\n') i++;
142
+ continue;
143
+ }
144
+
145
+ i++;
146
+ }
147
+
148
+ flushCode(n);
149
+ return mergeSegments(raw);
150
+ }
151
+
152
+ function stripJsDocStars(text) {
153
+ return text
154
+ .split('\n')
155
+ .map(line => line.replace(/^\s*\*\s?/, ''))
156
+ .join('\n')
157
+ .trim();
158
+ }
159
+
160
+ function parseCStyle(src) {
161
+ const raw = [];
162
+ const RE = /\/\*[\s\S]*?\*\//g;
163
+ let cursor = 0;
164
+
165
+ for (const match of src.matchAll(RE)) {
166
+ const start = match.index;
167
+ if (start > cursor) {
168
+ const chunk = src.slice(cursor, start).trim();
169
+ if (chunk) raw.push({ type: 'code', content: chunk });
170
+ }
171
+ const inner = stripJsDocStars(match[0].replace(/^\/\*+/, '').replace(/\*+\/$/, ''));
172
+ if (inner) raw.push({ type: 'doc', content: inner });
173
+ cursor = start + match[0].length;
174
+ }
175
+
176
+ const tail = src.slice(cursor).trim();
177
+ if (tail) raw.push({ type: 'code', content: tail });
178
+
179
+ return mergeSegments(raw);
180
+ }
181
+
182
+ function buildSegments(fileText, language) {
183
+ if (language === 'markdown') return [{ type: 'doc', content: fileText }];
184
+ if (language === 'python') return parsePython(fileText);
185
+ if (C_STYLE_LANGUAGES.has(language)) return parseCStyle(fileText);
186
+ return [];
187
+ }
188
+
189
+ function segmentsToMarkdown(segments, language) {
190
+ const prismLang = PRISM_LANG[language] ?? language;
191
+ return segments
192
+ .map(seg =>
193
+ seg.type === 'doc'
194
+ ? seg.content
195
+ : '```' + prismLang + '\n' + seg.content + '\n```'
196
+ )
197
+ .join('\n\n');
198
+ }
199
+
200
+ // Recursively collect all files under a directory, skipping SKIP_DIRS and hidden entries
201
+ function walkDir(dir, cwd) {
202
+ const results = [];
203
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
204
+ if (entry.name.startsWith('.')) continue;
205
+ if (SKIP_DIRS.has(entry.name)) continue;
206
+ const full = path.join(dir, entry.name);
207
+ if (entry.isDirectory()) {
208
+ results.push(...walkDir(full, cwd));
209
+ } else if (entry.isFile()) {
210
+ results.push(path.relative(cwd, full).split(path.sep).join('/'));
211
+ }
212
+ }
213
+ return results;
214
+ }
215
+
216
+ // Build the sidebar markdown.
217
+ // allFiles - every file discovered (relative, forward-slash paths)
218
+ // renderedSet - Set of relPaths that were successfully rendered
219
+ // outRelPathFn - maps a rendered relPath to its docs output path
220
+ // rootReadme - the relPath of the root README (e.g. "README.md"), gets route "/"
221
+ function buildSidebarMarkdown(allFiles, renderedSet, outRelPathFn, rootReadme) {
222
+ const sorted = [...allFiles].sort((a, b) => a.localeCompare(b));
223
+
224
+ // Build a tree: each node = { children: Map<name, node>, file?: { label, outRelPath?, rendered } }
225
+ const root = { children: new Map() };
226
+
227
+ for (const relPath of sorted) {
228
+ const parts = relPath.split('/');
229
+ let node = root;
230
+ for (let i = 0; i < parts.length - 1; i++) {
231
+ const seg = parts[i];
232
+ if (!node.children.has(seg)) node.children.set(seg, { children: new Map() });
233
+ node = node.children.get(seg);
234
+ }
235
+ const filename = parts[parts.length - 1];
236
+ const isRoot = relPath === rootReadme;
237
+ const isRendered = isRoot || renderedSet.has(relPath);
238
+ // Root README always links to "/" (the docsify home route)
239
+ const outRel = isRoot ? '/' : (isRendered ? outRelPathFn(relPath) : null);
240
+ node.children.set(filename, {
241
+ children: new Map(),
242
+ file: { label: filename, outRelPath: outRel, rendered: isRendered, relPath },
243
+ });
244
+ }
245
+
246
+ function renderNode(node, depth) {
247
+ const indent = ' '.repeat(depth);
248
+ const lines = [];
249
+ const dirs = [...node.children.entries()].filter(([, n]) => !n.file).sort(([a], [b]) => a.localeCompare(b));
250
+ const files = [...node.children.entries()].filter(([, n]) => n.file).sort(([a], [b]) => a.localeCompare(b));
251
+
252
+ for (const [name, child] of dirs) {
253
+ lines.push(`${indent}- **${name}**`);
254
+ lines.push(...renderNode(child, depth + 1));
255
+ }
256
+ for (const [, child] of files) {
257
+ if (child.file.rendered) {
258
+ lines.push(`${indent}- [${child.file.label}](${child.file.outRelPath})`);
259
+ } else {
260
+ // Unrendered: plain span, no link — styled as grayed-out, but shows GitHub icon on hover
261
+ lines.push(`${indent}- <span class="xp-unrendered" data-path="${child.file.relPath}">${child.file.label}</span>`);
262
+ }
263
+ }
264
+ return lines;
265
+ }
266
+
267
+ return renderNode(root, 0).join('\n') + '\n';
268
+ }
269
+
270
+ // Load the index.html template and substitute {{TITLE}}, inject dark mode if needed
271
+ function docsifyIndexHtml(title, theme, githubBase) {
272
+ const templatePath = path.join(__dirname, 'index.template.html');
273
+ let html = fs.readFileSync(templatePath, 'utf8');
274
+
275
+ if (theme === 'dark') {
276
+ html = html.replace('<html lang="en">', '<html lang="en" data-color-mode="dark">');
277
+ }
278
+
279
+ html = html.replaceAll('{{TITLE}}', title);
280
+ html = html.replaceAll('{{GITHUB_BASE}}', githubBase || '');
281
+ return html;
282
+ }
283
+
284
+ // Load the CSS file for the selected theme
285
+ function loadThemeCss(theme) {
286
+ const cssFile = theme === 'dark' ? 'ghmd-dark.css' : 'ghmd-light.css';
287
+ return fs.readFileSync(path.join(__dirname, cssFile), 'utf8');
288
+ }
289
+
290
+ // Detect GitHub remote and return a blob base URL, or empty string if not found
291
+ function getGitHubBase() {
292
+ try {
293
+ const { execSync } = require('child_process');
294
+ const remote = execSync('git remote get-url origin', { stdio: ['pipe','pipe','pipe'] })
295
+ .toString().trim();
296
+ // Normalise SSH and HTTPS to https://github.com/user/repo
297
+ let match = remote.match(/github\.com[:/]([^/]+\/[^\.]+?)(\.git)?$/);
298
+ if (!match) return '';
299
+ const repoPath = match[1];
300
+ // Detect default branch
301
+ let branch = 'main';
302
+ try {
303
+ branch = execSync('git symbolic-ref --short HEAD', { stdio: ['pipe','pipe','pipe'] })
304
+ .toString().trim();
305
+ } catch (_) {}
306
+ return `https://github.com/${repoPath}/blob/${branch}`;
307
+ } catch (_) {
308
+ return '';
309
+ }
310
+ }
311
+
312
+ // Build command
313
+ function build() {
314
+ const args = process.argv.slice(3);
315
+ const theme = args.includes('--dark') ? 'dark' : 'light';
316
+
317
+ const cwd = process.cwd();
318
+ const title = path.basename(cwd);
319
+ const outDir = path.join(cwd, 'docs');
320
+
321
+ fs.mkdirSync(outDir, { recursive: true });
322
+
323
+ // Write theme CSS
324
+ fs.writeFileSync(path.join(outDir, 'ghmd.css'), loadThemeCss(theme));
325
+
326
+ // Handle root README — always docs/README.md
327
+ const readmeSrc = ['README.md', 'readme.md', 'Readme.md']
328
+ .map(f => path.join(cwd, f))
329
+ .find(f => fs.existsSync(f)) ?? null;
330
+
331
+ if (readmeSrc) {
332
+ fs.copyFileSync(readmeSrc, path.join(outDir, 'README.md'));
333
+ console.log(`${path.relative(cwd, readmeSrc)} -> docs/README.md`);
334
+ } else {
335
+ fs.writeFileSync(
336
+ path.join(outDir, 'README.md'),
337
+ '# Docs\n\nGenerated by [Explicode](https://explicode.com).\n'
338
+ );
339
+ console.log('No README.md found, writing placeholder docs/README.md');
340
+ }
341
+
342
+ // Discover every file in the project (respects SKIP_DIRS)
343
+ const allFiles = walkDir(cwd, cwd);
344
+
345
+ // The relative path of the root README (for sidebar routing)
346
+ const rootReadme = readmeSrc ? path.relative(cwd, readmeSrc).split(path.sep).join('/') : null;
347
+
348
+ function outRelPath(relPath) {
349
+ const lang = getLanguage(relPath);
350
+ return lang === 'markdown' ? relPath : relPath + '.md';
351
+ }
352
+
353
+ const renderedSet = new Set();
354
+ const skipped = [];
355
+
356
+ for (const relPath of allFiles) {
357
+ // Root README was already copied above; just mark it rendered so it gets a sidebar link
358
+ if (relPath === rootReadme) {
359
+ renderedSet.add(relPath);
360
+ continue;
361
+ }
362
+
363
+ const lang = getLanguage(relPath);
364
+
365
+ if (!SUPPORTED_LANGUAGES.has(lang)) {
366
+ skipped.push(relPath);
367
+ continue;
368
+ }
369
+
370
+ const srcPath = path.join(cwd, relPath);
371
+ const src = fs.readFileSync(srcPath, 'utf8');
372
+ const segments = buildSegments(src, lang);
373
+ const markdown = segmentsToMarkdown(segments, lang);
374
+
375
+ const out = outRelPath(relPath);
376
+ const outPath = path.join(outDir, out);
377
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
378
+ fs.writeFileSync(outPath, markdown);
379
+
380
+ renderedSet.add(relPath);
381
+ console.log(`${relPath} -> docs/${out}`);
382
+ }
383
+
384
+ // Sidebar: all files visible, unrendered ones grayed out and non-clickable
385
+ fs.writeFileSync(
386
+ path.join(outDir, '_sidebar.md'),
387
+ buildSidebarMarkdown(allFiles, renderedSet, outRelPath, rootReadme)
388
+ );
389
+
390
+ // Write index.html only if it doesn't already exist
391
+ const githubBase = getGitHubBase();
392
+ if (githubBase) console.log(`GitHub source links: ${githubBase}`);
393
+
394
+ const indexPath = path.join(outDir, 'index.html');
395
+ if (!fs.existsSync(indexPath)) {
396
+ fs.writeFileSync(indexPath, docsifyIndexHtml(title, theme, githubBase));
397
+ }
398
+
399
+ fs.writeFileSync(path.join(outDir, '.nojekyll'), '');
400
+
401
+ console.log('\nDone! Output in docs/');
402
+ if (skipped.length) {
403
+ console.log(`Skipped ${skipped.length} unsupported file(s): ${skipped.join(', ')}`);
404
+ }
405
+ }
406
+
407
+ // Convert command
408
+ function convert() {
409
+ const relPath = process.argv[3];
410
+
411
+ if (!relPath) {
412
+ console.error('Usage: explicode convert <file>');
413
+ process.exit(1);
414
+ }
415
+
416
+ const cwd = process.cwd();
417
+ const srcPath = path.join(cwd, relPath);
418
+
419
+ if (!fs.existsSync(srcPath)) {
420
+ console.error(`File not found: ${relPath}`);
421
+ process.exit(1);
422
+ }
423
+
424
+ const lang = getLanguage(relPath);
425
+
426
+ if (lang === 'markdown') {
427
+ console.log(`${relPath} is already markdown, nothing to convert.`);
428
+ process.exit(0);
429
+ }
430
+
431
+ if (!SUPPORTED_LANGUAGES.has(lang)) {
432
+ console.error(`Unsupported language: ${relPath}`);
433
+ process.exit(1);
434
+ }
435
+
436
+ const src = fs.readFileSync(srcPath, 'utf8');
437
+ const segments = buildSegments(src, lang);
438
+ const markdown = segmentsToMarkdown(segments, lang);
439
+
440
+ const outPath = path.join(cwd, relPath + '.md');
441
+ fs.writeFileSync(outPath, markdown);
442
+ console.log(`${relPath} -> ${relPath}.md`);
443
+ }
444
+
445
+ // Entry point
446
+ const cmd = process.argv[2];
447
+ if (cmd === 'build') {
448
+ build();
449
+ } else if (cmd === 'convert') {
450
+ convert();
451
+ } else {
452
+ console.log('Explicode CLI\n');
453
+ console.log('Usage:');
454
+ console.log(' npx explicode build [--dark] Render docs from current directory');
455
+ console.log(' npx explicode convert <file> Convert a single file to markdown\n');
456
+ }