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