PS_Site_Builder/build.ps1
2025-12-30 13:57:49 +10:30

787 lines
24 KiB
PowerShell

<#
.SYNOPSIS
Build script for static website generator
.DESCRIPTION
This script processes Markdown content files and generates a static HTML website.
It dynamically detects content sections, processes blog posts, and generates
navigation menus. The output is a fully functional static website in the dist/ folder.
Features:
- Dynamic section detection from content/ folder
- Markdown to HTML conversion
- Blog post processing with front matter
- Automatic image handling
- Responsive navigation generation
.REQUIREMENTS
- PowerShell 7+ (for better UTF-8 handling)
- Markdown files in content/ folder
- Images in images/ folder
.NOTES
Output: Generated files are placed in dist/ directory
#>
# ============================================================================
# CONFIGURATION
# ============================================================================
# Get the directory where this script is located
$projectRoot = $PSScriptRoot
# Define source and output directories
$sourceDir = Join-Path $projectRoot "content" # Markdown content source
$templateFile = Join-Path $projectRoot "template.html" # Temporary template file
$imagesDir = Join-Path $projectRoot "images" # Source images directory
# Output directory - can be overridden with OUTPUT_DIR environment variable
$outputDir = if ($env:OUTPUT_DIR) {
Join-Path $projectRoot $env:OUTPUT_DIR
} else {
Join-Path $projectRoot "dist" # Default output directory
}
# Get current year for copyright
$year = (Get-Date).Year
# Site configuration - customize these values
$siteTitle = "My Website"
$siteHeader = "Welcome to My Website"
$footerText = "My Website"
# Temporary output files (will be moved to dist/ at the end)
$tempOutputFile = Join-Path $projectRoot "index.html"
$tempBlogOutputFile = Join-Path $projectRoot "blog.html"
# ============================================================================
# HELPER FUNCTIONS
# ============================================================================
<#
.SYNOPSIS
Finds the background image file in the images directory
.DESCRIPTION
Searches for any file matching "background.*" pattern and returns
the relative path for use in CSS background-image property.
.PARAMETER imagesDir
Path to the images directory
.OUTPUTS
String - Relative path to background image (e.g., "images/background.jpg")
Empty string if no background image found
#>
function Get-BackgroundImagePath {
param (
[string]$imagesDir
)
$backgroundImages = Get-ChildItem (Join-Path $imagesDir "background.*") -ErrorAction SilentlyContinue
if ($backgroundImages.Count -gt 0) {
return "images/" + $backgroundImages[0].Name
}
return "" # Return empty string if no background image found
}
# Create output directory if it doesn't exist
if (-not (Test-Path $outputDir)) {
New-Item -ItemType Directory -Path $outputDir | Out-Null
}
# Copy static files to dist first
Copy-Item "styles.css" -Destination $outputDir -Force
if (Test-Path "images") {
Copy-Item "images" -Destination $outputDir -Recurse -Force
}
# Set output files after copying
$outputFile = Join-Path $outputDir "index.html"
$blogOutputFile = Join-Path $outputDir "blog.html"
# Get background image path
$backgroundPath = Get-BackgroundImagePath -imagesDir $imagesDir
$backgroundStyle = if ($backgroundPath) {
"background-image: url('$backgroundPath');"
} else {
"" # No background image
}
<#
.SYNOPSIS
Generates navigation menu HTML from section IDs
.DESCRIPTION
Creates an unordered list of navigation links for all detected sections,
plus a Blog link. Section names are capitalized for display.
.PARAMETER sections
Array of section IDs (e.g., @("about", "services", "contact"))
.OUTPUTS
String - HTML navigation menu items
#>
function Get-NavigationHtml {
param (
[array]$sections
)
$navItems = @()
foreach ($section in $sections) {
# Capitalize first letter of section name
$sectionName = $section.Substring(0,1).ToUpper() + $section.Substring(1)
$navItems += " <li><a href=`"#$section`">$sectionName</a></li>"
}
# Always add Blog link
$navItems += " <li><a href=`"blog.html`">Blog</a></li>"
return $navItems -join "`n"
}
<#
.SYNOPSIS
Finds all numbered images for a section
.DESCRIPTION
Searches for images matching the pattern {sectionId}{number}.*
(e.g., "about1.webp", "about2.jpg", "services3.png")
and returns them as a hashtable indexed by number.
.PARAMETER sectionId
The section identifier (e.g., "about", "services")
.OUTPUTS
Hashtable - Images indexed by number (e.g., @{1="about1.webp"; 2="about2.jpg"})
.EXAMPLE
Get-SectionImages -sectionId "about"
Returns: @{1="about1.webp"; 2="about2.jpg"; 3="about3.jpg"}
#>
function Get-SectionImages {
param (
[string]$sectionId
)
# Get all files matching the pattern sectionX.* (e.g., about1.webp, about2.png)
$images = Get-ChildItem (Join-Path $imagesDir "$sectionId[0-9]*.*") -ErrorAction SilentlyContinue
# Create a hashtable to store images by number
$imagesByNumber = @{}
foreach ($image in $images) {
# Extract the number from the filename using regex
# Matches: sectionId followed by one or more digits
if ($image.BaseName -match "$sectionId(\d+)") {
$number = [int]$matches[1]
$imagesByNumber[$number] = $image.Name
}
}
return $imagesByNumber
}
<#
.SYNOPSIS
Converts a Markdown file to an HTML section
.DESCRIPTION
Processes a Markdown file and converts it to a full HTML section with:
- Section heading extracted from first # heading
- Content blocks separated by "---" dividers
- Alternating left/right image alignment
- Automatic image assignment from images directory
.PARAMETER file
Path to the Markdown file to convert
.PARAMETER sectionId
The section ID to use (e.g., "about", "services")
.OUTPUTS
String - Complete HTML section markup
.NOTES
Markdown format:
- First line: # Section Title
- Content blocks separated by "---"
- Each block becomes a section-content div with text and image
#>
function Convert-MarkdownToSection {
param (
[string]$file,
[string]$sectionId
)
# Read markdown content and split by "---" dividers
$content = Get-Content $file -Raw
$sections = $content -split "---" | ForEach-Object { $_.Trim() }
# Get available numbered images for this section (e.g., about1.webp, about2.jpg)
$sectionImages = Get-SectionImages -sectionId $sectionId
# Extract title from first section's # heading, or capitalize section ID as fallback
$title = if ($sections[0] -match "^#\s+(.+)$") {
$matches[1]
} else {
$sectionId.Substring(0,1).ToUpper() + $sectionId.Substring(1)
}
# Start building section HTML
$sectionHtml = @"
<section id="$sectionId" class="snap-section">
<h2>$title</h2>
<div class="content-container">
"@
# Process each content section (separated by "---")
for ($i = 0; $i -lt $sections.Count; $i++) {
$text = $sections[$i]
# Image selection priority:
# 1. Explicit markdown image syntax: ![alt](path)
# 2. Numbered section image (e.g., about1.webp for first block)
# 3. First available numbered image if exact match not found
# 4. Default fallback: sectionId.webp
$imageMatch = $text -match '!\[([^\]]*)\]\(([^\)]+)\)'
$imageSrc = if ($imageMatch) {
$text = $text -replace '!\[([^\]]*)\]\(([^\)]+)\)', '' # Remove image markdown from text
$matches[2] # Use specified image path
} elseif ($sectionImages.ContainsKey($i + 1)) {
"images/" + $sectionImages[$i + 1] # Use numbered section image (e.g., about1.webp)
} elseif ($sectionImages.Count -gt 0) {
# Use first available image if numbered image doesn't exist
$sortedKeys = $sectionImages.Keys | Sort-Object
$firstKey = $sortedKeys[0]
"images/" + $sectionImages[$firstKey]
} else {
"images/$sectionId.webp" # Default fallback
}
# Generate alt text: use markdown alt text if provided, otherwise generate descriptive text
$imageAlt = if ($imageMatch) {
$matches[1] # Use specified alt text from markdown
} else {
"$sectionId image $($i + 1)" # Default alt text
}
# Remove title heading from first section (already extracted above)
$text = $text -replace "^#\s+.+`n", ""
# Convert markdown links to HTML anchor tags
$text = $text -replace '\[([^\]]+)\]\(([^\)]+)\)', '<a href="$2">$1</a>'
# Alternate alignment: even indices = left-align (image on right), odd = right-align (image on left)
$alignment = if ($i % 2 -eq 0) { "left-align" } else { "right-align" }
$sectionHtml += @"
<div class="section-content $alignment">
<div class="text-content">
<p>$text</p>
</div>
<div class="image-content">
<img src="$imageSrc" alt="$imageAlt">
</div>
</div>
"@
}
$sectionHtml += @"
</div>
</section>
"@
return $sectionHtml
}
# ============================================================================
# MAIN BUILD PROCESS - INDEX PAGE
# ============================================================================
# Dynamically detect all .md files in content folder (excluding blog subdirectory)
# Each .md file becomes a section on the index page
$contentHtml = ""
$markdownFiles = Get-ChildItem -Path $sourceDir -Filter "*.md" -File | Where-Object { $_.DirectoryName -eq $sourceDir }
$sectionIds = @()
Write-Host "Found $($markdownFiles.Count) section markdown files:"
foreach ($file in $markdownFiles) {
$sectionId = $file.BaseName # Use filename (without .md) as section ID
$sectionIds += $sectionId
Write-Host " - $sectionId"
# Convert each markdown file to an HTML section
$contentHtml += Convert-MarkdownToSection -file $file.FullName -sectionId $sectionId
}
# Generate navigation menu HTML from detected sections
$navigationHtml = Get-NavigationHtml -sections $sectionIds
# Create HTML template with placeholders
$template = @"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>$siteTitle</title>
<style>
body::before {
$backgroundStyle
}
</style>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header>
<h1>$siteHeader</h1>
<nav>
<ul>
{{navigation}}
</ul>
</nav>
</header>
<main class="snap-container">
{{content}}
</main>
<footer>
<p>&copy; $year $footerText. All rights reserved.</p>
</footer>
</body>
</html>
"@
# Replace template placeholders with actual content
$finalHtml = $template -replace "{{content}}", $contentHtml -replace "{{navigation}}", $navigationHtml
$finalHtml | Out-File $tempOutputFile -Encoding utf8
Write-Host "Building site from $projectRoot"
Write-Host "Content directory: $sourceDir"
Write-Host "Images directory: $imagesDir"
Write-Host "Build complete! Output saved to $tempOutputFile"
# ============================================================================
# BLOG BUILD PROCESS
# ============================================================================
# Blog posts are stored in content/blog/ subdirectory
$blogDir = Join-Path $sourceDir "blog"
$blogOutputFile = Join-Path $outputDir "blog.html"
Write-Host "building blog"
Write-Host "Blog dir: $blogDir"
Write-Host "Blog output file: $blogOutputFile"
# Blog template
$blogTemplate = @"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>$siteTitle - Blog</title>
<style>
body::before {
$backgroundStyle
}
</style>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header>
<h1>$siteTitle</h1>
<nav>
<ul>
{{navigation}}
</ul>
</nav>
</header>
<main class="blog-container">
<div class="blog-grid">
{{content}}
</div>
{{pagination}}
</main>
<footer>
<p>&copy; $year $footerText. All rights reserved.</p>
</footer>
</body>
</html>
"@
# Check if blog directory exists
Write-Host "Building blog from: $blogDir"
if (Test-Path $blogDir) {
Write-Host "Blog directory exists"
$files = Get-ChildItem (Join-Path $blogDir "*.md")
Write-Host "Found blog files: $($files.Count)"
$files | ForEach-Object { Write-Host " - $($_.Name)" }
} else {
Write-Host "Blog directory does not exist at: $blogDir"
# Create the directory
New-Item -ItemType Directory -Path $blogDir -Force
Write-Host "Created blog directory"
}
<#
.SYNOPSIS
Finds the image file for a blog post
.DESCRIPTION
Searches for blog post images using multiple strategies:
1. Image name specified in front matter
2. Post filename (without extension)
.PARAMETER imageName
Image name from front matter (e.g., "blog1")
.PARAMETER postFileName
Name of the blog post file (e.g., "ai-tools.md")
.PARAMETER imagesDir
Path to images directory
.OUTPUTS
String - Relative path to image (e.g., "images/blog1.webp")
$null if no image found
#>
function Get-BlogPostImage {
param (
[string]$imageName,
[string]$postFileName,
[string]$imagesDir
)
# Build search list: try specified name first, then post filename
$searchNames = @()
if ($imageName) {
$searchNames += $imageName
}
$postBaseName = [System.IO.Path]::GetFileNameWithoutExtension($postFileName)
$searchNames += $postBaseName
# Search for image files matching any of the names
foreach ($name in $searchNames) {
$imageFiles = Get-ChildItem (Join-Path $imagesDir "$name.*") -ErrorAction SilentlyContinue
if ($imageFiles.Count -gt 0) {
return "images/" + $imageFiles[0].Name
}
}
return $null
}
<#
.SYNOPSIS
Converts Markdown text to HTML with proper formatting
.DESCRIPTION
Processes Markdown syntax and converts to HTML:
- Headings (#, ##, ###, ####)
- Links [text](url)
- Lists (bulleted and numbered)
- Code blocks and inline code
- Paragraphs
.PARAMETER markdown
Markdown text to convert
.OUTPUTS
String - HTML formatted text
#>
function Convert-MarkdownToHtml {
param (
[string]$markdown
)
if ([string]::IsNullOrWhiteSpace($markdown)) {
return ""
}
$html = $markdown
# Convert code blocks first (before other processing)
$html = $html -replace '(?ms)```(\w+)?\r?\n(.*?)```', '<pre><code>$2</code></pre>'
# Convert inline code (`code`)
$html = $html -replace '`([^`]+)`', '<code>$1</code>'
# Convert headings (##, ###, etc.)
$html = $html -replace '(?m)^####\s+(.+)$', '<h4>$1</h4>'
$html = $html -replace '(?m)^###\s+(.+)$', '<h3>$1</h3>'
$html = $html -replace '(?m)^##\s+(.+)$', '<h2>$1</h2>'
$html = $html -replace '(?m)^#\s+(.+)$', '<h1>$1</h1>'
# Convert markdown links [text](url)
$html = $html -replace '\[([^\]]+)\]\(([^\)]+)\)', '<a href="$2">$1</a>'
# Process line by line to handle lists properly
$lines = $html -split "`r?`n"
$output = @()
$inList = $false
$listType = ""
$listItems = @()
foreach ($line in $lines) {
$trimmed = $line.Trim()
# Check for numbered list
if ($trimmed -match '^\d+\.\s+(.+)$') {
if (-not $inList -or $listType -ne "ol") {
# Close previous list if exists
if ($inList) {
$output += "</$listType>"
}
$inList = $true
$listType = "ol"
$listItems = @()
}
$listItems += "<li>$($matches[1])</li>"
}
# Check for bullet list
elseif ($trimmed -match '^[-*]\s+(.+)$') {
if (-not $inList -or $listType -ne "ul") {
# Close previous list if exists
if ($inList) {
$output += "</$listType>"
}
$inList = $true
$listType = "ul"
$listItems = @()
}
$listItems += "<li>$($matches[1])</li>"
}
# Empty line - close list if open
elseif ([string]::IsNullOrWhiteSpace($trimmed)) {
if ($inList) {
$output += "<$listType>" + ($listItems -join "`n") + "</$listType>"
$inList = $false
$listType = ""
$listItems = @()
}
$output += ""
}
# Regular content line
else {
# Close list if open
if ($inList) {
$output += "<$listType>" + ($listItems -join "`n") + "</$listType>"
$inList = $false
$listType = ""
$listItems = @()
}
$output += $trimmed
}
}
# Close any open list
if ($inList) {
$output += "<$listType>" + ($listItems -join "`n") + "</$listType>"
}
# Join lines and split into paragraphs
$joined = $output -join "`n"
$paragraphs = $joined -split "`n`n" | Where-Object { $_.Trim() -ne "" }
$processedParagraphs = @()
foreach ($para in $paragraphs) {
$para = $para.Trim()
if ($para -ne "") {
# Don't wrap if it's already a block element
if ($para -match '^<(pre|h[1-4]|ul|ol|p)') {
$processedParagraphs += $para
} else {
$processedParagraphs += "<p>$para</p>"
}
}
}
return $processedParagraphs -join "`n"
}
<#
.SYNOPSIS
Converts a blog post Markdown file to HTML
.DESCRIPTION
Processes blog posts with front matter (YAML-like metadata between --- markers):
- Extracts title, date, author, tags, image
- Converts markdown content to HTML
- Generates complete blog post article HTML
.PARAMETER file
Path to blog post Markdown file
.PARAMETER imagesDir
Path to images directory for blog post images
.OUTPUTS
String - Complete HTML article markup
.NOTES
Front matter format:
---
title: Post Title
date: YYYY-MM-DD
author: Author Name
tags: [tag1, tag2, tag3]
image: imageName
---
#>
function Convert-BlogPostToHtml {
param (
[string]$file,
[string]$imagesDir
)
Write-Host "Converting file: $file"
$content = Get-Content $file -Raw
Write-Host "Content length: $($content.Length)"
# Updated regex pattern to match exactly three dashes
if ($content -match "(?ms)^---[\r\n](.*?)[\r\n]---[\r\n](.*)$") {
Write-Host "Found front matter"
$frontMatter = $matches[1]
$content = $matches[2].Trim()
Write-Host "Front matter: $frontMatter"
Write-Host "Content after front matter: $content"
# Parse front matter into hashtable
$metadata = @{}
foreach ($line in ($frontMatter -split "`n")) {
if ($line -match "^(\w+):\s*(.*)$") {
$key = $matches[1]
$value = $matches[2].Trim()
if ($key -eq "tags") {
$value = $value.Trim("[]").Split(",").ForEach({ $_.Trim() })
}
$metadata[$key] = $value
Write-Host "Parsed metadata: $key = $value"
}
}
# Find blog post image
$imagePath = Get-BlogPostImage -imageName $metadata.image -postFileName (Split-Path $file -Leaf) -imagesDir $imagesDir
$imageHtml = if ($imagePath) {
"<div class=`"blog-post-image`"><img src=`"$imagePath`" alt=`"$($metadata.title)`"></div>"
} else {
""
}
# Convert tags to HTML
$tagsHtml = $metadata.tags | ForEach-Object {
"<span class='blog-tag'>$_</span>"
}
# Process content sections with proper markdown formatting
$sections = $content -split "---" | ForEach-Object { $_.Trim() }
$processedContent = $sections | ForEach-Object {
if ($_.Trim() -ne "") {
Convert-MarkdownToHtml -markdown $_
}
} | Where-Object { $_ -ne "" }
$html = @"
<article class="blog-post">
<div class="blog-post-header">
<h2 class="blog-post-title">$($metadata.title)</h2>
<div class="blog-post-meta">
<span>$($metadata.date)</span> <span>$($metadata.author)</span>
</div>
<div class="blog-post-tags">
$($tagsHtml -join '')
</div>
</div>
$imageHtml
<div class="blog-post-content">
$($processedContent -join "`n")
</div>
</article>
"@
Write-Host "Generated HTML length: $($html.Length)"
return $html
}
Write-Host "No front matter found in file"
return ""
}
# Process blog posts: read all .md files, extract dates, and sort by date (newest first)
$blogPosts = Get-ChildItem (Join-Path $blogDir "*.md") | ForEach-Object {
Write-Host "Processing blog post: $($_.Name)"
$content = Get-Content $_.FullName -Raw
Write-Host "Content read: $($content.Length) characters"
# Updated regex pattern to match exactly three dashes
if ($content -match "(?ms)^---[\r\n](.*?)[\r\n]---") {
Write-Host "Found front matter in $($_.Name)"
$frontMatter = $matches[1]
Write-Host "Front matter: $frontMatter"
if ($frontMatter -match "date:\s*(.*)") {
Write-Host "Found date: $($matches[1])"
$date = [datetime]::Parse($matches[1])
@{
File = $_
Date = $date
}
} else {
Write-Host "No date found in front matter"
}
} else {
Write-Host "No front matter found in $($_.Name)"
}
} | Sort-Object { $_.Date } -Descending
Write-Host "Sorted blog posts: $($blogPosts.Count)"
# Convert each blog post to HTML and combine
$blogHtml = ""
foreach ($post in $blogPosts) {
Write-Host "Converting blog post to HTML: $($post.File.Name)"
$html = Convert-BlogPostToHtml -file $post.File.FullName -imagesDir $imagesDir
Write-Host "Generated HTML length: $($html.Length)"
$blogHtml += $html
}
Write-Host "Total blog HTML length: $($blogHtml.Length)"
# Generate blog navigation (same as main page)
$blogNavHtml = Get-NavigationHtml -sections $sectionIds
# Generate final blog page HTML by replacing template placeholders
$finalBlogHtml = $blogTemplate -replace "{{content}}", $blogHtml -replace "{{pagination}}", "" -replace "{{navigation}}", $blogNavHtml
$finalBlogHtml | Out-File $tempBlogOutputFile -Encoding utf8
Write-Host "Blog build complete"
# ============================================================================
# FINAL OUTPUT - COPY FILES TO DIST DIRECTORY
# ============================================================================
# Create output directory if it doesn't exist
if (-not (Test-Path $outputDir)) {
New-Item -ItemType Directory -Path $outputDir | Out-Null
}
# Copy all built files to dist directory
Copy-Item $tempOutputFile -Destination (Join-Path $outputDir "index.html") -Force
Copy-Item $tempBlogOutputFile -Destination (Join-Path $outputDir "blog.html") -Force
Copy-Item "styles.css" -Destination $outputDir -Force
# Copy images directory recursively
if (Test-Path "images") {
Copy-Item "images" -Destination $outputDir -Recurse -Force
}
# Clean up temporary files created during build
Remove-Item $tempOutputFile -Force -ErrorAction SilentlyContinue
Remove-Item $tempBlogOutputFile -Force -ErrorAction SilentlyContinue
Write-Host "Files copied to dist directory"
Write-Host "Build complete! Website is ready in: $outputDir"