diff --git a/README.md b/README.md index e7814a3..44000a8 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ dotnet build -c Release # Beispiel-Seite erzeugen und bauen dotnet bin/Release/net10.0/webstatic.dll init mysite cd mysite -dotnet ../../bin/Release/net10.0/webstatic.dll build --env work +dotnet ../../bin/Release/net10.0/webstatic.dll build --env development # Lokal ansehen dotnet ../../bin/Release/net10.0/webstatic.dll serve --port 8000 @@ -30,14 +30,13 @@ dotnet ../../bin/Release/net10.0/webstatic.dll serve --port 8000 ## Umgebungen -| `--env` | baseUrl (Beispiel) | Zweck | -|----------|------------------------------|------------------------| -| `work` | `http://localhost:8000` | Lokale Entwicklung | -| `review` | `https://review.gitcover.org`| Freigabe/Vorschau | -| `staging`| `https://gcbok.gitcover.org` | Abnahme (brx5) | -| `deploy` | `https://gcbok.org` | Production | +| `--env` | baseUrl (Beispiel) | Zweck | +|--------------|------------------------------|------------------------| +| `development`| `http://localhost:8000` | Lokale Entwicklung (work/preview/review) | +| `staging` | `https://gcbok.gitcover.org` | Abnahme (brx5) | +| `deploy` | `https://gcbok.org` | Production | -Aliase: `--preview` = `work`, `--staging` = `staging`, `--deploy` = `deploy`. +Aliase: `--preview` / `--work` / `--review` = `development`, `--staging` = `staging`, `--deploy` = `deploy`. ## Konfiguration (`webstatic.json`) diff --git a/docs/getting-started.md b/docs/getting-started.md index 62e216a..034e85e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -13,9 +13,12 @@ JavaScript-Framework. ```bash git clone build_webstatic cd build_webstatic -./install.sh # installiert nach /opt/GitCover/webstatic +./install.sh # klont nach /opt/GitCover/webstatic (Default: Clone-Modus) ``` -Nach der Installation ist `webstatic` im PATH verfügbar. +Der Clone-Modus legt ein echtes Git-Repo in `/opt/GitCover/webstatic` an, sodass +spätere Updates via `git pull` (mit `git stash`/`git stash pop` für lokale +Anpassungen) möglich sind. Alternativ `WEBSTATIC_INSTALL_MODE=copy ./install.sh` +für eine reine Binary-Kopie. Nach der Installation ist `webstatic` im PATH verfügbar. ## 3. Neue Website anlegen ```bash @@ -49,19 +52,20 @@ Ihr Inhalt … ``` - `order` steuert die Reihenfolge in Navigation und Sidebar. -- `order: 0` = Banner, der auf der Startseite (order 1) eingebettet wird. -- Sprachvarianten: `02-start.en.md`, `02-start.vi.md` (Fallback auf Basisdatei). +- `order: 0` = Banner (eigene Seite, getrennt vom Hero/`order: 1`). +- Sprachvarianten: `02-start.de.md` (Default), `02-start.en.md`, `02-start.vi.md` + (fehlende Sprache fällt auf `.de.md` zurück). ## 5. Bauen & Vorschau ```bash -webstatic build --env work # localhost:8000 -webstatic serve --port 8000 # lokaler Server (Strg+C beendet) +webstatic build --env development # localhost:8000 +webstatic serve --port 8000 # lokaler Server (Strg+C beendet) ``` ## 6. Veröffentlichen | Ziel | Befehl | |------|--------| -| Review | `webstatic build --env review` | +| Development (lokal, work/preview/review) | `webstatic build --env development` | | Staging | `webstatic build --env staging` | | Production | `webstatic build --env deploy` | diff --git a/install.sh b/install.sh old mode 100644 new mode 100755 index a278db3..9a076ac --- a/install.sh +++ b/install.sh @@ -1,14 +1,25 @@ #!/usr/bin/env bash -# GCBoK.WebStaticBuilder — KMU-Installationsskript +# GCBoK.WebStaticBuilder — Installationsskript # Installiert das Tool nach /opt/GitCover/webstatic und legt einen Symlink # `webstatic` nach /usr/local/bin. Idempotent. +# +# Modi (WEBSTATIC_INSTALL_MODE): +# clone (Default) — klont das Quell-Repo inkl. .git nach /opt/GitCover/webstatic. +# Ermoeglicht spaeter `git pull` + lokale Anpassungen stashen/mergen +# (Hermes-Update-Konzept). Das Ziel ist ein echtes Git-Repo. +# copy — kopiert nur das gebaute Binary + Beispiel + Docs (kein .git). +# +# Hinweis: /opt ist root-owned. Das Script sinnvollerweise als root ausfuehren, +# z.B. `sudo env PATH="$PATH" ./install.sh`, damit `dotnet` im PATH bleibt. set -euo pipefail +INSTALL_MODE="${WEBSTATIC_INSTALL_MODE:-clone}" INSTALL_DIR="${WEBSTATIC_INSTALL_DIR:-/opt/GitCover/webstatic}" BIN_LINK="/usr/local/bin/webstatic" REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" echo "== GCBoK.WebStaticBuilder Installer ==" +echo "Modus: $INSTALL_MODE" echo "Installationsziel: $INSTALL_DIR" # 1) .NET 10 pruefen @@ -17,34 +28,61 @@ if ! command -v dotnet >/dev/null 2>&1; then echo "Installiere .NET 10 von https://dotnet.microsoft.com/download" >&2 exit 1 fi -DOTNET_VER="$(dotnet --version 2>/dev/null || echo 0)" -echo "Gefunden: dotnet $DOTNET_VER" +echo "Gefunden: dotnet $(dotnet --version 2>/dev/null || echo unbekannt)" -# 2) Projekt bauen (Release) +# 2) Quelle beschaffen +if [ "$INSTALL_MODE" = "clone" ]; then + echo "== Clone-Modus: bereite $INSTALL_DIR vor ==" + if [ -d "$INSTALL_DIR/.git" ]; then + echo "Bereits ein Git-Repo — ueberspringe Clone (git pull ist beim Update noetig)." + else + if [ -e "$INSTALL_DIR" ]; then + echo "FEHLER: $INSTALL_DIR existiert, ist aber kein Git-Repo." >&2 + echo "Entferne oder verschiebe es manuell, dann erneut ausfuehren." >&2 + exit 1 + fi + # Lokaler Klon (offline-sicher); Remote wird auf das echte Upstream gesetzt. + git clone "$REPO_DIR" "$INSTALL_DIR" + SRC_REMOTE="$(git -C "$REPO_DIR" remote get-url origin 2>/dev/null || true)" + if [ -n "$SRC_REMOTE" ]; then + git -C "$INSTALL_DIR" remote set-url origin "$SRC_REMOTE" 2>/dev/null || true + echo "Remote 'origin' -> $SRC_REMOTE" + fi + fi + BUILD_PROJECT="$INSTALL_DIR/GCBoK.WebStaticBuilder.csproj" + DLL="$INSTALL_DIR/bin/Release/net10.0/webstatic.dll" +else + echo "== Copy-Modus: baue Release aus $REPO_DIR ==" + dotnet build "$REPO_DIR/GCBoK.WebStaticBuilder.csproj" -c Release -v minimal + echo "== Kopiere nach $INSTALL_DIR ==" + mkdir -p "$INSTALL_DIR" + rm -rf "$INSTALL_DIR/bin" "$INSTALL_DIR/webstatic.example" 2>/dev/null || true + cp -r "$REPO_DIR/bin/Release/net10.0/." "$INSTALL_DIR/" + cp -r "$REPO_DIR/webstatic.example" "$INSTALL_DIR/webstatic.example" 2>/dev/null || true + for f in LICENSE README.md docs plans AGENTS.md MEMORY.md SKILLS.md; do + [ -e "$REPO_DIR/$f" ] && cp -r "$REPO_DIR/$f" "$INSTALL_DIR/" 2>/dev/null || true + done + BUILD_PROJECT="$REPO_DIR/GCBoK.WebStaticBuilder.csproj" + DLL="$INSTALL_DIR/webstatic.dll" +fi + +# 3) Bauen (im Installationsziel bei Clone, sonst im Repo) echo "== Baue Release ==" -dotnet build "$REPO_DIR/GCBoK.WebStaticBuilder.csproj" -c Release -v minimal - -# 3) Nach /opt kopieren -echo "== Kopiere nach $INSTALL_DIR ==" -mkdir -p "$INSTALL_DIR" -rm -rf "$INSTALL_DIR/bin" "$INSTALL_DIR/webstatic.example" 2>/dev/null || true -cp -r "$REPO_DIR/bin/Release/net10.0/." "$INSTALL_DIR/" -cp -r "$REPO_DIR/webstatic.example" "$INSTALL_DIR/webstatic.example" 2>/dev/null || true -# Hilfsdateien auf oberster Ebene mitnehmen (LICENSE, README, docs) -for f in LICENSE README.md docs plans AGENTS.md MEMORY.md SKILLS.md; do - [ -e "$REPO_DIR/$f" ] && cp -r "$REPO_DIR/$f" "$INSTALL_DIR/" 2>/dev/null || true -done +dotnet build "$BUILD_PROJECT" -c Release -v minimal # 4) Symlink -echo "== Verknuepfe $BIN_LINK -> $INSTALL_DIR/webstatic.dll ==" +echo "== Verknuepfe $BIN_LINK -> $DLL ==" if [ -w "$(dirname "$BIN_LINK")" ]; then - ln -sf "$INSTALL_DIR/webstatic.dll" "$BIN_LINK" + ln -sf "$DLL" "$BIN_LINK" else echo "Hinweis: $BIN_LINK benoetigt Schreibrechte; erstelle Link via sudo." - sudo ln -sf "$INSTALL_DIR/webstatic.dll" "$BIN_LINK" + sudo ln -sf "$DLL" "$BIN_LINK" fi echo "" echo "Fertig. Danach verfuegbar ueber: webstatic --help" -echo "Beispiel-Seite erzeugen: webstatic init mysite && cd mysite && webstatic build --env work" -echo "Umgebungsvariable (optional): WEBSTATIC_BUILDER=$INSTALL_DIR/webstatic.dll" +echo "Beispiel-Seite erzeugen: webstatic init mysite && cd mysite && webstatic build --env development" +echo "GCBoK bauen: webstatic build --root www --env development" +echo "Umgebungsvariable (optional): WEBSTATIC_BUILDER=$DLL" +echo "" +echo "Update (Clone-Modus): cd $INSTALL_DIR && git stash && git pull && git stash pop && dotnet build -c Release" diff --git a/src/Builder.cs b/src/Builder.cs index 1fee756..b8c2877 100644 --- a/src/Builder.cs +++ b/src/Builder.cs @@ -36,7 +36,20 @@ public static class Builder Directory.CreateDirectory(Directories.DistDir); CopyAssets(verbose); - var langLinksHtml = BuildLangLinksHtml(); + // Precompute, for every page, its slug per language (keyed by order|section), + // so the language switcher can link to the equivalent page in each language + // even when slugs differ between translations. + var pageSlugs = new Dictionary>(); + foreach (var l in cfg.LanguageCodes()) + { + foreach (var p in ContentLoader.LoadContent(Directories.ContentDir, l)) + { + var key = $"{p.Meta.Order ?? -1}|{p.Meta.Section ?? ""}"; + if (!pageSlugs.TryGetValue(key, out var m)) + pageSlugs[key] = m = new Dictionary(); + m[l] = p.Href; + } + } foreach (var lang in cfg.LanguageCodes()) { @@ -90,6 +103,17 @@ public static class Builder var sidebarTocHtml = BuildSidebarTocHtml(page.Content); var pageNavHtml = (page.Meta.Order == 1) ? "" : BuildPageNavHtml(contents, page, lang); + var introHtml = string.IsNullOrEmpty(page.Meta.Intro) + ? "" : MarkdownProcessor.RenderMarkdown(page.Meta.Intro); + var positioningHtml = string.IsNullOrEmpty(page.Meta.Positioning) + ? "" : MarkdownProcessor.RenderMarkdown(page.Meta.Positioning); + var publisherHtml = string.IsNullOrEmpty(page.Meta.Publisher) + ? "" : MarkdownProcessor.RenderMarkdown(page.Meta.Publisher); + + var langKey = $"{page.Meta.Order ?? -1}|{page.Meta.Section ?? ""}"; + var langLinksHtml = BuildLangLinksHtml( + pageSlugs.TryGetValue(langKey, out var m) ? m : null); + var pageHtml = layout .Replace("{{seo_head}}", seoHead).Replace("{{ seo_head }}", seoHead) .Replace("{{ json_ld }}", "").Replace("{{json_ld}}", "") @@ -102,6 +126,9 @@ public static class Builder .Replace("{{lang_links}}", langLinksHtml).Replace("{{ lang_links }}", langLinksHtml) .Replace("{{glossary_href}}", GlossaryHref(lang)).Replace("{{ glossary_href }}", GlossaryHref(lang)) .Replace("{{glossary_label}}", GlossaryLabel(lang)).Replace("{{ glossary_label }}", GlossaryLabel(lang)) + .Replace("{{intro}}", introHtml).Replace("{{ intro }}", introHtml) + .Replace("{{positioning}}", positioningHtml).Replace("{{ positioning }}", positioningHtml) + .Replace("{{publisher}}", publisherHtml).Replace("{{ publisher }}", publisherHtml) .Replace("{{content}}", fullBody).Replace("{{ content }}", fullBody) .Replace("{{body}}", fullBody).Replace("{{ body }}", fullBody); @@ -188,7 +215,13 @@ public static class Builder File.AppendAllText(Directories.BuildLogPath, $" Assets copied to: {dstDir}\n"); } - private static string BuildLangLinksHtml() + /// + /// Build the language-switch dropdown for the current page. + /// maps each language code to the current page's slug in that language (resolved by + /// shared identity — order + section — so each language links to its own equivalent + /// page). When null, all links point to the language root. + /// + private static string BuildLangLinksHtml(Dictionary? langSlug) { var sb = new StringBuilder(); foreach (var kv in Directories.Config.Languages) @@ -197,7 +230,10 @@ public static class Builder var li = kv.Value; var label = li.Label ?? li.Name; var code = (li.HrefLang ?? lang).ToUpperInvariant(); - sb.AppendLine($"
  • {HtmlEncode(label)} {code}
  • "); + string slug = ""; + if (langSlug != null && langSlug.TryGetValue(lang, out var h)) slug = h.Trim('/'); + var href = string.IsNullOrEmpty(slug) ? $"/{lang}/" : $"/{lang}/{slug}/"; + sb.AppendLine($"
  • {HtmlEncode(label)} {code}
  • "); } return sb.ToString().Trim(); } @@ -227,7 +263,7 @@ public static class Builder .OrderBy(p => p.Meta.Order ?? 999).ToList(); var heroPage = contents.FirstOrDefault(p => p.Meta.Order == 1); if (heroPage != null) - sb.AppendLine("
  • Startseite
  • "); + sb.AppendLine($"
  • {I18n.Get(lang, "nav.home")}
  • "); foreach (var page in navPages) { @@ -285,7 +321,7 @@ public static class Builder var prevLabel = (prev.Meta.Eyebrow ?? prev.Meta.LocalizedTitle(lang, prev.Meta.Title ?? "")); if (prevLabel.Contains(" — ")) prevLabel = prevLabel.Substring(prevLabel.IndexOf(" — ") + 3); sb.AppendLine($" "); - sb.AppendLine($" ← Vorherige"); + sb.AppendLine($" {I18n.Get(lang, "nav.prev")}"); sb.AppendLine($" {HtmlEncode(prevLabel)}"); sb.AppendLine(" "); } @@ -297,7 +333,7 @@ public static class Builder var nextLabel = (next.Meta.Eyebrow ?? next.Meta.LocalizedTitle(lang, next.Meta.Title ?? "")); if (nextLabel.Contains(" — ")) nextLabel = nextLabel.Substring(nextLabel.IndexOf(" — ") + 3); sb.AppendLine($" "); - sb.AppendLine($" Nächste →"); + sb.AppendLine($" {I18n.Get(lang, "nav.next")}"); sb.AppendLine($" {HtmlEncode(nextLabel)}"); sb.AppendLine(" "); } @@ -324,7 +360,6 @@ public static class Builder var legalDir = Directories.LegalDir; if (!Directory.Exists(legalDir)) return; var layout = LoadLayout(); - var langLinksHtml = BuildLangLinksHtml(); foreach (var lang in cfg.LanguageCodes()) { @@ -358,6 +393,9 @@ public static class Builder var seoHead = SeoHelper.RenderSeoHead(lang, title, "", $"{env.BaseUrl}{canonical}", env.Cdn, defaultTitle, defaultDesc); var sidebarTocHtml = BuildSidebarTocHtml(content); + var legalSlug = $"/{Path.GetFileNameWithoutExtension(baseName)}/"; + var legalLangSlug = cfg.LanguageCodes().ToDictionary(l => l, l => legalSlug); + var langLinksHtml = BuildLangLinksHtml(legalLangSlug); var pageHtml = layout .Replace("{{seo_head}}", seoHead).Replace("{{ seo_head }}", seoHead) diff --git a/src/ContentLoader.cs b/src/ContentLoader.cs index f654d16..a07b6fc 100644 --- a/src/ContentLoader.cs +++ b/src/ContentLoader.cs @@ -7,49 +7,58 @@ namespace GCBoK.WebStaticBuilder; /// Content loading and YAML frontmatter parsing. public static class ContentLoader { + /// + /// Load all content files for a language. Files are grouped by their base name + /// (language suffix stripped). The requested language is preferred; if it is + /// missing, the German (.de.md) variant is used as fallback, and finally + /// an un-suffixed default. This makes German the canonical default content + /// source, derived explicitly from .de.md rather than an ambiguous + /// suffixless file. + /// public static List LoadContent(string contentDir, string lang) { - var results = new List(); + var byBase = new Dictionary>(); foreach (var filepath in Directory.GetFiles(contentDir, "*.md", SearchOption.TopDirectoryOnly)) { var nameWithoutExt = Path.GetFileNameWithoutExtension(filepath); string? fileLang = null; - if (nameWithoutExt.Contains(".")) + if (nameWithoutExt.Contains('.')) { - var parts = nameWithoutExt.Split('.'); - if (parts.Length >= 2) - { - var lastPart = parts[^1].ToLowerInvariant(); - if (lastPart == "de" || lastPart == "en" || lastPart == "vi") - { - fileLang = lastPart; - if (fileLang != lang) continue; - } - else - { - continue; - } - } + var lastPart = nameWithoutExt.Split('.')[^1].ToLowerInvariant(); + if (lastPart is "de" or "en" or "vi") + fileLang = lastPart; + else + continue; } - var text = File.ReadAllText(filepath); + var baseName = fileLang == null + ? nameWithoutExt + : nameWithoutExt.Substring(0, nameWithoutExt.Length - fileLang.Length - 1); + + if (!byBase.TryGetValue(baseName, out var map)) + byBase[baseName] = map = new Dictionary(); + map[fileLang ?? ""] = filepath; + } + + var results = new List(); + foreach (var map in byBase.Values) + { + string? chosen = null; + if (map.TryGetValue(lang, out var langPath)) chosen = langPath; + else if (map.TryGetValue("de", out var dePath)) chosen = dePath; + else if (map.TryGetValue("", out var defPath)) chosen = defPath; + if (chosen == null) continue; + + var text = File.ReadAllText(chosen); var (meta, content) = ParseFrontmatter(text); var langs = meta.Langs ?? ""; if (!string.IsNullOrEmpty(langs) && !langs.Split(',').Select(l => l.Trim()).Contains(lang)) continue; - if (fileLang == null && lang != "de") - { - var baseName = nameWithoutExt; - var langSpecificPath = Path.Combine(contentDir, $"{baseName}.{lang}.md"); - if (File.Exists(langSpecificPath)) - continue; - } - - results.Add(new PageInfo(filepath, meta, GetHref(meta), content)); + results.Add(new PageInfo(chosen, meta, GetHref(meta), content)); } return results.OrderBy(x => x.Meta.Order ?? 999).ToList(); diff --git a/src/Program.cs b/src/Program.cs index 3189b5c..7140057 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -73,10 +73,11 @@ if (argsList.Contains("init")) } // Build -var envName = "work"; -if (Has("--preview")) envName = "work"; -if (Has("--work")) envName = "work"; -if (Has("--review")) envName = "review"; +// "development" folds work, preview and review into a single local/dev environment. +var envName = "development"; +if (Has("--preview")) envName = "development"; +if (Has("--work")) envName = "development"; +if (Has("--review")) envName = "development"; if (Has("--staging")) envName = "staging"; if (Has("--deploy")) envName = "deploy"; if (Val("--env") is { } ev) envName = ev; diff --git a/webstatic.example/webstatic.json b/webstatic.example/webstatic.json index 08654b1..14f56d5 100644 --- a/webstatic.example/webstatic.json +++ b/webstatic.example/webstatic.json @@ -30,8 +30,7 @@ "pagefind": true, "translator": { "provider": "azure", "key_env": "WEBSTATIC_TRANSLATOR_KEY" }, "environments": { - "work": { "base_url": "http://localhost:8000", "cdn": "/assets" }, - "review": { "base_url": "https://review.gitcover.org", "cdn": "https://gitcover.org/assets" }, + "development": { "base_url": "http://localhost:8000", "cdn": "/assets" }, "staging": { "base_url": "https://gcbok.gitcover.org", "cdn": "https://gitcover.org/assets" }, "deploy": { "base_url": "https://gcbok.org", "cdn": "https://gitcover.org/assets" } }