<# .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 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
`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 }