Public/Export-ModuleToFlatTextAndManifest.ps1

<#
.SYNOPSIS
Export all files and folders in a module to a JSON manifest and a single text file with clearly delimited sections for each file.

.DESCRIPTION
Export-ModuleToFlatTextAndManifest takes a path to a module root directory, recursively enumerates all files and folders, and generates:
- A JSON manifest listing all file and folder paths relative to the module root (using / as the separator so the export can be imported on any platform), with type and parent path information.
- A single text file containing the content of every file, with each file's content wrapped in a unique, easily parseable section header and footer.

This can be used for archiving, review, or later reconstruction of the module folder structure using a companion import function.
Only text files are exported; binary files (files containing NUL characters) are skipped with a warning.

.PARAMETER ModulePath
The root path of the module to export.

.PARAMETER OutputPath
The directory where the JSON manifest and text file will be written. Created if it does not exist.

.EXAMPLE
Export-ModuleToFlatTextAndManifest -ModulePath 'C:\git\tcs.intune\modules\tcs.intune' -OutputPath 'C:\temp\tcs.intune.export'

Creates tcs.intune_manifest.json and tcs.intune_flat.txt in C:\temp\tcs.intune.export.

.INPUTS
None. ModulePath and OutputPath are not accepted from the pipeline.

.OUTPUTS
None. Writes the manifest and flat text files to OutputPath; use -Verbose to see paths.

.LINK
Import-ModuleFromFlatTextAndManifest

.NOTES
Author: TheCodeSaiyan
Date: 2025-09-11
Compatible with PowerShell Constrained Language Mode (no [regex] or other restricted .NET types).
#>

function Export-ModuleToFlatTextAndManifest {
    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$ModulePath,

        [Parameter(Mandatory = $true)]
        [string]$OutputPath
    )

    $TelemetryArgs = @{
        ModuleName    = $MyInvocation.MyCommand.Module.Name
        ModuleVersion = [string]$MyInvocation.MyCommand.Module.Version
        CommandName   = $MyInvocation.MyCommand.Name
        ExecutionID   = [guid]::NewGuid().ToString()
    }
    Invoke-TelemetryCollection @TelemetryArgs -Stage Start -ClearTimer

    try {
        if (-not (Test-Path -Path $ModulePath -PathType Container)) {
            throw "ModulePath '$ModulePath' does not exist."
        }
        # Work with the full path so relative paths and trailing separators give correct relative names
        $moduleFullPath = (Resolve-Path -Path $ModulePath -ErrorAction Stop).ProviderPath.TrimEnd('\', '/')
        if (-not (Test-Path -Path $OutputPath)) {
            $null = New-Item -ItemType Directory -Path $OutputPath -Force -ErrorAction Stop
        }

        $items = Get-ChildItem -Path $moduleFullPath -Recurse -Force | ForEach-Object {
            $relPath = $_.FullName.Substring($moduleFullPath.Length).TrimStart('\', '/') -replace '\\', '/'
            $parentPath = if ($relPath -match '/') { $relPath -replace '/[^/]*$', '' } else { '' }
            [PSCustomObject]@{
                RelativePath = $relPath
                ParentPath   = $parentPath
                FullPath     = $_.FullName
                ItemType     = if ($_.PSIsContainer) { 'Directory' } else { 'File' }
            }
        } | Sort-Object -Property ItemType, RelativePath

        $moduleRoot = Split-Path -Path $moduleFullPath -Leaf
        $manifestPath = Join-Path -Path $OutputPath -ChildPath "${moduleRoot}_manifest.json"
        $textPath = Join-Path -Path $OutputPath -ChildPath "${moduleRoot}_flat.txt"

        $sections = @()
        $exportedItems = @()
        foreach ($item in $items) {
            if ($item.ItemType -eq 'File') {
                try {
                    $content = Get-Content -Path $item.FullPath -Raw -Encoding UTF8 -ErrorAction Stop
                }
                catch {
                    Write-Warning "Failed to read file: $($item.FullPath). Error: $($_.Exception.Message)"
                    continue
                }
                if ($null -eq $content) {
                    $content = ''
                }
                if ($content -match "`0") {
                    Write-Warning "Skipping binary file: $($item.FullPath)"
                    continue
                }
                # The import removes exactly one line break after the start marker and one before the end marker
                $sections += "###FILE-START:$($item.RelativePath)###`n$content`n###FILE-END:$($item.RelativePath)###`n"
            }
            $exportedItems += $item | Select-Object -Property RelativePath, ParentPath, ItemType, FullPath
        }

        $manifestObj = [PSCustomObject]@{
            ModuleRoot = $moduleRoot
            Items      = @($exportedItems)
        }
        $manifestObj | ConvertTo-Json -Depth 5 | Set-Content -Path $manifestPath -Encoding UTF8 -ErrorAction Stop
        Set-Content -Path $textPath -Value ($sections -join "`n") -Encoding UTF8 -NoNewline -ErrorAction Stop

        Write-Verbose "Exported manifest to $manifestPath and flat text to $textPath."
        Invoke-TelemetryCollection @TelemetryArgs -Stage End
    }
    catch {
        Invoke-TelemetryCollection @TelemetryArgs -Stage End -Failed $true -Exception $_
        throw
    }
}