Skip to content

Commit d58f978

Browse files
committed
feat: add assemble command to reassemble exploded markdown documents
## CHANGES - Add new `assemble` command to CLI - Create `assembleDocument` method for reassembly - Add `incrementHeadingLevels` helper method - Add `extractSectionFilesFromTOC` to parse TOC - Rename `explode-doc` command to `explode` - Update help text and examples - Bump version to 1.3.0 - Add comprehensive tests for workflow
1 parent a583684 commit d58f978

File tree

5 files changed

+314
-38
lines changed

5 files changed

+314
-38
lines changed

bin/md-tree.js

Lines changed: 152 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -69,28 +69,30 @@ class MarkdownCLI {
6969
Usage: md-tree <command> <file> [options]
7070
7171
Commands:
72-
list <file> List all headings in the file
73-
extract <file> <heading> Extract a specific section by heading text
74-
extract-all <file> [level] Extract all sections at level (default: 2)
75-
explode-doc <file> <output-dir> Extract all level 2 sections and create index
76-
tree <file> Show the document structure as a tree
77-
search <file> <selector> Search using CSS-like selectors
78-
stats <file> Show document statistics
79-
toc <file> Generate table of contents
80-
version Show version information
81-
help Show this help message
72+
list <file> List all headings in the file
73+
extract <file> <heading> Extract a specific section by heading text
74+
extract-all <file> [level] Extract all sections at level (default: 2)
75+
explode <file> <output-dir> Extract all level 2 sections and create index
76+
assemble <dir> <output-file> Reassemble exploded document from directory
77+
tree <file> Show the document structure as a tree
78+
search <file> <selector> Search using CSS-like selectors
79+
stats <file> Show document statistics
80+
toc <file> Generate table of contents
81+
version Show version information
82+
help Show this help message
8283
8384
Options:
84-
--output, -o <dir> Output directory for extracted files
85-
--level, -l <number> Heading level to work with
86-
--format, -f <json|text> Output format (default: text)
87-
--max-level <number> Maximum heading level for TOC (default: 3)
85+
--output, -o <dir> Output directory for extracted files
86+
--level, -l <number> Heading level to work with
87+
--format, -f <json|text> Output format (default: text)
88+
--max-level <number> Maximum heading level for TOC (default: 3)
8889
8990
Examples:
9091
md-tree list README.md
9192
md-tree extract README.md "Installation"
9293
md-tree extract-all README.md 2 --output ./sections
93-
md-tree explode-doc README.md ./exploded
94+
md-tree explode README.md ./exploded
95+
md-tree assemble ./exploded reassembled.md
9496
md-tree tree README.md
9597
md-tree search README.md "heading[depth=2]"
9698
md-tree stats README.md
@@ -389,17 +391,28 @@ For more information, visit: https://github.com/ksylvan/markdown-tree-parser
389391
break;
390392
}
391393

392-
case 'explode-doc': {
394+
case 'explode': {
393395
if (args.length < 3) {
394396
console.error(
395-
'❌ Usage: md-tree explode-doc <file> <output-directory>'
397+
'❌ Usage: md-tree explode <file> <output-directory>'
396398
);
397399
process.exit(1);
398400
}
399401
await this.explodeDocument(args[1], args[2]);
400402
break;
401403
}
402404

405+
case 'assemble': {
406+
if (args.length < 3) {
407+
console.error(
408+
'❌ Usage: md-tree assemble <directory> <output-file>'
409+
);
410+
process.exit(1);
411+
}
412+
await this.assembleDocument(args[1], args[2]);
413+
break;
414+
}
415+
403416
case 'tree':
404417
if (args.length < 2) {
405418
console.error('❌ Usage: md-tree tree <file>');
@@ -601,8 +614,129 @@ For more information, visit: https://github.com/ksylvan/markdown-tree-parser
601614

602615
return clonedTree;
603616
}
617+
618+
// Helper method to increment all heading levels in a tree by 1
619+
incrementHeadingLevels(tree) {
620+
if (!tree || !tree.children) return tree;
621+
622+
// Create a deep copy to avoid modifying the original tree
623+
const clonedTree = JSON.parse(JSON.stringify(tree));
624+
625+
const incrementNode = (node) => {
626+
if (node.type === 'heading' && node.depth < 6) {
627+
node.depth = node.depth + 1;
628+
}
629+
630+
if (node.children) {
631+
node.children.forEach(incrementNode);
632+
}
633+
};
634+
635+
if (clonedTree.children) {
636+
clonedTree.children.forEach(incrementNode);
637+
}
638+
639+
return clonedTree;
640+
}
641+
642+
async assembleDocument(inputDir, outputFile) {
643+
const indexPath = path.join(inputDir, 'index.md');
644+
645+
// Check if index.md exists
646+
try {
647+
await fs.access(indexPath);
648+
} catch (_error) {
649+
console.error(`❌ index.md not found in ${inputDir}`);
650+
process.exit(1);
651+
}
652+
653+
const indexContent = await this.readFile(indexPath);
654+
const indexTree = await this.parser.parse(indexContent);
655+
656+
// Extract the main title and get the list of section files from TOC
657+
const headings = this.parser.getHeadingsList(indexTree);
658+
const mainTitle = headings.find((h) => h.level === 1);
659+
660+
if (!mainTitle) {
661+
console.error('❌ No main title found in index.md');
662+
process.exit(1);
663+
}
664+
665+
console.log(`\n📚 Assembling document: ${mainTitle.text}`);
666+
667+
// Parse the TOC to extract section file references
668+
const sectionFiles = await this.extractSectionFilesFromTOC(indexTree);
669+
670+
if (sectionFiles.length === 0) {
671+
console.error('❌ No section files found in TOC');
672+
process.exit(1);
673+
}
674+
675+
console.log(`📖 Found ${sectionFiles.length} sections to assemble`);
676+
677+
// Start building the reassembled document
678+
let assembledContent = `# ${mainTitle.text}\n\n`;
679+
680+
// Process each section file
681+
for (const sectionFile of sectionFiles) {
682+
console.log(`✅ Processing ${sectionFile.filename}...`);
683+
684+
const filePath = path.join(inputDir, sectionFile.filename);
685+
try {
686+
const sectionContent = await this.readFile(filePath);
687+
const sectionTree = await this.parser.parse(sectionContent);
688+
689+
// Increment heading levels by 1 to restore original structure
690+
const adjustedTree = this.incrementHeadingLevels(sectionTree);
691+
const sectionMarkdown = await this.parser.stringify(adjustedTree);
692+
693+
// Remove the leading heading since it will be a level 2 now
694+
assembledContent += sectionMarkdown + '\n\n';
695+
} catch (_error) {
696+
console.error(
697+
`⚠️ Warning: Could not read ${sectionFile.filename}, skipping...`
698+
);
699+
}
700+
}
701+
702+
// Write the assembled document
703+
await this.writeFile(outputFile, assembledContent.trim());
704+
console.log(`\n✨ Document assembled to ${outputFile}`);
705+
}
706+
707+
async extractSectionFilesFromTOC(indexTree) {
708+
// Convert the tree back to markdown to parse the TOC links
709+
const indexMarkdown = await this.parser.stringify(indexTree);
710+
const lines = indexMarkdown.split('\n');
711+
712+
const sectionFiles = [];
713+
const processedFiles = new Set();
714+
715+
for (const line of lines) {
716+
// Look for TOC lines that reference files (not just anchors)
717+
const match = line.match(/\[([^\]]+)\]\(\.\/([^#)]+)(?:#[^)]*)?\)/);
718+
if (match) {
719+
const [, linkText, filename] = match;
720+
721+
// Only include level 2 sections (main sections, not sub-sections)
722+
// Level 2 items have exactly 2 spaces before the dash (are children of main heading)
723+
// Level 3+ items have 4+ spaces (are nested deeper)
724+
if (line.match(/^ {2}[-*] \[/) && !processedFiles.has(filename)) {
725+
sectionFiles.push({
726+
filename,
727+
title: linkText,
728+
});
729+
processedFiles.add(filename);
730+
}
731+
}
732+
}
733+
734+
return sectionFiles;
735+
}
604736
}
605737

606-
// Run CLI
738+
// Export the class for testing
739+
export { MarkdownCLI };
740+
607741
const cli = new MarkdownCLI();
608742
cli.run();

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@kayvan/markdown-tree-parser",
3-
"version": "1.2.1",
3+
"version": "1.3.0",
44
"description": "A powerful JavaScript library and CLI tool for parsing and manipulating markdown files as tree structures using the remark/unified ecosystem",
55
"type": "module",
66
"main": "index.js",

test/test-cli.js

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -155,10 +155,10 @@ async function runTests() {
155155

156156
const testFile = await setupTests();
157157

158-
// Test explode-doc command
159-
await test('CLI explode-doc command', async () => {
158+
// Test explode command
159+
await test('CLI explode command', async () => {
160160
const outputDir = path.join(testDir, 'exploded');
161-
const result = await runCLI(['explode-doc', testFile, outputDir]);
161+
const result = await runCLI(['explode', testFile, outputDir]);
162162

163163
assert(
164164
result.code === 0,
@@ -189,7 +189,7 @@ async function runTests() {
189189
);
190190
});
191191

192-
await test('CLI explode-doc index.md content', async () => {
192+
await test('CLI explode index.md content', async () => {
193193
const outputDir = path.join(testDir, 'exploded');
194194
const indexPath = path.join(outputDir, 'index.md');
195195
const indexContent = await fs.readFile(indexPath, 'utf-8');
@@ -236,7 +236,7 @@ async function runTests() {
236236
);
237237
});
238238

239-
await test('CLI explode-doc individual files', async () => {
239+
await test('CLI explode individual files', async () => {
240240
const outputDir = path.join(testDir, 'exploded');
241241

242242
// Check introduction.md
@@ -276,34 +276,31 @@ async function runTests() {
276276
);
277277
});
278278

279-
await test('CLI explode-doc error handling', async () => {
279+
await test('CLI explode error handling', async () => {
280280
// Test with non-existent file
281-
const result = await runCLI(['explode-doc', 'non-existent.md', testDir]);
281+
const result = await runCLI(['explode', 'non-existent.md', testDir]);
282282
assert(result.code !== 0, 'Should fail with non-existent file');
283283
assert(
284284
result.stderr.includes('Error reading file'),
285285
'Should show error message'
286286
);
287287
});
288288

289-
await test('CLI explode-doc usage help', async () => {
289+
await test('CLI explode usage help', async () => {
290290
// Test with missing arguments
291-
const result = await runCLI(['explode-doc']);
291+
const result = await runCLI(['explode']);
292292
assert(result.code !== 0, 'Should fail with missing arguments');
293293
assert(
294-
result.stderr.includes('Usage: md-tree explode-doc'),
294+
result.stderr.includes('Usage: md-tree explode'),
295295
'Should show usage message'
296296
);
297297
});
298298

299-
// Test that help includes explode-doc
300-
await test('CLI help includes explode-doc', async () => {
299+
// Test that help includes explode
300+
await test('CLI help includes explode', async () => {
301301
const result = await runCLI(['help']);
302302
assert(result.code === 0, 'Help command should succeed');
303-
assert(
304-
result.stdout.includes('explode-doc'),
305-
'Help should mention explode-doc'
306-
);
303+
assert(result.stdout.includes('explode'), 'Help should mention explode');
307304
assert(
308305
result.stdout.includes('Extract all level 2 sections and create index'),
309306
'Should have description'

0 commit comments

Comments
 (0)