SCRIPT LIBRARY · POWERSHELL
Join Chapter Files into One Manuscript with PowerShell
Stitch a folder of Markdown or text chapter files into one full manuscript, in the right order, with a title block and a word count that's never out of date.
- What it does
- Combines every .md and .txt chapter file in a folder, in natural order, into a single Markdown manuscript with a separator between chapters, an optional title block with the author, date and a live word count, and optional stripping of YAML front matter and HTML comments.
- Requires
- Windows PowerShell 5.1 or PowerShell 7+
- No modules
- Permissions
- Read access to the chapter folder and write access wherever the manuscript goes. The chapter files are never changed.
- Runs on
- Windows 10/11, macOS or Linux with PowerShell 7
- Tested
- Parse-checked and run in PowerShell 7.4 against a temp folder with Chapter 1, 2, 9 and 10, a .txt chapter, a prologue with a BOM, CRLF line endings, front matter and a comment, a non-chapter file, and an existing output file, with and without -WhatIf, -Force, -Exclude and each separator
Part 3 of the thread Tools for the writing desk
Writing one file per chapter is lovely right up until someone asks for the whole book. Then it's copy, paste, scroll, copy, paste, and somewhere around Chapter 23 you realise Chapter 10 landed after Chapter 1 because that's how the folder sorted it.
I had a little script for this that did exactly one book: its path, its title and its word count were all typed into it, and the count was whatever I'd last remembered to update. This version works on any folder, sorts chapters the way a person would, and works the word count out from the finished text every time it runs.
It can also tidy up on the way through. If your chapters carry YAML front matter or <!-- notes to self -->, you can leave those out of the manuscript without touching the source files.
<#
.SYNOPSIS
Combines a folder of chapter files into one full-manuscript Markdown file.
.DESCRIPTION
Reads every .md and .txt file in a folder, sorts them naturally (Chapter 2 before Chapter 10), and writes them
out as a single Markdown file, with a separator between chapters.
Give it a -Title and the file starts with a title block: the title, the author, the date and a word count that's
worked out from the finished text, so it's never stale. Use -StripFrontMatter and -StripComments to drop YAML
front matter and <!-- notes to self --> from each chapter on the way in.
The output file is skipped if it happens to live in the chapter folder, so running it twice doesn't swallow
the last build. Supports -WhatIf.
.PARAMETER Path
The folder of chapter files. Defaults to the current folder.
.PARAMETER OutputPath
Where to write the manuscript. Defaults to Manuscript.md in the parent of the chapter folder.
.PARAMETER Title
Book title for the title block. No title, no title block.
.PARAMETER Author
Author name for the title block.
.PARAMETER Date
Date shown in the title block. Defaults to today.
.PARAMETER Separator
What goes between chapters: Rule (a Markdown ---), PageBreak (an HTML page break that Pandoc, Typora and most
Markdown-to-PDF tools honour), or None. Default: PageBreak.
.PARAMETER Include
Extensions to include. Default: .md, .txt.
.PARAMETER Exclude
Wildcard patterns for files to leave out, such as '*notes*'.
.PARAMETER StripFrontMatter
Remove a YAML front matter block from the top of each chapter.
.PARAMETER StripComments
Remove HTML comments (<!-- ... -->) from each chapter.
.PARAMETER Force
Overwrite the output file if it already exists.
.EXAMPLE
.\Join-ManuscriptChapter.ps1 -Path .\chapters -Title 'The Lighthouse Keeper' -Author 'A. Writer'
.EXAMPLE
.\Join-ManuscriptChapter.ps1 -Path .\chapters -OutputPath .\build\draft.md -StripFrontMatter -StripComments -Force -WhatIf
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })]
[string]$Path = '.',
[string]$OutputPath,
[string]$Title,
[string]$Author,
[datetime]$Date = (Get-Date),
[ValidateSet('Rule', 'PageBreak', 'None')][string]$Separator = 'PageBreak',
[string[]]$Include = @('.md', '.txt'),
[string[]]$Exclude = @(),
[switch]$StripFrontMatter,
[switch]$StripComments,
[switch]$Force
)
function Measure-Word([string]$Text) {
# Count what a reader would: drop comments, link targets and formatting marks, then count runs with a letter or digit.
$Text = $Text -replace '(?s)<!--.*?-->', '' -replace '!\[[^\]]*\]\([^)]*\)', '' -replace '\[([^\]]*)\]\([^)]*\)', '$1' -replace '<[^>]+>', ' '
$Text = $Text -replace '(?m)^\s{0,3}(#{1,6}|>|[-*+]|\d+\.)\s+', '' -replace '[*_~`]+', ''
@($Text -split '\s+' | Where-Object { $_ -match '[\p{L}\p{N}]' }).Count
}
$folder = (Resolve-Path -LiteralPath $Path).ProviderPath
if (-not $OutputPath) { $OutputPath = Join-Path (Split-Path $folder -Parent) 'Manuscript.md' }
$outFull = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)
if ((Test-Path -LiteralPath $outFull) -and -not $Force -and -not $WhatIfPreference) {
throw "$outFull already exists. Use -Force to overwrite it."
}
$Include = @($Include | ForEach-Object { if ($_ -like '.*') { $_.ToLower() } else { ".$_".ToLower() } })
$files = @(Get-ChildItem -LiteralPath $folder -File |
Where-Object { $Include -contains $_.Extension.ToLower() -and $_.FullName -ne $outFull } |
Where-Object { $name = $_.Name; -not ($Exclude | Where-Object { $name -like $_ }) } |
Sort-Object { [regex]::Replace($_.Name, '\d+', { $args[0].Value.PadLeft(10, '0') }) })
if (-not $files.Count) { Write-Warning "No $($Include -join ', ') files found in $folder."; return }
$break = switch ($Separator) {
'Rule' { "`n`n---`n`n" }
'PageBreak' { "`n`n<div style=`"page-break-after: always;`"></div>`n`n" }
'None' { "`n`n" }
}
$chapters = [System.Collections.Generic.List[string]]::new()
$included = [System.Collections.Generic.List[string]]::new()
foreach ($file in $files) {
try {
$text = [IO.File]::ReadAllText($file.FullName) # detects a BOM and strips it; assumes UTF-8 otherwise
$text = $text -replace "`r`n?", "`n"
if ($StripFrontMatter) { $text = $text -replace '(?s)\A---\n.*?\n(---|\.\.\.)\n', '' }
if ($StripComments) { $text = $text -replace '(?s)<!--.*?-->\n?', '' }
$text = $text.Trim()
if (-not $text) { Write-Warning "$($file.Name) is empty after cleanup; skipped."; continue }
$chapters.Add($text)
$included.Add($file.Name)
Write-Verbose "Added $($file.Name)"
}
catch {
Write-Warning "$($file.Name): $($_.Exception.Message)"
}
}
$body = $chapters -join $break
$words = Measure-Word $body
$header = ''
if ($Title) {
$lines = @("# $Title", '')
if ($Author) { $lines += "**$Author**", '' }
$lines += "Word count: $($words.ToString('N0')) ", "Date: $($Date.ToString('MMMM d, yyyy'))", '', '---', '', ''
$header = $lines -join "`n"
}
$written = $false
if ($PSCmdlet.ShouldProcess($outFull, "Write manuscript from $($included.Count) chapter file(s)")) {
$outDir = Split-Path $outFull -Parent
if (-not (Test-Path -LiteralPath $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null }
[IO.File]::WriteAllText($outFull, $header + $body + "`n", [Text.UTF8Encoding]::new($false))
$written = $true
}
[pscustomobject]@{
OutputPath = $outFull
Chapters = $included.Count
Words = $words
Files = $included.ToArray()
Written = $written
}
Parameters
| Parameter | Type | Default | What it's for |
|---|---|---|---|
-Path | string | . | The folder of chapter files. |
-OutputPath | string | Manuscript.md next to the chapter folder | Where to write the finished manuscript. |
-Title | string | — | Book title. Setting it adds the title block; leave it off for chapters only. |
-Author | string | — | Author name for the title block. |
-Date | datetime | Today | Date shown in the title block, written out like September 29, 2026. |
-Separator | string | PageBreak | What goes between chapters. Rule (a Markdown horizontal rule), PageBreak (an HTML page break) or None. |
-Include | string[] | .md, .txt | Which extensions count as chapters. |
-Exclude | string[] | — | Wildcards for files to leave out, like '*notes*' or 'Outline*'. |
-StripFrontMatter | switch | — | Drop YAML front matter from the top of each chapter. |
-StripComments | switch | — | Drop HTML comments from each chapter. |
-Force | switch | — | Overwrite the output file if it's already there. |
Run it
A full manuscript with a title block, page breaks between chapters.
.\Join-ManuscriptChapter.ps1 -Path .\chapters -Title 'The Lighthouse Keeper' -Author 'A. Writer'A clean copy for a reader, with the notes and front matter stripped out, rebuilt over last week's.
.\Join-ManuscriptChapter.ps1 -Path .\chapters -OutputPath .\build\lighthouse-keeper-draft.md -Title 'The Lighthouse Keeper' -Author 'A. Writer' -StripFrontMatter -StripComments -ForceSee which files would go in, and in what order, without writing anything.
(.\Join-ManuscriptChapter.ps1 -Path .\chapters -WhatIf).FilesPlain rules between chapters and no outline file, ready for Pandoc.
.\Join-ManuscriptChapter.ps1 -Path .\chapters -Separator Rule -Exclude 'Outline*' -Force; pandoc ..\Manuscript.md -o manuscript.docxWhat you'll see
OutputPath : C:\Writing\lighthouse-keeper\Manuscript.md
Chapters : 32
Words : 81406
Files : {00 Prologue.md, Chapter 1.md, Chapter 2.md, Chapter 3.md...}
Written : True
# The Lighthouse Keeper
**A. Writer**
Word count: 81,406
Date: September 29, 2026
---
# Prologue
...
How it works
- Find the chapters. It lists every
.mdand.txtfile in the folder, drops anything matching-Excludeand the output file itself, then sorts with every number padded out, so the order follows the chapter numbers rather than the characters. - Read and tidy each one. Each file is read as UTF-8 (a byte order mark is dropped), Windows line endings become plain newlines, and with the switches on, front matter and
<!-- -->comments come out. An empty file gets a warning and is left out. - Join them. Chapters are joined with the separator you picked: a page break by default, a
---rule, or just a blank line. - Count the words. The count runs on the joined text with link targets, HTML and formatting marks ignored, so
**bold**is one word and the page-break divs are none. It uses the same rules as Measure-ManuscriptWords, so the two agree. - Write it out. The title block goes on top if you gave a
-Title, and the file is written as UTF-8 without a BOM. An existing file is left alone unless you add-Force, and-WhatIfshows the plan without writing anything. Either way you get an object back with the path, the chapter count, the words and the file list.
Take it further
- Go straight to Kindle. When the full draft reads right, Convert-MarkdownToKdpHtml takes the same folder of chapters and builds a Kindle-ready file.
- Keep dated drafts. Put the date in the output name,
-OutputPath ".\drafts\draft-$(Get-Date -Format yyyy-MM-dd).md", and you've got a snapshot of the book every time you send it out. - Add chapter headings. If your chapter files don't start with a heading, prepend
"# $($file.BaseName)"to each one inside the loop, and the file names become the chapter titles.
Things that'll trip you up
- Headings count as words. The word count is taken from the finished manuscript, so "Chapter 12" adds two words to the total. Over thirty chapters that's a rounding error, but it's why this number can run a little higher than a count of the chapter files on their own.
- Old builds in the chapter folder get included. The output file itself is always skipped, but if you once saved a full-draft.md next to your chapters, it'll be swept in as a chapter. Keep builds in their own folder, or use -Exclude.
- Name chapters so they sort. Numbers are sorted naturally, so Chapter 9 comes before Chapter 10. Words aren't, so "Prologue" lands after "Chapter". Prefix it with 00, and the epilogue with 99.
- Page breaks depend on the tool. The PageBreak separator is an HTML div that Pandoc (to PDF or .docx via HTML), Typora and most Markdown-to-PDF tools honour. A plain Markdown viewer just shows nothing there. If that's all you need, use -Separator Rule.