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 +156 -0
- package/cli.js +456 -0
- package/ghmd-dark.css +208 -0
- package/ghmd-light.css +153 -0
- package/index.template.html +439 -0
- package/package.json +16 -0
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
|
+
[](https://marketplace.visualstudio.com/items?itemName=Explicode.explicode)
|
|
10
|
+
[](https://marketplace.visualstudio.com/items?itemName=Explicode.explicode)
|
|
11
|
+
[](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
|
+
}
|