Add HTML to PnP Modern Search Templates
These two scripts work together to inject new HTML into multiple PnP Modern Search Handlebars result templates stored in a SharePoint Site Assets library. The first is a diagnostic tool to confirm your anchor string before making any changes; the second performs the injection across all templates in a folder.
The challenge with any find-and-replace approach on HTML files is that the anchor string must match the file content exactly — including indentation, trailing spaces, and line endings. Before running the main update script, use the diagnostic script to inspect the exact characters around your intended insertion point. It downloads the template and prints a numbered, bracket-wrapped view of the relevant lines, making trailing spaces and CRLF line endings visible.
Once you have confirmed the exact anchor string, the main script connects to the target site, downloads each .html template from the specified Site Assets folder, performs the string replacement in memory, and uploads the modified file back — no temp files on disk.
Prerequisites
- PnP PowerShell module installed
- Site owner permissions on the target site
- An Entra app registration client ID configured for interactive login
- PnP Modern Search Handlebars templates stored in a Site Assets folder
Handlebars Template Context — Before
This is the section of the Handlebars template where the CTA button is injected — immediately after the closing </div> of the tags block:
<div class="tags"><!-- Tag - fixed styling -->
{{#if item.RefinableString05}}
<span class="card-tag">{{trim item.RefinableString05}}</span>
{{/if}}
{{#if item.RefinableString04}}
<span class="card-tag">{{trim item.RefinableString04}}</span>
{{/if}}
</div>
</div>
Script 1: Find-TemplateAnchor.ps1
Run this diagnostic script first. It downloads a single template and prints lines 160–175 with bracket wrapping so you can see trailing spaces and CRLF endings. Use the output to confirm the exact anchor string before running the update script.
# ============================================================
# Find-TemplateAnchor.ps1
# Downloads NewsCards.html and shows the closing tags section
# so we can identify the exact anchor string for replacement
# ============================================================
$siteUrl = "https://tenantName.sharepoint.com/sites/siteName"
$folderUrl = "/sites/siteName/SiteAssets/pnpCustomerTemplates"
$clientId = ""
Connect-PnPOnline -Url $siteUrl -Interactive -ClientId $clientId
$content = Get-PnPFile -Url "$folderUrl/NewsCards.html" -AsString
Write-Host "File downloaded — $($content.Length) characters`n"
$lines = $content -split "`n"
Write-Host "Total lines: $($lines.Count)`n"
Write-Host "--- Lines 160 to 175 ---"
for ($i = 160; $i -le 175; $i++) {
if ($i -lt $lines.Count) {
Write-Host "$($i.ToString().PadLeft(3)): [$($lines[$i])]"
}
}
Script 2: UpdateHTMLToPnPTemplate.ps1
Once you have confirmed the anchor string using the diagnostic script above, run this script to inject the CTA button HTML and supporting CSS into every .html template in the target folder. Files where the anchor is not found are skipped with a warning rather than modified.
# ============================================================
# UpdateHTMLToPnPTemplate.ps1
# Injects a CTA button into all PnP Handlebars result templates
# stored in a SharePoint Site Assets folder
# ============================================================
$siteUrl = "https://tenantName.sharepoint.com/sites/siteName"
$folderPath = "SiteAssets/pnpCustomerTemplates"
$clientId = "" # <-- replace with your Entra app registration client ID
$buttonUrl = "https://www.google.com" # <-- replace with real URL per deployment
$buttonLabel = "View More" # <-- replace with real label
# Derived server-relative folder URL — used for Get-PnPFile and Add-PnPFile
$folderUrl = "/sites/siteName/$folderPath"
# The button HTML to inject — sits inside card-content, after the tags div
$buttonHtml = @"
<div class="card-actions">
<a href="$buttonUrl" target="_blank" rel="noopener noreferrer" class="card-cta-btn">$buttonLabel</a>
</div>
"@
# CSS to inject into the <style> block of each template
$buttonCss = @"
/* CTA button */
.card-actions {
margin-top: 8px;
}
.card-cta-btn {
display: inline-block;
font-size: 11px;
font-weight: 600;
color: #ffffff;
background-color: #0f6cbd;
border: 1px solid #0f6cbd;
border-radius: 4px;
padding: 4px 12px;
text-decoration: none;
letter-spacing: 0.02em;
transition: background-color 0.2s ease;
white-space: nowrap;
}
.card-cta-btn:hover {
background-color: #115ea3;
text-decoration: none;
}
"@
# ============================================================
function Update-Template {
param(
[string]$FileName,
[string]$Content
)
$updated = $false
$newContent = $Content
# 1 — Inject button HTML after tags block
if ($newContent.Contains(" </div> `r`n </div>")) {
$newContent = $newContent.Replace(
" </div> `r`n </div>",
" </div>`r`n$buttonHtml`r`n </div>"
)
$updated = $true
} else {
Write-Warning "ANCHOR NOT FOUND (HTML): $FileName — skipping button injection"
}
# 2 — Inject CSS before closing </style>
if ($newContent.Contains("</style>")) {
$newContent = $newContent.Replace("</style>", "$buttonCss`n</style>")
} else {
Write-Warning "NO </style> TAG FOUND: $FileName — CSS not injected"
}
if ($updated) { return $newContent }
return $null
}
# ============================================================
try {
Connect-PnPOnline -Url $siteUrl -Interactive -ClientId $clientId
# Get all .html files in the templates folder
$files = Get-PnPFolderItem -FolderSiteRelativeUrl $folderPath -ItemType File |
Where-Object { $_.Name -like "*.html" }
if (-not $files) {
Write-Warning "No .html files found in $folderPath"
return
}
Write-Host "Found $($files.Count) template(s)`n"
foreach ($file in $files) {
$filePath = "$folderUrl/$($file.Name)"
Write-Host "Processing: $($file.Name)"
# Download file content
$content = Get-PnPFile -Url $filePath -AsString
# Apply updates
$newContent = Update-Template -FileName $file.Name -Content $content
if ($newContent) {
# Upload updated content back
$bytes = [System.Text.Encoding]::UTF8.GetBytes($newContent)
$stream = New-Object System.IO.MemoryStream(, $bytes)
Add-PnPFile -FileName $file.Name -Folder $folderUrl -Stream $stream | Out-Null
$stream.Dispose()
Write-Host " OK $($file.Name)" -ForegroundColor Green
} else {
Write-Host " SKIPPED $($file.Name)" -ForegroundColor Yellow
}
}
Write-Host "`nDone."
}
catch {
Write-Error "Error: $_"
}
Handlebars Template Context — After
This is how the same section looks once the script has run — the card-actions div with the CTA button has been inserted between the tags block and the closing card div:
<div class="tags"><!-- Tag - fixed styling -->
{{#if item.RefinableString05}}
<span class="card-tag">{{trim item.RefinableString05}}</span>
{{/if}}
{{#if item.RefinableString04}}
<span class="card-tag">{{trim item.RefinableString04}}</span>
{{/if}}
</div>
<div class="card-actions">
<a href="https://www.google.com" target="_blank" rel="noopener noreferrer" class="card-cta-btn">View More</a>
</div>
</div>
Usage Notes
- Run the diagnostic script first on a single template to verify the anchor string matches exactly — pay close attention to trailing spaces after the closing
</div> - If the anchor is not found, adjust the
.Contains()string inUpdate-Templateto match the output from the diagnostic script - The update script skips any file where the anchor is not found rather than modifying it incorrectly — check the warnings in the output
- Each file is uploaded back to the same filename in the same folder, overwriting the existing template
- Update
$buttonUrland$buttonLabelbefore each deployment — these are injected as literal strings into the template HTML