diff --git a/.github/workflows/deploy-quartz.yml b/.github/workflows/deploy-quartz.yml index 1586ae3..396028f 100644 --- a/.github/workflows/deploy-quartz.yml +++ b/.github/workflows/deploy-quartz.yml @@ -19,6 +19,6 @@ jobs: deploy: uses: GameCult/GameCult-Quartz/.github/workflows/quartz-pages.yml@main with: - content-dir: Kalsa + content-dir: Kalsa/Public overlay-dir: site output-dir: quartz-site/public diff --git a/scripts/lore/check-publication-boundary.ps1 b/scripts/lore/check-publication-boundary.ps1 index c093b2f..77d4f82 100644 --- a/scripts/lore/check-publication-boundary.ps1 +++ b/scripts/lore/check-publication-boundary.ps1 @@ -11,7 +11,7 @@ if (-not $RepositoryRoot) { } $repoFull = [System.IO.Path]::GetFullPath($RepositoryRoot).TrimEnd('\', '/') if (-not $ContentRoot) { - $ContentRoot = Join-Path $repoFull "Kalsa" + $ContentRoot = Join-Path $repoFull "Kalsa\Public" } $contentFull = [System.IO.Path]::GetFullPath($ContentRoot).TrimEnd('\', '/') @@ -19,7 +19,7 @@ if (-not (Test-Path -LiteralPath $repoFull -PathType Container)) { throw "Repository root not found: $repoFull" } if (-not (Test-Path -LiteralPath $contentFull -PathType Container)) { - throw "Canonical publication root not found: $contentFull" + throw "Reader publication root not found: $contentFull" } if (-not $contentFull.StartsWith($repoFull + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { throw "Publication root must remain inside the repository root" @@ -47,8 +47,8 @@ if (-not $SkipEntrypointCheck) { } else { $launcherText = Get-Content -Raw -LiteralPath $launcher -Encoding utf8 - if ($launcherText -notmatch '(?s)--contentDir.{0,80}["'']Kalsa["'']') { - $errors += "Quartz launcher does not bind --contentDir to Kalsa" + if ($launcherText -notmatch '(?s)--contentDir.{0,80}["'']Kalsa/Public["'']') { + $errors += "Quartz launcher does not bind --contentDir to Kalsa/Public" } } if (-not (Test-Path -LiteralPath $workflow -PathType Leaf)) { @@ -56,8 +56,8 @@ if (-not $SkipEntrypointCheck) { } else { $workflowText = Get-Content -Raw -LiteralPath $workflow -Encoding utf8 - if ($workflowText -notmatch '(?m)^\s*content-dir:\s*Kalsa\s*$') { - $errors += "Quartz deployment workflow does not bind content-dir to Kalsa" + if ($workflowText -notmatch '(?m)^\s*content-dir:\s*Kalsa/Public\s*$') { + $errors += "Quartz deployment workflow does not bind content-dir to Kalsa/Public" } } } @@ -66,4 +66,4 @@ if ($errors.Count -gt 0) { throw "Publication-boundary verification failed:`n- $($errors -join "`n- ")" } -Write-Output "Publication boundary passed: only $contentFull is admitted as canonical site input." +Write-Output "Publication boundary passed: only $contentFull is admitted as reader-site input." diff --git a/scripts/lore/check-publication-output.ps1 b/scripts/lore/check-publication-output.ps1 new file mode 100644 index 0000000..8b82246 --- /dev/null +++ b/scripts/lore/check-publication-output.ps1 @@ -0,0 +1,100 @@ +param( + [string]$RepositoryRoot, + [string]$ContentRoot, + [string]$OutputRoot +) + +$ErrorActionPreference = "Stop" + +if (-not $RepositoryRoot) { + $RepositoryRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +} +$repoFull = [System.IO.Path]::GetFullPath($RepositoryRoot).TrimEnd('\', '/') +if (-not $ContentRoot) { + $ContentRoot = Join-Path $repoFull "Kalsa\Public" +} +if (-not $OutputRoot) { + $OutputRoot = Join-Path $repoFull "quartz-site\public" +} +$contentFull = [System.IO.Path]::GetFullPath($ContentRoot).TrimEnd('\', '/') +$outputFull = [System.IO.Path]::GetFullPath($OutputRoot).TrimEnd('\', '/') + +foreach ($root in @($contentFull, $outputFull)) { + if (-not (Test-Path -LiteralPath $root -PathType Container)) { + throw "Required publication surface not found: $root" + } + if (-not $root.StartsWith($repoFull + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Publication surfaces must remain inside the repository root" + } +} + +function Get-SourceSlug([string]$path) { + $relative = $path.Substring($contentFull.Length).TrimStart('\', '/') -replace '\\', '/' + $withoutExtension = [System.IO.Path]::ChangeExtension($relative, $null).TrimEnd('.') + return $withoutExtension -replace ' ', '-' +} + +function Get-Route([string]$sourceSlug) { + if ($sourceSlug -eq 'index') { return '' } + if ($sourceSlug.EndsWith('/index')) { return $sourceSlug.Substring(0, $sourceSlug.Length - 6) } + return $sourceSlug +} + +function Compare-ExactSet([string]$label, [string[]]$expected, [string[]]$actual) { + $expectedSet = @($expected | Sort-Object -Unique) + $actualSet = @($actual | Sort-Object -Unique) + $missing = @($expectedSet | Where-Object { $_ -notin $actualSet }) + $extra = @($actualSet | Where-Object { $_ -notin $expectedSet }) + if ($missing.Count -gt 0 -or $extra.Count -gt 0) { + $parts = @() + if ($missing.Count -gt 0) { $parts += "missing: $($missing -join ', ')" } + if ($extra.Count -gt 0) { $parts += "unexpected: $($extra -join ', ')" } + throw "$label does not match the reader source ($($parts -join '; '))" + } +} + +$sourceSlugs = @(Get-ChildItem -LiteralPath $contentFull -Recurse -File -Filter '*.md' | ForEach-Object { + Get-SourceSlug $_.FullName +}) +$routes = @($sourceSlugs | ForEach-Object { Get-Route $_ }) + +$contentIndexPath = Join-Path $outputFull 'static\contentIndex.json' +$sitemapPath = Join-Path $outputFull 'sitemap.xml' +$rssPath = Join-Path $outputFull 'index.xml' +foreach ($path in @($contentIndexPath, $sitemapPath, $rssPath)) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Generated publication artifact missing: $path" + } +} + +$contentIndex = Get-Content -Raw -LiteralPath $contentIndexPath -Encoding utf8 | ConvertFrom-Json -AsHashtable +Compare-ExactSet 'Search index routes' $sourceSlugs @($contentIndex.Keys) + +$sitemapText = Get-Content -Raw -LiteralPath $sitemapPath -Encoding utf8 +$sitemapRoutes = @([regex]::Matches($sitemapText, '(?[^<]+)') | ForEach-Object { + [uri]::UnescapeDataString(([uri]$_.Groups['url'].Value).AbsolutePath.Trim('/')) +}) +Compare-ExactSet 'Sitemap routes' $routes $sitemapRoutes + +$rssText = Get-Content -Raw -LiteralPath $rssPath -Encoding utf8 +$rssRoutes = @([regex]::Matches($rssText, '(?s).*?(?[^<]+).*?') | ForEach-Object { + [uri]::UnescapeDataString(([uri]$_.Groups['url'].Value).AbsolutePath.Trim('/')) +}) +Compare-ExactSet 'RSS routes' $routes $rssRoutes + +$expectedHtml = @('404.html') +foreach ($route in $routes) { + $expectedHtml += if ($route) { "$route.html" } else { 'index.html' } + if ($route -and $route.Contains('/')) { + $segments = $route -split '/' + for ($i = 1; $i -lt $segments.Count; $i += 1) { + $expectedHtml += (($segments[0..($i - 1)] -join '/') + '/index.html') + } + } +} +$actualHtml = @(Get-ChildItem -LiteralPath $outputFull -Recurse -File -Filter '*.html' | ForEach-Object { + $_.FullName.Substring($outputFull.Length).TrimStart('\', '/') -replace '\\', '/' +}) +Compare-ExactSet 'Generated HTML routes' $expectedHtml $actualHtml + +Write-Output "Publication output passed: $($sourceSlugs.Count) reader notes own search, sitemap, RSS, and HTML routes." diff --git a/scripts/lore/check-wikilinks.ps1 b/scripts/lore/check-wikilinks.ps1 index 6694a7a..6750048 100644 --- a/scripts/lore/check-wikilinks.ps1 +++ b/scripts/lore/check-wikilinks.ps1 @@ -67,11 +67,21 @@ foreach ($note in $notes) { } $relativeCandidate = [System.IO.Path]::GetFullPath((Join-Path $note.DirectoryName ($noteTarget + '.md'))) if (Test-Path -LiteralPath $relativeCandidate -PathType Leaf) { $resolved += $relativeCandidate } - $stem = [System.IO.Path]::GetFileName($noteTarget).ToLowerInvariant() - if ($noteByStem.ContainsKey($stem)) { $resolved += $noteByStem[$stem] } + if ($noteTarget -notmatch '[/\\]') { + $stem = [System.IO.Path]::GetFileName($noteTarget).ToLowerInvariant() + if ($noteByStem.ContainsKey($stem)) { $resolved += $noteByStem[$stem] } + } } $resolved = @($resolved | Sort-Object -Unique) $sourceRelative = $note.FullName.Substring($contentFull.Length).TrimStart('\', '/') + $escaped = @($resolved | Where-Object { + $resolvedFull = [System.IO.Path]::GetFullPath($_) + -not ($resolvedFull.StartsWith($contentFull + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) + }) + if ($escaped.Count -gt 0) { + $errors += "$sourceRelative -> [[$($match.Groups['target'].Value)]] escapes the selected content root" + continue + } if ($resolved.Count -eq 0) { $errors += "$sourceRelative -> [[$($match.Groups['target'].Value)]] is unresolved" } diff --git a/scripts/lore/test-lore-tools.ps1 b/scripts/lore/test-lore-tools.ps1 index 0b47f7d..c62f45c 100644 --- a/scripts/lore/test-lore-tools.ps1 +++ b/scripts/lore/test-lore-tools.ps1 @@ -20,6 +20,31 @@ try { & (Join-Path $PSScriptRoot "check-publication-boundary.ps1") -RepositoryRoot $fixtureFull -ContentRoot $content -SkipEntrypointCheck & (Join-Path $PSScriptRoot "measure-depth.ps1") -ContentRoot $content -CompareRoot (Join-Path $fixtureFull "missing-comparison") | Out-Null + $output = Join-Path $fixtureFull "quartz-site\public" + $static = Join-Path $output "static" + New-Item -ItemType Directory -Path $static -Force | Out-Null + Set-Content -LiteralPath (Join-Path $output "404.html") -Encoding utf8 -Value "Not found" + Set-Content -LiteralPath (Join-Path $output "index.html") -Encoding utf8 -Value "Fixture" + Set-Content -LiteralPath (Join-Path $output "Institution.html") -Encoding utf8 -Value "Institution" + Set-Content -LiteralPath (Join-Path $static "contentIndex.json") -Encoding utf8 -Value '{"index":{},"Institution":{}}' + Set-Content -LiteralPath (Join-Path $output "sitemap.xml") -Encoding utf8 -Value 'https://example.invalid/https://example.invalid/Institution' + Set-Content -LiteralPath (Join-Path $output "index.xml") -Encoding utf8 -Value 'https://example.invalid/https://example.invalid/Institution' + & (Join-Path $PSScriptRoot "check-publication-output.ps1") -RepositoryRoot $fixtureFull -ContentRoot $content -OutputRoot $output + + $staleOutput = Join-Path $output "Stale.html" + Set-Content -LiteralPath $staleOutput -Encoding utf8 -Value "Stale" + $caughtStaleOutput = $false + try { + & (Join-Path $PSScriptRoot "check-publication-output.ps1") -RepositoryRoot $fixtureFull -ContentRoot $content -OutputRoot $output | Out-Null + } + catch { + $caughtStaleOutput = $true + } + if (-not $caughtStaleOutput) { + throw "Publication-output checker failed to reject stale generated HTML" + } + Remove-Item -LiteralPath $staleOutput -Force + $broken = Join-Path $content "Broken.md" Set-Content -LiteralPath $broken -Encoding utf8 -Value "# Broken`n`nSee [[No Such Note]]." $caughtBrokenLink = $false @@ -48,6 +73,22 @@ try { } Remove-Item -LiteralPath $brokenAnchor -Force + $outside = Join-Path $fixtureFull "Outside.md" + Set-Content -LiteralPath $outside -Encoding utf8 -Value "# Outside`n`nThis note is outside the selected publication root." + $brokenEscape = Join-Path $content "Broken Escape.md" + Set-Content -LiteralPath $brokenEscape -Encoding utf8 -Value "# Broken Escape`n`nSee [[../Outside]]." + $caughtEscapedLink = $false + try { + & (Join-Path $PSScriptRoot "check-wikilinks.ps1") -ContentRoot $content | Out-Null + } + catch { + $caughtEscapedLink = $true + } + if (-not $caughtEscapedLink) { + throw "Wikilink checker failed to reject a link outside the selected content root" + } + Remove-Item -LiteralPath $brokenEscape -Force + $forbidden = Join-Path $content "workshop" New-Item -ItemType Directory -Path $forbidden | Out-Null $caughtBoundary = $false @@ -61,7 +102,7 @@ try { throw "Publication checker failed to reject a nested workshop directory" } - Write-Output "Lore tool tests passed, including broken-link, broken-anchor, and publication-boundary negative checks." + Write-Output "Lore tool tests passed, including broken-link, broken-anchor, escaped-link, stale-output, and publication-boundary negative checks." } finally { $resolvedFixture = [System.IO.Path]::GetFullPath($fixtureFull) diff --git a/scripts/quartz/quartz.ps1 b/scripts/quartz/quartz.ps1 index 4edf607..1617f7d 100644 --- a/scripts/quartz/quartz.ps1 +++ b/scripts/quartz/quartz.ps1 @@ -34,18 +34,31 @@ if (-not (Test-Path $sharedQuartzRoot)) { } $buildScript = Join-Path $sharedQuartzRoot "scripts\build-site.mjs" +$outputRelative = "quartz-site/public" +$outputRoot = [System.IO.Path]::GetFullPath((Join-Path $repoRoot "quartz-site")) +$outputFull = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $outputRelative)) if (-not (Test-Path $buildScript)) { throw "GameCult-Quartz build script was not found at '$buildScript'." } +if (-not $outputFull.StartsWith($outputRoot + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Quartz output must remain below '$outputRoot'." +} + +# Quartz emitters replace the reader projection. Old author-vault pages must not +# survive merely because the next source tree is smaller. +if (Test-Path -LiteralPath $outputFull) { + Remove-Item -LiteralPath $outputFull -Recurse -Force +} + $scriptArgs = @( $buildScript, $Command, "--siteRoot", $repoRoot, "--overlayDir", "site", - "--contentDir", "Kalsa", - "--outputDir", "quartz-site/public" + "--contentDir", "Kalsa/Public", + "--outputDir", $outputRelative ) & $node @scriptArgs