diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e54752e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build --no-restore -c Release + + - name: Test + run: dotnet test --no-build -c Release --verbosity normal diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml new file mode 100644 index 0000000..2c6064f --- /dev/null +++ b/.github/workflows/deploy-pages.yml @@ -0,0 +1,32 @@ +name: Deploy to GitHub Pages + +on: + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Generate index.json + run: dotnet run scripts/GenerateIndex.cs -- src/BoneLog.Blazor/wwwroot/data/posts src/BoneLog.Blazor/wwwroot/data/index.json + + - name: Publish Blazor app + run: dotnet publish src/BoneLog.Blazor/BoneLog.Blazor.csproj -c Release -o release --nologo + + - name: Add .nojekyll + run: touch release/wwwroot/.nojekyll + + - name: Deploy to gh-pages branch + uses: JamesIves/github-pages-deploy-action@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + branch: gh-pages + folder: release/wwwroot + clean: true diff --git a/.github/workflows/full-build.yml b/.github/workflows/full-build.yml deleted file mode 100644 index 7a7d25f..0000000 --- a/.github/workflows/full-build.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Full Build and Deploy - -on: - push: - branches: [ master ] - paths: - - 'src/**' - - '!src/BoneLog.Blazor/wwwroot/**' - - '!src/BoneLog.Blazor/Automations/**' - workflow_dispatch: - -jobs: - build-and-deploy: - runs-on: ubuntu-latest - name: Full Build and Deploy - - steps: - - name: Checkout Repository - uses: actions/checkout@v4 - - - name: Setup .NET SDK - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 8.0.x - - - name: Publish Blazor Project - run: dotnet publish src/BoneLog.Blazor/BoneLog.Blazor.csproj -c Release -o release --nologo - - - name: Change base href in index.html - run: sed -i 's|||g' release/wwwroot/index.html - - - name: Change base href in 404.html - run: sed -i 's|||g' release/wwwroot/404.html - - - name: Add .nojekyll - run: touch release/wwwroot/.nojekyll - - - name: Deploy full build output - uses: JamesIves/github-pages-deploy-action@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - branch: gh-pages - folder: release/wwwroot diff --git a/.github/workflows/generate-index.yml b/.github/workflows/generate-index.yml new file mode 100644 index 0000000..bad748e --- /dev/null +++ b/.github/workflows/generate-index.yml @@ -0,0 +1,41 @@ +name: Generate Index + +on: + workflow_call: + inputs: + posts-dir: + description: Directory containing post markdown files + required: true + type: string + index-out: + description: Output path for index.json + required: true + type: string + commit-changes: + description: Commit index files back to the repository + required: false + type: boolean + default: false + +jobs: + generate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Generate index.json + run: dotnet run scripts/GenerateIndex.cs -- ${{ inputs.posts-dir }} ${{ inputs.index-out }} + + - name: Commit index files + if: inputs.commit-changes + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: chore: update index.json + file_pattern: | + src/BoneLog.Blazor/wwwroot/data/index.json + src/BoneLog.Blazor/wwwroot/data/index.manifest.json diff --git a/.github/workflows/index-on-main.yml b/.github/workflows/index-on-main.yml new file mode 100644 index 0000000..b8c1142 --- /dev/null +++ b/.github/workflows/index-on-main.yml @@ -0,0 +1,22 @@ +name: Update index on main + +on: + push: + branches: [master, main] + paths: + - 'src/BoneLog.Blazor/wwwroot/data/posts/**' + - 'scripts/**' + workflow_dispatch: + +permissions: + contents: write + +jobs: + index: + uses: ./.github/workflows/generate-index.yml + permissions: + contents: write + with: + posts-dir: src/BoneLog.Blazor/wwwroot/data/posts + index-out: src/BoneLog.Blazor/wwwroot/data/index.json + commit-changes: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a277140 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,48 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Generate index.json + run: dotnet run scripts/GenerateIndex.cs -- src/BoneLog.Blazor/wwwroot/data/posts src/BoneLog.Blazor/wwwroot/data/index.json + + - name: Publish Blazor app + run: dotnet publish src/BoneLog.Blazor/BoneLog.Blazor.csproj -c Release -o release --nologo + + - name: Add .nojekyll + run: touch release/wwwroot/.nojekyll + + - name: Create site archive + run: | + cd release/wwwroot + zip -r ../../bonelog-site.zip . + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: bonelog-site.zip + generate_release_notes: true + + - name: Deploy to gh-pages branch + uses: JamesIves/github-pages-deploy-action@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + branch: gh-pages + folder: release/wwwroot + clean: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 8a6421e..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Run Tests - -on: - push: - branches: - - '**' - pull_request: - branches: - - master - -jobs: - run-tests: - runs-on: ubuntu-latest - name: Run Tests - - steps: - - uses: actions/checkout@v4 - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 8.0.x - - - name: Restore dependencies - run: dotnet restore - - - name: Build - run: dotnet build --no-restore - - - name: Test - run: dotnet test --verbosity normal --configuration Release \ No newline at end of file diff --git a/.github/workflows/update-wwwroot.yml b/.github/workflows/update-wwwroot.yml deleted file mode 100644 index 62ca458..0000000 --- a/.github/workflows/update-wwwroot.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Update posts.json and Deploy wwwroot/data & wwwroot/images - -on: - push: - branches: [ master ] - paths: - - 'src/BoneLog.Blazor/wwwroot/data/**' - - 'src/BoneLog.Blazor/wwwroot/images/**' - - 'src/BoneLog.Blazor/Automations/**' - workflow_dispatch: - workflow_run: - workflows: ["Full Build and Deploy"] - types: - - completed - -jobs: - update-and-deploy: - if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Python - uses: actions/setup-python@v4 - with: - python-version: '3.x' - - - name: Install dependencies - run: pip install pyyaml - - - name: Run generate_index.py - run: python3 src/BoneLog.Blazor/Automations/generate_index.py - - - name: Copy only data and images to temp folder - run: | - mkdir -p deploy-folder/data - mkdir -p deploy-folder/images - cp -r src/BoneLog.Blazor/wwwroot/data/* deploy-folder/data/ - cp -r src/BoneLog.Blazor/wwwroot/images/* deploy-folder/images/ - - - name: Deploy only data and images - uses: JamesIves/github-pages-deploy-action@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - branch: gh-pages - folder: deploy-folder - clean: false diff --git a/BoneLog.sln b/BoneLog.sln index b69adf0..66adc20 100644 --- a/BoneLog.sln +++ b/BoneLog.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 +# Visual Studio Version 18 +VisualStudioVersion = 18.6.11806.211 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" EndProject @@ -11,6 +11,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{9C8CDFCA-7 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BoneLog.Tests", "test\BoneLog.Tests\BoneLog.Tests.csproj", "{ADC7FA48-B270-4B4F-8AC1-E1B885612536}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BoneLog", "src\BoneLog\BoneLog.csproj", "{339F10EE-430B-4B14-A2F0-582486225567}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -45,6 +47,18 @@ Global {ADC7FA48-B270-4B4F-8AC1-E1B885612536}.Release|x64.Build.0 = Release|Any CPU {ADC7FA48-B270-4B4F-8AC1-E1B885612536}.Release|x86.ActiveCfg = Release|Any CPU {ADC7FA48-B270-4B4F-8AC1-E1B885612536}.Release|x86.Build.0 = Release|Any CPU + {339F10EE-430B-4B14-A2F0-582486225567}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {339F10EE-430B-4B14-A2F0-582486225567}.Debug|Any CPU.Build.0 = Debug|Any CPU + {339F10EE-430B-4B14-A2F0-582486225567}.Debug|x64.ActiveCfg = Debug|Any CPU + {339F10EE-430B-4B14-A2F0-582486225567}.Debug|x64.Build.0 = Debug|Any CPU + {339F10EE-430B-4B14-A2F0-582486225567}.Debug|x86.ActiveCfg = Debug|Any CPU + {339F10EE-430B-4B14-A2F0-582486225567}.Debug|x86.Build.0 = Debug|Any CPU + {339F10EE-430B-4B14-A2F0-582486225567}.Release|Any CPU.ActiveCfg = Release|Any CPU + {339F10EE-430B-4B14-A2F0-582486225567}.Release|Any CPU.Build.0 = Release|Any CPU + {339F10EE-430B-4B14-A2F0-582486225567}.Release|x64.ActiveCfg = Release|Any CPU + {339F10EE-430B-4B14-A2F0-582486225567}.Release|x64.Build.0 = Release|Any CPU + {339F10EE-430B-4B14-A2F0-582486225567}.Release|x86.ActiveCfg = Release|Any CPU + {339F10EE-430B-4B14-A2F0-582486225567}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -52,6 +66,7 @@ Global GlobalSection(NestedProjects) = preSolution {05399B60-9B81-411D-ADBA-C23B0F37E7FA} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {ADC7FA48-B270-4B4F-8AC1-E1B885612536} = {9C8CDFCA-7503-4193-A32A-5AC7ED985175} + {339F10EE-430B-4B14-A2F0-582486225567} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {10272B7A-92FA-4BBD-A6B7-1E65BD5617B4} diff --git a/README.md b/README.md index e958ce4..a0b2781 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,50 @@ # BoneLOG -### Easy to use, Free for all + +**Live site:** [https://taqiam.github.io/BoneLog/](https://taqiam.github.io/BoneLog/) + +**Documentation:** [BoneLog docs (start here)](https://taqiam.github.io/BoneLog/post/Guide/Index) + --- ## What's this? -Wanna spin up a small blog on GitHub Pages real quick and post your stuff with zero hassle? -**BoneLOG** is made just for that. +A small **file-based blog** on **Blazor WebAssembly**, write Markdown, push to Git, deploy static files. No CMS, no database. -Just write your posts in Markdown, push them, and boom — they go live automatically. -No build tools, no CMS, no configs. Just files. +## Quick start -## Getting Started +1. [Fork](https://github.com/Taqiam/BoneLog/fork) this repo and enable **GitHub Actions**. +2. Add posts under [`src/BoneLog.Blazor/wwwroot/data/posts`](src/BoneLog.Blazor/wwwroot/data/posts). +3. Edit [`config.json`](src/BoneLog.Blazor/wwwroot/config.json), set `BaseDir` (e.g. `"/BoneLog/"`) and `BaseDataPath` (`"data/"`). +4. **Settings → Pages** → branch **`gh-pages`**, root **`/`**. +5. Deploy: **Actions → Deploy to GitHub Pages**, or push tag `v1.0.0`. -1. Fork this repo -2. Enable GitHub Actions in your fork -3. Add your Markdown posts to: - `/src/BoneLog.Blazor/wwwroot/data` -4. Enable GitHub Pages: - - Go to **Settings > Pages** - - Choose the `gh-pages` branch as source -5. (Optional) If you renamed the repo or use a custom domain: - - Edit the `base href` in `.github/workflows/full-build.yml`: - ```yaml - sed -i 's|||g' ... - ``` - - Or remove these lines if you're using a custom domain. +Push post changes → **Update index on main** refreshes `index.json`. Deploy again to update the live site. -> [!NOTE] -> posts.json is generated automatically after each push if GitHub Actions is enabled. +## Documentation posts -## Customization +| Topic | Link | +|-------|------| +| **Index** | [Documentation hub](https://taqiam.github.io/BoneLog/post/Guide/Index) | +| Quick start | [GitHub Pages](https://taqiam.github.io/BoneLog/post/Guide/Quick-Start) | +| Publishing | [Deploy & hosting](https://taqiam.github.io/BoneLog/post/Guide/Publishing) | +| Custom hosting | [Release zip & any host](https://taqiam.github.io/BoneLog/post/Guide/Full-Custom-Hosting) | +| Writing | [Posts & Markdown](https://taqiam.github.io/BoneLog/post/Guide/Writing-Posts) | +| Paths | [Paths & addresses](https://taqiam.github.io/BoneLog/post/Guide/Paths) | +| Search | [Filters](https://taqiam.github.io/BoneLog/post/Guide/Search-and-Filter) | +| Config | [config.json](https://taqiam.github.io/BoneLog/post/Guide/Configuration) | +| Workflows | [GitHub Actions](https://taqiam.github.io/BoneLog/post/Guide/Workflows) | +| Developers | [Architecture](https://taqiam.github.io/BoneLog/post/Guide/Developer-Overview) | -All data and content are inside: -[`/src/BoneLog.Blazor/wwwroot/data`](...) +## Customize -- To add blog posts → Put `.md` files inside `/data/posts` -- To create custom pages → Create folders/files like: - `/data/projects/project1.md` → will be accessible at `yourdomain.com/data/projects/project1` -- To change site settings (title, navbar, social links, etc.) → Edit `site-settings.json` +| Goal | Location | +|------|----------| +| Posts | `wwwroot/data/posts/` | +| About | `wwwroot/data/AboutMe.md` | +| Settings & nav | `wwwroot/config.json` | +| Images | `wwwroot/images/` | +| Styles | `wwwroot/css/app.css` | ## Contribute -Dev doors are always open! -Got a small idea or a feature that might help others too? -Open a PR and let’s make it happen. - -### A final note -Sure — there are tons of features you could add. -But honestly? This project is meant to stay small, file-based, and simple. - -Let’s not over-architect something that works beautifully in its simplicity. -That said, if you’ve built something cool and think it belongs here — -send a PR. I'd love to see it. +PRs welcome, keep small and clear. \ No newline at end of file diff --git a/global.json b/global.json index f2f4b02..9769786 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "8.0.410" + "version": "10.0.300" } } diff --git a/scripts/GenerateIndex.cs b/scripts/GenerateIndex.cs new file mode 100644 index 0000000..44f098f --- /dev/null +++ b/scripts/GenerateIndex.cs @@ -0,0 +1,345 @@ +#:package YamlDotNet@16.3.0 +#:property JsonSerializerIsReflectionEnabledByDefault=true + +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +#region Entry + +var options = CliOptions.Parse(args); +if (options is null) +{ + Console.Error.WriteLine("Usage: dotnet run GenerateIndex.cs -- [--full]"); + Console.Error.WriteLine(" Default: incremental update (hash-checked). Use --full to rebuild all posts."); + Environment.Exit(1); +} + +var rootPath = Path.GetFullPath(options.RootPath); +var indexOut = Path.GetFullPath(options.IndexPath); +var manifestOut = Path.ChangeExtension(indexOut, ".manifest.json"); + +if (!Directory.Exists(rootPath)) +{ + Console.Error.WriteLine($"Root path not found: {rootPath}"); + Environment.Exit(1); +} + +var result = options.Full + ? IndexBuilder.BuildFull(rootPath) + : await IndexBuilder.BuildIncrementalAsync(rootPath, indexOut, manifestOut); + +await JsonWriter.WriteAsync(indexOut, result.Posts); +await JsonWriter.WriteAsync(manifestOut, result.Manifest); + +Console.WriteLine( + options.Full + ? $"Full build: wrote {result.Posts.Count} posts -> {indexOut}" + : $"Updated {result.UpdatedCount}, kept {result.KeptCount}, removed {result.RemovedCount} -> {indexOut}"); + +#endregion + +#region Models + +sealed record CliOptions(string RootPath, string IndexPath, bool Full) +{ + public static CliOptions? Parse(string[] args) + { + var full = false; + var positional = new List(args.Length); + + foreach (var arg in args) + { + if (arg is "--full" or "-f") + full = true; + else if (arg is "--help" or "-h") + return null; + else + positional.Add(arg); + } + + if (positional.Count != 2) + return null; + + return new CliOptions(positional[0], positional[1], full); + } +} + +sealed class IndexManifest +{ + public Dictionary Hashes { get; set; } = new(StringComparer.OrdinalIgnoreCase); +} + +sealed class BuildResult +{ + public required List Posts { get; init; } + public required IndexManifest Manifest { get; init; } + public int UpdatedCount { get; init; } + public int KeptCount { get; init; } + public int RemovedCount { get; init; } +} + +sealed class PostIndex +{ + public string Title { get; set; } = ""; + public string Path { get; set; } = ""; + public string? Content { get; set; } + public string? ShortDescription { get; set; } + public string? Category { get; set; } + public string[]? Tags { get; set; } + public DateTime? Date { get; set; } + public string? Thumbnail { get; set; } + public string Language { get; set; } = "EN"; +} +sealed class PostFrontMatter +{ + public string? Title { get; set; } + public string? Date { get; set; } + public string[]? Tags { get; set; } + public string? Thumbnail { get; set; } + public string? ShortDescription { get; set; } + public string? Language { get; set; } +} + +#endregion + +#region IndexBuilder + +static class IndexBuilder +{ + static readonly Regex FrontMatter = new(@"^---\s*\n(.*?)\n---\s*\n(.*)$", RegexOptions.Singleline | RegexOptions.Compiled); + + static readonly IDeserializer Yaml = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + + public static BuildResult BuildFull(string rootPath) + { + var root = new DirectoryInfo(rootPath); + var posts = new List(); + var manifest = new IndexManifest(); + + foreach (var file in root.EnumerateFiles("*.md", SearchOption.AllDirectories)) + { + if (!TryParsePost(file, root, out var post)) + continue; + + posts.Add(post); + manifest.Hashes[post.Path] = HashHelper.ComputeFileHash(file.FullName); + } + + SortPosts(posts); + return new BuildResult + { + Posts = posts, + Manifest = manifest, + UpdatedCount = posts.Count, + KeptCount = 0, + RemovedCount = 0 + }; + } + + public static async Task BuildIncrementalAsync(string rootPath, string indexPath, string manifestPath) + { + if (!File.Exists(indexPath) || !File.Exists(manifestPath)) + return BuildFull(rootPath); + + var existingPosts = await JsonReader.ReadAsync(indexPath) ?? []; + var existingManifest = await JsonReader.ReadAsync(manifestPath) ?? new IndexManifest(); + + var postsByPath = existingPosts.ToDictionary(p => p.Path, StringComparer.OrdinalIgnoreCase); + var newHashes = new Dictionary(StringComparer.OrdinalIgnoreCase); + var root = new DirectoryInfo(rootPath); + + var updated = 0; + var kept = 0; + + foreach (var file in root.EnumerateFiles("*.md", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(root.FullName, file.FullName).Replace('\\', '/'); + var postPath = Path.ChangeExtension(relativePath, null)!; + var hash = HashHelper.ComputeFileHash(file.FullName); + newHashes[postPath] = hash; + + if (existingManifest.Hashes.TryGetValue(postPath, out var previousHash) && + string.Equals(previousHash, hash, StringComparison.OrdinalIgnoreCase) && + postsByPath.TryGetValue(postPath, out var existing)) + { + kept++; + continue; + } + + if (TryParsePost(file, root, out var post)) + { + postsByPath[postPath] = post; + updated++; + } + else + { + postsByPath.Remove(postPath); + } + } + + var removed = 0; + foreach (var stalePath in postsByPath.Keys.Except(newHashes.Keys, StringComparer.OrdinalIgnoreCase).ToList()) + { + postsByPath.Remove(stalePath); + removed++; + } + + var posts = postsByPath.Values.ToList(); + SortPosts(posts); + + return new BuildResult + { + Posts = posts, + Manifest = new IndexManifest { Hashes = newHashes }, + UpdatedCount = updated, + KeptCount = kept, + RemovedCount = removed + }; + } + + static void SortPosts(List posts) => posts.Sort(static (a, b) => + { + var cmp = Nullable.Compare(b.Date, a.Date); + return cmp != 0 ? cmp : string.Compare(a.Path, b.Path, StringComparison.OrdinalIgnoreCase); + }); + + static bool TryParsePost(FileInfo file, DirectoryInfo root, out PostIndex post) + { + post = new PostIndex(); + + string markdown; + try + { + markdown = File.ReadAllText(file.FullName, Encoding.UTF8); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Failed to read {file.Name}: {ex.Message}"); + return false; + } + + var relativePath = Path.GetRelativePath(root.FullName, file.FullName).Replace('\\', '/'); + var postPath = Path.ChangeExtension(relativePath, null)!; + + PostFrontMatter? header = null; + var match = FrontMatter.Match(markdown); + if (match.Success) + { + try + { + header = Yaml.Deserialize(match.Groups[1].Value.Trim()); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Invalid YAML in {relativePath}: {ex.Message}"); + } + } + else + { + Console.Error.WriteLine($"No front matter in {relativePath}"); + } + + post.Title = string.IsNullOrWhiteSpace(header?.Title) ? FolderNames.ToTitle(Path.GetFileNameWithoutExtension(file.Name)) : header.Title; + post.Path = postPath; + post.ShortDescription = header?.ShortDescription; + post.Tags = header?.Tags; + post.Thumbnail = header?.Thumbnail; + post.Category = CategoryPathFromPostPath(postPath); + post.Date = ParseDate(header?.Date); + post.Language = string.IsNullOrWhiteSpace(header?.Language) ? "EN" : header.Language.Trim(); + + return true; + } + + static DateTime? ParseDate(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + + return DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dt) ? dt : null; + } + + static string? CategoryPathFromPostPath(string postPath) + { + var parts = postPath.Replace('\\', '/').Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length <= 1) return null; + + var folderSegments = parts[..^1]; + return folderSegments.Length == 0 ? null : string.Join(" / ", folderSegments.Select(FolderNames.ToTitle)); + } +} + +#endregion + +#region Utilities + +static class HashHelper +{ + public static string ComputeFileHash(string filePath) + { + using var stream = File.OpenRead(filePath); + var hash = SHA256.HashData(stream); + return Convert.ToHexString(hash); + } +} + +static class FolderNames +{ + public static string ToTitle(string folderName) + { + if (string.IsNullOrWhiteSpace(folderName)) + return folderName; + + var words = folderName.Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (words.Length == 0) + return folderName; + + return string.Join(' ', words.Select(static w => + char.ToUpperInvariant(w[0]) + (w.Length > 1 ? w[1..].ToLowerInvariant() : ""))); + } +} + +static class JsonWriter +{ + static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public static async Task WriteAsync(string outputPath, T value) + { + var dir = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + + await using var stream = File.Create(outputPath); + await JsonSerializer.SerializeAsync(stream, value, Options); + } +} + +static class JsonReader +{ + static readonly JsonSerializerOptions Options = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public static async Task ReadAsync(string path) + { + await using var stream = File.OpenRead(path); + return await JsonSerializer.DeserializeAsync(stream, Options); + } +} + +#endregion diff --git a/scripts/generate-index.bat b/scripts/generate-index.bat new file mode 100644 index 0000000..f5fa574 --- /dev/null +++ b/scripts/generate-index.bat @@ -0,0 +1,15 @@ +@echo off +setlocal EnableExtensions + +set "SCRIPT_DIR=%~dp0" +set "REPO_ROOT=%SCRIPT_DIR%.." + +pushd "%REPO_ROOT%" +dotnet run "%SCRIPT_DIR%GenerateIndex.cs" -- ^ + "%REPO_ROOT%\src\BoneLog.Blazor\wwwroot\data\posts" ^ + "%REPO_ROOT%\src\BoneLog.Blazor\wwwroot\data\index.json" +set "EXIT_CODE=%ERRORLEVEL%" +popd + +if not "%EXIT_CODE%"=="0" pause +exit /b %EXIT_CODE% diff --git a/scripts/generate-index.sh b/scripts/generate-index.sh new file mode 100644 index 0000000..3c4fc77 --- /dev/null +++ b/scripts/generate-index.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +POSTS="$REPO_ROOT/src/BoneLog.Blazor/wwwroot/data/posts" +INDEX="$REPO_ROOT/src/BoneLog.Blazor/wwwroot/data/index.json" + +dotnet run "$SCRIPT_DIR/GenerateIndex.cs" -- "$POSTS" "$INDEX" diff --git a/src/BoneLog.Blazor/Automations/generate_index.py b/src/BoneLog.Blazor/Automations/generate_index.py deleted file mode 100644 index 452be8d..0000000 --- a/src/BoneLog.Blazor/Automations/generate_index.py +++ /dev/null @@ -1,53 +0,0 @@ -from dataclasses import dataclass -from typing import List, Optional -import os -import yaml -import json -import re - -@dataclass -class PostHeader: - title: str - date: str - tags: List[str] - thumbnail: Optional[str] = None - shortDescription: Optional[str] = None - filename: Optional[str] = None - -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -POSTS_PATH = os.path.join(BASE_DIR, '../wwwroot/data/posts') -OUTPUT_PATH = os.path.join(BASE_DIR, '../wwwroot/data/posts.json') - -all_posts = [] - -for filename in os.listdir(POSTS_PATH): - if not filename.endswith('.md'): - continue - - full_path = os.path.join(POSTS_PATH, filename) - with open(full_path, 'r', encoding='utf-8-sig') as f: - content = f.read() - - if content.lstrip().startswith('---'): - parts = content.split('---', 2) - if len(parts) >= 3: - yaml_text = parts[1].strip() - try: - data = yaml.safe_load(yaml_text) - data.pop('cover', None) - data['filename'] = os.path.splitext(filename)[0] - post = PostHeader(**data) - all_posts.append(post) - except Exception as e: - print(f"Error parsing YAML in {filename}: {e}") - else: - print(f"Invalid front matter format in {filename}") - else: - print(f"No front matter found in {filename}") - -os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True) - -with open(OUTPUT_PATH, 'w', encoding='utf-8') as f: - json.dump([post.__dict__ for post in all_posts], f, ensure_ascii=False, indent=2) - -print("posts.json generated.") diff --git a/src/BoneLog.Blazor/BoneLog.Blazor.csproj b/src/BoneLog.Blazor/BoneLog.Blazor.csproj index 2a00c2a..f1ba57e 100644 --- a/src/BoneLog.Blazor/BoneLog.Blazor.csproj +++ b/src/BoneLog.Blazor/BoneLog.Blazor.csproj @@ -1,18 +1,16 @@  - net8.0 + net10.0 enable enable - - - - - - + + + + @@ -20,4 +18,8 @@ + + + + diff --git a/src/BoneLog.Blazor/Components/Category/Category.razor b/src/BoneLog.Blazor/Components/Category/Category.razor new file mode 100644 index 0000000..2632c87 --- /dev/null +++ b/src/BoneLog.Blazor/Components/Category/Category.razor @@ -0,0 +1,22 @@ +@if (!string.IsNullOrEmpty(Name)) +{ + var segments = Name.Split(PathExtensions.CategorySeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + + + @for (var i = 0; i < segments.Length; i++) + { + if (i > 0) + { + + } + @segments[i] + } + + +} + +@code { + [Parameter] + public string? Name { get; set; } +} \ No newline at end of file diff --git a/src/BoneLog.Blazor/Components/Category/CategoryBox.razor b/src/BoneLog.Blazor/Components/Category/CategoryBox.razor new file mode 100644 index 0000000..aa7d175 --- /dev/null +++ b/src/BoneLog.Blazor/Components/Category/CategoryBox.razor @@ -0,0 +1,44 @@ +@inject NavigationManager Nav + + + +@code { + [Parameter] + public string? SelectedCategory { get; set; } + + [Parameter] + public IReadOnlyList Posts { get; set; } = []; + + private Models.Category[] categories = []; + + protected override void OnParametersSet() => categories = Posts.GetCategories(); + + private void NavigateToAllPosts() => Nav.NavigateTo(Nav.BaseUri); +} diff --git a/src/BoneLog.Blazor/Components/Category/CategoryTreeNode.razor b/src/BoneLog.Blazor/Components/Category/CategoryTreeNode.razor new file mode 100644 index 0000000..f247769 --- /dev/null +++ b/src/BoneLog.Blazor/Components/Category/CategoryTreeNode.razor @@ -0,0 +1,73 @@ +@{ + var hasChildren = Category.SubCategories is { Length: > 0 }; + var isActive = IsCategorySelected(Category.Title); +} + +
  • +
    + @if (hasChildren) + { + + } + else + { + + } + + + @Category.Title + @if (Category.NumberOfPosts > 0) + { + @Category.NumberOfPosts + } + +
    + + @if (hasChildren && isExpanded) + { +
      + @foreach (var sub in Category.SubCategories!) + { + + } +
    + } +
  • + +@code { + [Parameter, EditorRequired] + public BoneLog.Models.Category Category { get; set; } = default!; + + [Parameter] + public string? SelectedCategory { get; set; } + + private bool isExpanded; + + protected override void OnParametersSet() + { + if (Category.SubCategories is { Length: > 0 } subCategories + && ContainsSelected(subCategories)) + { + isExpanded = true; + } + } + + private void ToggleExpanded() => isExpanded = !isExpanded; + + private bool IsCategorySelected(string title) => + !string.IsNullOrEmpty(SelectedCategory) + && SelectedCategory.Equals(title, StringComparison.OrdinalIgnoreCase); + + private bool ContainsSelected(BoneLog.Models.Category[] nodes) => + nodes.Any(n => + IsCategorySelected(n.Title) + || (n.SubCategories is { Length: > 0 } subs && ContainsSelected(subs))); +} diff --git a/src/BoneLog.Blazor/Components/Language/Language.razor b/src/BoneLog.Blazor/Components/Language/Language.razor new file mode 100644 index 0000000..e9ca823 --- /dev/null +++ b/src/BoneLog.Blazor/Components/Language/Language.razor @@ -0,0 +1,15 @@ +@inject SiteConfig Config + +@if (Config.FeaturesOrDefault.EnableMultilanguage && !string.IsNullOrEmpty(Code)) +{ + + + @Code + + +} + +@code { + [Parameter] + public string? Code { get; set; } +} diff --git a/src/BoneLog.Blazor/Components/Language/LanguageBox.razor b/src/BoneLog.Blazor/Components/Language/LanguageBox.razor new file mode 100644 index 0000000..70cdbb4 --- /dev/null +++ b/src/BoneLog.Blazor/Components/Language/LanguageBox.razor @@ -0,0 +1,54 @@ +@inject NavigationManager Nav + + + +@code { + [Parameter] + public string? SelectedLanguage { get; set; } + + [Parameter] + public IReadOnlyList Posts { get; set; } = []; + + private PostIndexLanguageEntry[] languages = []; + + protected override void OnParametersSet() => languages = Posts.GetLanguages(); + + private void NavigateToAllPosts() => Nav.NavigateTo(Nav.BaseUri); + + private bool IsLanguageSelected(string language) => + !string.IsNullOrEmpty(SelectedLanguage) + && SelectedLanguage.Equals(language, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/BoneLog.Blazor/Components/Loading.razor b/src/BoneLog.Blazor/Components/Loading.razor index 6020860..4efd2c4 100644 --- a/src/BoneLog.Blazor/Components/Loading.razor +++ b/src/BoneLog.Blazor/Components/Loading.razor @@ -1,3 +1,3 @@ -
    +
    -
    \ No newline at end of file +
    diff --git a/src/BoneLog.Blazor/Components/MarkdownContent.razor b/src/BoneLog.Blazor/Components/MarkdownContent.razor new file mode 100644 index 0000000..a58a677 --- /dev/null +++ b/src/BoneLog.Blazor/Components/MarkdownContent.razor @@ -0,0 +1,37 @@ +@inject PathSettings PathSettings + +@if (!string.IsNullOrWhiteSpace(Content)) +{ +
    + @((MarkupString)Content) +
    +} + +@code { + [Inject] private IJSRuntime JS { get; set; } = default!; + + [Parameter] public string? Content { get; set; } + + [Parameter] public string? ContentPath { get; set; } + + [Parameter] public string ContentKind { get; set; } = "post"; + + [Parameter] public string? AdditionalCssClass { get; set; } + + private string PostsPrefix => + PathSettings.PostsPath.Trim().Trim('/'); + + private string CssClass => + string.IsNullOrWhiteSpace(AdditionalCssClass) + ? "markdown-content" + : $"markdown-content {AdditionalCssClass}"; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!string.IsNullOrWhiteSpace(Content)) + await JS.InvokeVoidAsync("boneLogMarkdown.render"); + } +} diff --git a/src/BoneLog.Blazor/Components/NotFound.razor b/src/BoneLog.Blazor/Components/NotFound.razor index 784266c..7a366fd 100644 --- a/src/BoneLog.Blazor/Components/NotFound.razor +++ b/src/BoneLog.Blazor/Components/NotFound.razor @@ -1,9 +1,11 @@ -
    -

    404

    -
    +

    404

    + - + Back to Home -
    \ No newline at end of file +
    diff --git a/src/BoneLog.Blazor/Components/PostPreview.razor b/src/BoneLog.Blazor/Components/PostPreview.razor index abef523..ac499c0 100644 --- a/src/BoneLog.Blazor/Components/PostPreview.razor +++ b/src/BoneLog.Blazor/Components/PostPreview.razor @@ -1,48 +1,59 @@ -
    - @if(ShowImage && !string.IsNullOrWhiteSpace(ThumbnailUrl)) +@inject SiteConfig Config + +
    + @if (ShowImage && !string.IsNullOrWhiteSpace(ThumbnailUrl)) { -
    +
    Post thumbnail + class="post-preview__image" />
    } -
    +
    -
    -
    - @if(Tags?.Any() == true) - { - @foreach(var tag in Tags) +
    + @if (!string.IsNullOrWhiteSpace(Category) || (Config.FeaturesOrDefault.EnableMultilanguage && !string.IsNullOrWhiteSpace(Language)) || Tags?.Length > 0) + { + - - @PublishDate.ToString("MMM dd, yyyy") - +
    + } + @if (PublishDate is not null) + { + + @PublishDate.Value.ToString("MMM dd, yyyy") + + }
    @@ -50,14 +61,18 @@ @code { [Parameter] public string Title { get; set; } = string.Empty; [Parameter] public string Description { get; set; } = string.Empty; - [Parameter] public string Url { get; set; } = "#"; + [Parameter] public string Path { get; set; } = "#"; [Parameter] public string? ThumbnailUrl { get; set; } - [Parameter] public List? Tags { get; set; } - [Parameter] public DateTime PublishDate { get; set; } = DateTime.UtcNow; + [Parameter] public string? Category { get; set; } + [Parameter] public string[]? Tags { get; set; } + [Parameter] public string? Language { get; set; } + [Parameter] public DateTime? PublishDate { get; set; } [Parameter] public bool ImageOnRight { get; set; } = true; private bool ShowImage = true; + private string PostHref => $"post/{Path.TrimStart('/')}"; + private void OnImageError() { ShowImage = false; diff --git a/src/BoneLog.Blazor/Components/SearchQueryLink.razor b/src/BoneLog.Blazor/Components/SearchQueryLink.razor new file mode 100644 index 0000000..96bf1c5 --- /dev/null +++ b/src/BoneLog.Blazor/Components/SearchQueryLink.razor @@ -0,0 +1,23 @@ +@inject NavigationManager Nav + + + @ChildContent + + +@code { + [Parameter, EditorRequired] + public string Query { get; set; } = ""; + + [Parameter] + public string? Class { get; set; } + + [Parameter] + public RenderFragment? ChildContent { get; set; } + + private string Href => $"{Nav.BaseUri}?q={Uri.EscapeDataString(Query)}"; + + private void Navigate() => Nav.NavigateTo(Href); +} diff --git a/src/BoneLog.Blazor/Components/Tag.razor b/src/BoneLog.Blazor/Components/Tag.razor deleted file mode 100644 index 50a04e1..0000000 --- a/src/BoneLog.Blazor/Components/Tag.razor +++ /dev/null @@ -1,13 +0,0 @@ -@if(!string.IsNullOrEmpty(TagName)) -{ - - - @TagName - - -} - -@code { - [Parameter] - public string? TagName { get; set; } -} \ No newline at end of file diff --git a/src/BoneLog.Blazor/Components/Tag/Tag.razor b/src/BoneLog.Blazor/Components/Tag/Tag.razor new file mode 100644 index 0000000..af70aab --- /dev/null +++ b/src/BoneLog.Blazor/Components/Tag/Tag.razor @@ -0,0 +1,13 @@ +@if (!string.IsNullOrEmpty(TagName)) +{ + + + @TagName + + +} + +@code { + [Parameter] + public string? TagName { get; set; } +} diff --git a/src/BoneLog.Blazor/Components/Tag/TagBox.razor b/src/BoneLog.Blazor/Components/Tag/TagBox.razor new file mode 100644 index 0000000..3f1e065 --- /dev/null +++ b/src/BoneLog.Blazor/Components/Tag/TagBox.razor @@ -0,0 +1,41 @@ + + +@code { + [Parameter] + public string? SelectedTag { get; set; } + + [Parameter] + public IReadOnlyList Posts { get; set; } = []; + + private PostIndexTagEntry[] tags = []; + + protected override void OnParametersSet() => + tags = Posts.GetTags(); + + private bool IsTagSelected(string tag) => + !string.IsNullOrEmpty(SelectedTag) + && SelectedTag.Equals(tag, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/BoneLog.Blazor/Components/ToggleThemeButton.razor b/src/BoneLog.Blazor/Components/ToggleThemeButton.razor index 9bdd816..0f6136f 100644 --- a/src/BoneLog.Blazor/Components/ToggleThemeButton.razor +++ b/src/BoneLog.Blazor/Components/ToggleThemeButton.razor @@ -1,17 +1,13 @@ @inject ThemeService ThemeService - @@ -23,4 +19,3 @@ } private async Task ToggleTheme() => await ThemeService.ToggleAsync(); } - diff --git a/src/BoneLog.Blazor/Dtos/AboutHeaderDto.cs b/src/BoneLog.Blazor/Dtos/AboutHeaderDto.cs deleted file mode 100644 index 9502aa9..0000000 --- a/src/BoneLog.Blazor/Dtos/AboutHeaderDto.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace BoneLog.Blazor.Dtos; - -public record class AboutHeaderDto -{ - public string Name { get; init; } = null!; - public string? Headline { get; init; } - public string? Avatar { get; init; } -} diff --git a/src/BoneLog.Blazor/Dtos/PostHeaderDto.cs b/src/BoneLog.Blazor/Dtos/PostHeaderDto.cs deleted file mode 100644 index 82d6594..0000000 --- a/src/BoneLog.Blazor/Dtos/PostHeaderDto.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace BoneLog.Blazor.Dtos; - -public record class PostHeaderDto -{ - public string Title { get; init; } = null!; - public string Date { get; init; } = null!; - public List? Tags { get; init; } - public string? Cover { get; init; } -} diff --git a/src/BoneLog.Blazor/Dtos/PostIndexDto.cs b/src/BoneLog.Blazor/Dtos/PostIndexDto.cs deleted file mode 100644 index 0811375..0000000 --- a/src/BoneLog.Blazor/Dtos/PostIndexDto.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace BoneLog.Blazor.Dtos; - -public record PostIndexDto(string Title,string FileName,DateTime Date,string[] Tags,string ShortDescription,string Thumbnail); - diff --git a/src/BoneLog.Blazor/Dtos/SiteSettingsDto.cs b/src/BoneLog.Blazor/Dtos/SiteSettingsDto.cs deleted file mode 100644 index 0133dc6..0000000 --- a/src/BoneLog.Blazor/Dtos/SiteSettingsDto.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace BoneLog.Blazor.Dtos; - - -public record NavItemDto(string Title,string Url); -public record SocialLinkDto(string Url,string IconClass); -public record SiteSettingsDto(string Title,List? NavItems,List? SocialLinks); diff --git a/src/BoneLog.Blazor/Layout/Footer.razor b/src/BoneLog.Blazor/Layout/Footer.razor index 5f88174..69c2aa4 100644 --- a/src/BoneLog.Blazor/Layout/Footer.razor +++ b/src/BoneLog.Blazor/Layout/Footer.razor @@ -1,18 +1,15 @@ -@using BoneLog.Blazor.Components -@using BoneLog.Blazor.Dtos -@using System.Text.Json +@using BoneLog.Blazor.Dtos @inject HttpClient Http -@inject SiteSettingsService SiteSettings +@inject SiteConfig Config -