一个会说 C# 的静态网站生成器:Statiq 入门终极指南

1. 简介

多年来,静态网站生成器(SSG)的世界一直被 .NET 之外的生态系统所主导。我们只能在场外看着 JavaScript 开发者享受 Gatsby 和 Next.js,Go 开发者拥抱 Hugo。对于 .NET 开发者来说,选择往往很有限、过时,或者需要离开我们舒适的 C# 环境去与不熟悉的构建链搏斗。

Statiq 应运而生。

Statiq 不仅仅是另一个静态网站生成器;它是一个专为 .NET 生态系统设计的强大、灵活的框架。它允许你使用你已经了解并喜爱的语言、库和模式——Razor、Markdown 和 C#——来构建极其快速的静态网站。无论你是在构建一个简单的博客、文档网站,还是复杂的企业 Web 应用程序,Statiq 都能将 .NET 的稳健性带入 JAMstack。

在本指南中,我将详细介绍 Statiq 到底是什么,为什么它是 .NET 开发者的游戏规则改变者,以及你如何在几分钟内启动并运行你的第一个网站。

在最后,我将为你提供一个包含以下完整功能的完整网站和主题!

  • 搜索功能
  • 像博客系统一样在网站中使用标签
  • 添加 Google AdSense 和 Analytics 服务
  • 归档页面
  • 分类的列表页面

2. 为什么我们需要生成静态网站?

也许你会想,我们为什么要这样做?我们可以轻松地使用 WordPress 创建一个漂亮的网站。是的,你可以!但是要托管一个 WordPress 网站,你需要处理以下问题:

  • 主机需要支持 PHP(我认为这可能不是问题)
  • 性能问题(带有大量的插件)
  • 安全问题。所有动态网站都有被黑客攻击的风险,因为它们可能存在一些未知的漏洞,或者你可能没有及时更新它们。(我的 WP 网站以前就被黑过 🥲)

然而相反的是,你会发现使用静态网站有以下好处:

  • 你可以使用只支持 HTML 的非常便宜的主机
  • 你可以获得非常好的性能
  • 你的网站会非常安全,因为静态网站完全没有漏洞,如果你的主机只支持 HTML,那么对方将很难进行注入和其他攻击(这就是我喜欢的 😀)
  • 你可以轻松地将任何 HTML 模板转换为你的主题
  • 纯 HTML 静态网站对 SEO 非常友好

3. 什么是 Statiq?

它的核心在于,Statiq 是建立在现代 .NET 平台上的一个强大、灵活的静态生成框架。如果你来自 JavaScript(Gatsby、Next.js)或 Go(Hugo)的世界,你可以把 Statiq 看作是 .NET 中的同类产品,但在架构上有重大的不同。

虽然大多数静态网站生成器(SSG)是通过 YAML 或 TOML 文件配置的“工具”,但 Statiq 被设计为一个“框架”。这意味着你不仅仅是在“配置”你的网站;你是在“编程”它。

哲学:管道和模块

Statiq 围绕着文档(documents)、管道(pipelines)和模块(modules)这一独特的概念运作:

  • 万物皆文档:在 Statiq 中,“文档”不仅仅是一个 Markdown 文件。它可以是一张图片、一个数据文件、一个 Razor 模板,甚至是一个 API 响应。
  • 管道:你定义管道来处理这些文档。管道本质上就是一系列步骤的组合。
  • 模块:代表管道中的单个步骤。例如,你可能有一个用来 ReadFiles(读取文件)的模块,另一个用来 RenderMarkdown(渲染 Markdown)的模块,以及最后一个用来 WriteFiles(写入文件)的模块。

这种架构让你能够对内容的获取、转换和生成方式进行精细控制。

为什么选择 Statiq?

  • .NET 生态系统:你可以利用 C# 和 NuGet 包的全部力量。如果你需要在构建过程中从数据库获取数据或调用自定义 API,你只需编写 C# 代码即可完成。
  • Razor & Markdown:它拥有一流的 Razor(ASP.NET Core 中使用的视图引擎)和 Markdown 支持,允许你在用纯文本编写内容的同时,构建带有逻辑的复杂布局。
  • Statiq.Web:虽然核心框架是通用的,但 Statiq.Web 是建立在其之上的专用库。它预配置了用于资产、内容和模板的通用管道,让你只需最少的代码就能启动并运行一个标准网站。

Statiq 中的工作流

简而言之,Statiq 弥合了静态网站的简单性与 .NET 应用程序的动态能力之间的差距。

4. 创建并支持多主题

Statiq 非常易于使用,你可以按照官方指南在此处创建你的第一个应用。我将向你展示更多关于如何创建自己的主题以及使用 Statiq 构建完整网站的细节!😁

4.1 多主题

默认情况下,Statiq 官方指南仅提供如下简单且单一的主题结构。

但我想支持多个主题,并且能在 appsettings.json 中轻松使用它们。

为了支持多主题,我们需要创建一个 themes 文件夹,并在其中创建一个具体的主题文件夹,将模板页面放入该主题的 input 文件夹中(实际上,这不是必须的;你也可以将页面放在主题根目录中)。

在项目根目录下创建包含以下内容的 appsettings.json

{
    "Theme": "themes/editorial"
}

在创建 Boststrapper 时,于 Program.cs 中读取配置:

return await Bootstrapper
              .Factory
              .CreateWeb(args)
              .ConfigureSettings(settings =>
              {
                  if (!settings.ContainsKey("Theme"))
                  {
                      settings["Theme"] = "themes/default";
                  }
              })

并在 Program.cs 的 ConfigureFileSystem 中处理主题模板文件:

.ConfigureFileSystem((fileSystem, settings) =>
 {
     var themePath = settings.GetString("Theme");
     if (!string.IsNullOrEmpty(themePath))
     {
         // Create a validated path from the setting
         var themePathNormalized = new NormalizedPath(themePath);

         // Ensure the path is absolute. If it's relative, combine it with the root path.
         if (themePathNormalized.IsRelative)
         {
             themePathNormalized = fileSystem.RootPath.Combine(themePathNormalized);
         }

         // Check if the theme has an 'input' folder (common convention)
         // If it does, strictly use that as the input path instead of the theme root
         var themeInputPath = themePathNormalized.Combine("input");

         if (fileSystem.GetDirectory(themeInputPath).Exists)
         {
             fileSystem.InputPaths.Add(themeInputPath);
         }
         else
         {
             fileSystem.InputPaths.Add(themePathNormalized);
         }
     }
 })

就是这样,然后我们就可以随时切换主题了。

4.2 创建主题布局

好的,接下来,我将基于 HTML 5UP 的一个免费 HTML 模板来创建一个 Statiq 主题。

我们可以先看一看这个 HTML 模板。

根据这个布局,我们应该创建如下几个页面:

  • _Layout.cshtml:主结构
  • Index.cshtml:主页
  • _Sidebar.cshtml:侧边栏

这些是主要页面,但我们还需要更多,例如归档、标签、搜索和 404 页面……

所以主题文件夹结构应该如下所示。

我已经创建了一个基于 Statiq 的网站 Tableware.com

因此,我将使用这个网站来解释如何创建主题。

首先,我们在 _Layout.cshtml 中定义主要的布局结构:

<div id="wrapper">

        <div id="main">
            <div class="inner">

                <header id="header">
                    <a href="/" class="logo"><strong>Tableware</strong></a>
                    @* <ul class="icons">
                        <li><a href="#" class="icon brands fa-twitter"><span class="label">Twitter</span></a></li>
                        <li><a href="#" class="icon brands fa-facebook-f"><span class="label">Facebook</span></a></li>
                        <li><a href="#" class="icon brands fa-instagram"><span class="label">Instagram</span></a></li>
                    </ul> *@
                </header>
                @RenderBody()
            </div>
        </div>

        @await Html.PartialAsync("_Sidebar")

    </div>

然后创建侧边栏,因为代码太多了,所以我只展示如何在视图中获取文章的核心逻辑,不过别担心,我会在最后提供所有源代码:

<div class="mini-posts">
                @{
                    // 1. Get all posts in the "posts" folder
                    // 2. Filter out posts without a date
                    // 3. Order by date in descending order
                    // 4. x.ContainsKey("Date"): only find the post with "Date" metadata,
                    // exclude pages like "About Us" without date
                    // 5. !x.Source.IsNull: exclude pages like search-index.json
                    // 6. Make sure the source contain "posts"
                    var recentPosts = OutputPages
                        .Where(x => x.ContainsKey("Date") && !x.Source.IsNull && x.Source.ToString().Contains("posts"))
                        .OrderByDescending(x => x.Get<DateTime>("Date"))
                        .Take(3);
                }

                @if (recentPosts.Any())
                {
                    foreach (var doc in recentPosts)
                    {
                        <article>
                            @if (doc.ContainsKey("Image"))
                            {
                                <a href="@doc.GetLink()" class="image">
                                    <img src="@doc.GetString("Image")" alt="@doc.GetString("Title")" />
                                </a>
                            }

                            <h3 style="font-size: 1em;">
                                <a href="@doc.GetLink()">@doc.GetString("Title")</a>
                            </h3>

                            <p style="font-size: 0.8em; margin-bottom: 0;">
                                @(doc.Get<DateTime>("Date").ToLongDateString())
                            </p>
                        </article>
                    }
                }
                else
                {
                    <p>No updates yet.</p>
                }
            </div>
            <ul class="actions">
                <li><a href="/archives" class="button">More Archives</a></li>
            </ul>
        </section>

你可以从 input/post 文件夹中获取文章,假设这些文章是使用 Markdown 格式创建的。

以及在主页(index.cshtml)上用于展示如何获取并显示文章的代码片段:

<div class="posts">
            @{
                // 1. Scan all output pages
                // 2. Filter condition: source file not null + source file path contains "posts" + must be .md file (exclude index.cshtml)
                // 3. Sort: use Get<DateTime> to sort by date in descending order
                var recentPosts = OutputPages
                    .Where(x => x.Source != null
                             && x.Source.ToString().Contains("posts")
                             && x.Source.Extension == ".md"
                             && x.ContainsKey("Date"))
                    .OrderByDescending(x => x.Get<DateTime>("Date"))
                    .Take(6);
            }

            @foreach (var doc in recentPosts)
            {
                <article>
                    <a href="@doc.GetLink()" class="image">
                        <img src="@(doc.GetString("Image") ?? "/images/pic01.jpg")" alt="@doc.GetTitle()" />
                    </a>
                    <h3>@doc.GetTitle()</h3>

                    <p>@doc.GetString("Description")</p>

                    <ul class="actions">
                        <li><a href="@doc.GetLink()" class="button">Read Review</a></li>
                    </ul>
                </article>
            }
        </div>

5. 核心功能

我将向你展示如何在 Statiq 中为静态网站创建核心功能。

5.1 搜索功能

搜索功能是网站必不可少的功能,但我们的网站只是一个静态 HTML 网站,那么该怎么做呢?

我们可以使用 Lunr.js

Lunr.js 是一个专为客户端使用而设计的轻量级全文搜索库。与需要服务器处理查询的传统搜索引擎(如 Elasticsearch 或 Solr)不同,Lunr 完全在用户的浏览器中运行。

它的工作原理是获取一个 JSON 数据集,在内存中构建一个倒排索引,然后对该索引执行搜索。这使得它成为静态网站(Jamstack)的完美伴侣,因为它不需要任何外部依赖、没有服务器端代码,也不需要 API 密钥。它体积小、速度快,并能处理模糊匹配和权重提升,在静态 HTML 页面上提供了“真正”的搜索体验。

实现步骤:

第一步:生成搜索索引(Statiq 端)

我们需要将所有内容生成到一个 JSON 文件中。在 program.cs 的 ConfigureEngine 中添加以下代码:

.ConfigureEngine(engine =>
                {
                    // Get the default "Content" pipeline
                    // This is the pipeline where Statiq Web processes all Markdown and Razor files
                    var contentPipeline = engine.Pipelines["Content"];

                    // Add a module in the "PostProcess" phase (after all processing is done, but before writing to disk)
                    contentPipeline.PostProcessModules.Add(
                    new SetDestination(Config.FromDocument(doc =>
                    {
                        // 1. Get the source file name
                        var sourceName = doc.Source.FileName.ToString();

                        // 2. Check: If it is our search index file
                        if (sourceName.Contains("search-index.json"))
                        {
                            // [Critical Fix] Use NormalizedPath instead of FilePath
                            return new NormalizedPath("search-index.json");
                        }

                        // 3. Keep other files as they are
                        return doc.Destination;
                    }))
                    );
...
...

然后创建一个 Razor 视图(例如 search-index.json.cshtml)。此文件会遍历 Statiq 管道中所有已发布的文档。

它将输出一个包含每个页面的标题、描述、URL 和内容(或关键字)的单一 search-index.json 文件。

这实际上将你的网站内容变成了一个便携式数据库。以下是 search-index.json.cshtml 中的完整代码:

---
Permalink: /search-index.json
---
@using System.Text.Json
@using System.Text.RegularExpressions
@using Statiq.Common
@using System.Net 

@{
    Layout = null;

    // Define the clean function
    Func<string, string> CleanText = (input) =>
    {
        if (string.IsNullOrEmpty(input)) return string.Empty;

        // 1. Remove the HTML tags (like <p>, <div>, etc.)
        var text = Regex.Replace(input, "<.*?>", " ");

        // 2. Decode "   HTML tags
        text = WebUtility.HtmlDecode(text);

        // 3. Remove newlines, tabs, and collapse multiple spaces into one
        text = Regex.Replace(text, @"\s+", " ").Trim();

        return text;
    };

    var searchEntries = new List<object>();

    foreach (var doc in OutputPages)
    {

        // 1. Only index HTML files (by extension)
        if (doc.Destination.Extension != ".html") continue;

        // 2. Must have a title
        if (!doc.ContainsKey("Title") || string.IsNullOrEmpty(doc.GetString("Title"))) continue;

        // 3. Get the file name
        var fileName = doc.Destination.FileName.ToString();

        // 4. Exclude the search page, index page, and the search index itself
        if (fileName.Equals("search.html", StringComparison.OrdinalIgnoreCase)) continue;
        if (fileName.Equals("index.html", StringComparison.OrdinalIgnoreCase)) continue;
        if (fileName.Contains("search-index")) continue; 

        // --- Handle content ---

        // Synchronously get the content (since we are in a synchronous context)
        string rawContent = doc.GetContentStringAsync().GetAwaiter().GetResult();

        // Clean the content
        string cleanContent = CleanText(rawContent);

        // Get the 5000 first characters of the content (or less if the content is shorter)
        if (cleanContent.Length > 5000)
        {
            cleanContent = cleanContent.Substring(0, 5000);
        }

        searchEntries.Add(new
        {
            title = doc.GetString("Title"),
            link = doc.GetLink(),
            description = doc.GetString("Description") ?? "",
            content = cleanContent
        });
    }

    var options = new JsonSerializerOptions
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        WriteIndented = false
    };

    string json = JsonSerializer.Serialize(searchEntries, options);
}
@Html.Raw(json)

第二步:加载索引(客户端)

页面加载时,一个简单的 JavaScript 函数会使用 fetch() 获取我们在第一步中生成的 search-index.json 文件。

一旦加载了 JSON,我们就会将其传递给 Lunr。Lunr 会分析文本,去除停用词(如 “the”、“and”),提取词干(将 “running” 转换为 “run”),并构建其内部索引以便快速检索。

我们需要为此创建一个 search.cshtml

Title: Search Results
Layout: _Layout.cshtml
---
<section>
    <header class="main">
        <h1>Search Results</h1>
    </header>
    
    <div id="search-results-container">
        <p>Searching...</p>
    </div>
</section>

<script src="https://cdnjs.cloudflare.com/ajax/libs/lunr.js/2.3.9/lunr.min.js"></script>
<script>
    document.addEventListener("DOMContentLoaded", function() {
        const params = new URLSearchParams(window.location.search);
        const query = params.get("query");
        const container = document.getElementById("search-results-container");
        
        if (!query) {
            container.innerHTML = "<p>Please enter a search term.</p>";
            return;
        }

        fetch("/search-index.json")
            .then(response => response.json())
            .then(data => {
                const idx = lunr(function () {
                    this.ref('link');
                    this.field('title');
                    this.field('description');
                    this.field('content');

                    data.forEach(function (doc) {
                        this.add(doc);
                    }, this);
                });

                const results = idx.search(query);
                
                if (results.length === 0) {
                    container.innerHTML = "<p>No results found for '" + query + "'.</p>";
                } else {
                    let html = '<div class="posts">';
                    results.forEach(result => {
                        const item = data.find(d => d.link === result.ref);
                        html += `
                            <article>
                                <h3>${item.title}</h3>
                                <p>${item.description || ''}</p>
                                <ul class="actions">
                                    <li><a href="${item.link}" class="button">Read More</a></li>
                                </ul>
                            </article>
                        `;
                    });
                    html += "</div>";
                    container.innerHTML = html;
                }
            })
            .catch(error => {
                console.error("Error:", error);
                container.innerHTML = "<p>An error occurred.</p>";
            });
    });
</script>

第三步:执行搜索

好了,我们在这个主题的侧边栏里有一个简单的 HTML 搜索表单,当你提交搜索查询时,Lunr 会查询内存中的索引并返回匹配的文档引用列表(具体来说是 ref ID,它会映射回我们的 URL)。

<section id="search" class="alt">
            <form method="get" action="/search">
                <input type="text" name="query" id="query" placeholder="Search" />
            </form>
        </section>

局限性与可扩展性:何时需要更换?

虽然 Statiq + Lunr.js 的方法强大、高性价比且保护隐私,但它完全依赖于客户端浏览器。它并非“万能”的解决方案。你需要考虑以下实际限制:

A. “有效载荷”问题(文件大小) 由于用户必须下载整个搜索索引(search-index.json),因此文件大小会随着内容的增加呈线性增长。

  • 全文索引:如果你索引每篇文章的整个正文,文件很快就会变得很大。
  • 优化:为了缓解这个问题,我建议只索引标题(Title)、标签(Tags)和简短的摘录/描述(Excerpt/Description)。这能在博客不断增长的同时,保持文件轻量。

B. 浏览器性能 Lunr 需要解析 JSON 并在内存中构建倒排索引。对于一个拥有数千个页面的网站,这个过程可能会阻塞主线程,导致在较慢的设备(尤其是手机)上 UI 出现短暂卡顿。

C. 结论:多少页面适用?

  • 中小型网站(< 1,000 个页面):这是一个极佳的解决方案。性能影响微乎其微。
  • 大型网站(> 2,000 个页面):你可能开始遇到性能瓶颈。在这个规模下,索引文件的大小会成为一种负担。
  • 企业级规模:如果你有数以万计的文档,你应该考虑使用服务器端解决方案,如 Algolia、Elasticsearch 或 Azure AI Search。

5.2 标签功能

如果你想创建一个博客网站,我认为你需要一个标签(Tags)功能,你可以为每篇文章添加标签,好的,让我们开始吧!

我们需要在 Program.cs 中添加一个管道来处理标签逻辑:

// We create a new pipeline named "Tags"
engine.Pipelines.Add("Tags", new Pipeline
{
    // Depends on the Content pipeline, ensuring articles have been read
    Dependencies = { "Content" },
    ProcessModules = {
        // A. Read all articles processed by the "Content" pipeline
        new ReplaceDocuments("Content"),
        // B. Filter out articles without Tags to prevent errors
        new FilterDocuments(Config.FromDocument(doc => doc.ContainsKey("Tags"))),
        // C. Core: Group by the "Tags" field
        // This turns 100 articles into (for example) 10 documents, where each document represents a Tag
        new GroupDocuments("Tags"),
        // D. Load the template we just wrote
        new MergeContent(new ReadFiles("_TagLayout.cshtml")),
        // E. Render Razor
        new RenderRazor()
            .WithModel(Config.FromDocument((doc, ctx) => doc)),
        // F. Set output path: /tag/tag-name.html
        new SetDestination(Config.FromDocument(doc =>
        {
            var tagName = doc.GetString(Keys.GroupKey);
            var slug = tagName.ToLower().Replace(" ", "-");
            return new NormalizedPath($"tag/{slug}.html");
        })),
        // [Critical Fix] G. Write files!
        // This step is mandatory, otherwise the files only exist in memory
        new WriteFiles()
    }
});

创建 _TagLayout.cshtml

@{
    Layout = "_Layout.cshtml";
    // Get the current tags name
    var tagName = Document.GetString(Keys.GroupKey);
}

<section>
    <header class="main">
        <h1>Tag: <em>@tagName</em></h1>
    </header>

    <div class="posts">
        @foreach (var post in Document.GetChildren())
        {
            <article>
                @if (post.ContainsKey("Image"))
                {
                    <a href="@post.GetLink()" class="image">
                        <img src="@post.GetString("Image")" alt="" />
                    </a>
                }
                <h3><a href="@post.GetLink()">@post.GetString("Title")</a></h3>
                <p>@post.GetString("Description")</p>
                <ul class="actions">
                    <li><a href="@post.GetLink()" class="button">Read More</a></li>
                </ul>
            </article>
        }
    </div>
</section>

结果如下。

此外我们还可以做得更多,为了提高 SEO,我们可以根据标签自动为文章生成内部链接。

在项目根目录下的 TagAutoLinkModule.cs 中创建一个 ParallelModule

using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using Statiq.Common;

public class TagAutoLinkModule : ParallelModule
{
   protected override async Task<IEnumerable<IDocument>> ExecuteInputAsync(IDocument input, IExecutionContext context)
    {
        // 1. Retrieve all tags and their corresponding URLs from the entire context
        // Assuming your tag page path format is /tags/tag-name
        var allTags = context.Outputs
        .SelectMany(doc => doc.GetList<string>("Tags") ?? Enumerable.Empty<string>()) // Directly use the string "Tags"
        .Distinct()
        .OrderByDescending(t => t.Length)
        .ToDictionary(
            tag => tag,
            tag => $"/tag/{tag.ToLower().Replace(" ", "-")}"
        );

        string content = await input.GetContentStringAsync();

        // 2. Iterate through all tags to process the current article
        foreach (var tag in allTags)
        {
            // Prevent in-article tags from linking to themselves (optional)
            // string pattern = $@"(?<!<[^>]*){tag}(?![^<]*</a>)";

            // Complex Regex: Match tags, but exclude cases where they are already inside <a> tags or HTML attributes
            string pattern = $@"\b({Regex.Escape(tag.Key)})\b(?![^<]*>)(?![^<]*</a>)";

            content = Regex.Replace(content, pattern, $"<a href=\"{tag.Value}\" class=\"internal-tag-link\">$1</a>", RegexOptions.IgnoreCase);
        }

        return input.Clone(context.GetContentProvider(content, "text/html")).Yield();
    }
}

它会找到匹配标签的关键字并添加超链接。在管道中处理该模块:

ModifyPipeline(nameof(Statiq.Web.Pipelines.Content), pipeline =>
{
    // Add custom module to PostModules
    // This ensures it runs after all standard processing (like Markdown rendering) is complete
    pipeline.ProcessModules.Add(new TagAutoLinkModule());
})

你可以在下面看到结果。

自动添加超链接以匹配标签。

5.3 分页功能

对于静态网站来说,分页也是一个问题,但 Statiq 可以帮你处理好它。

例如,我们有一个列表页面模板 _ListLayout.cshtml,用于显示一个分类下的所有文章。

我们需要从 input 文件夹获取文章:

@{
    // 1. Define the variable IEnumerable<IDocument>
    IEnumerable<IDocument> posts;
    // 2. Get the children docs
    var childDocs = Document.GetChildren();
    if (childDocs.Any())
    {
        posts = childDocs;
    }
    else
    {
        posts = OutputPages.GetChildrenOf(Document)
            .Where(x => x.Source.Extension == ".md" && x.Id != Document.Id)
            .OrderByDescending(x => x.Get<DateTime>("Date", DateTime.MinValue));
    }
}
@foreach (var post in posts)
{
    <article>
        @if (post.ContainsKey("Image"))
        {
            <a href="@post.GetLink()" class="image">
                <img src="@post.GetString("Image")" alt="" />
            </a>
        }
        <h3><a href="@post.GetLink()">@post.GetString("Title")</a></h3>
        <p>@post.GetString("Description")</p>
        <ul class="actions">
            <li><a href="@post.GetLink()" class="button">Read More</a></li>
        </ul>
    </article>
}

并处理分页逻辑:

@{
    // Get the previouse and next page documents
    var prevPage = Document.GetDocument(Keys.Previous);
    var nextPage = Document.GetDocument(Keys.Next);
    // Get the total pages and current page number
    var totalPages = Document.GetInt(Keys.TotalPages);
    var currentPage = Document.GetInt(Keys.Index);
}
@if (totalPages > 1)
{
    <div class="pagination" style="margin-top: 3rem; text-align: center;">
        @if (prevPage != null)
        {
            <a href="@prevPage.GetLink()" class="button">Prev</a>
        }
        else
        {
            <span class="button disabled">Prev</span>
        }
        <span class="extra" style="margin: 0 1rem;">
            Page @currentPage of @totalPages
        </span>
        @if (nextPage != null)
        {
            <a href="@nextPage.GetLink()" class="button">Next</a>
        }
        else
        {
            <span class="button disabled">Next</span>
        }
    </div>
}

5.4 归档

最后一个功能是显示归档。这也是博客网站的常见功能。

实际上,我们只需要对文章进行分组和排序,就可以获取归档数据了。

var allPosts = OutputPages
.Where(x => x.Source != null
&& x.Source.ToString().Contains("posts")
&& x.Source.Extension == ".md"
                 && x.ContainsKey("Date"));
var postsByYear = allPosts
.OrderByDescending(x => x.Get<DateTime>("Date"))
.GroupBy(x => x.Get<DateTime>("Date").Year);

创建一个 archives.cshtml 模板来显示数据:

foreach (var yearGroup in postsByYear)
 {
     <h2 id="year-@yearGroup.Key" style="border-bottom: 2px solid #f56a6a; padding-bottom: 10px; margin-top: 2em;">
         @yearGroup.Key
     </h2>

     <div class="table-wrapper">
         <table>
             <thead>
                 <tr>
                     <th style="width: 120px;">Date</th>
                     <th>Title</th>
                     <th>Category / Tags</th>
                 </tr>
             </thead>
             <tbody>
                 @foreach (var post in yearGroup)
                 {
                     var postDate = post.Get<DateTime>("Date");

                     <tr>
                         <td style="white-space: nowrap; width: 120px;">
                             @postDate.ToString("MMM dd")
                         </td>

                         <td>
                             <a href="@post.GetLink()" style="font-weight: bold;">
                                 @post.GetString("Title")
                             </a>
                         </td>

                         <td>
                             @if (post.ContainsKey("Tags"))
                             {
                                 foreach (var tag in post.GetList<string>("Tags").Take(3))
                                 {
                                     <span class="button small"
                                           style="font-size: 0.6em; height: 2em; line-height: 2em; padding: 0 10px; margin-right: 2px;">
                                         @tag
                                     </span>
                                 }
                             }
                         </td>
                     </tr>
                 }
             </tbody>
         </table>
     </div>
 }

6. 创建内容

创建好主题和功能后,我们就可以开始创建内容了。Statiq 会从 input 文件夹中读取内容,所以我们必须把内容放在那里。并且我们可以使用 Markdown 来编写我们的内容。

例如,在我的网站中,我将所有文章放入 input/posts 文件夹,将图片放入 input/images 文件夹,并创建如下一篇文章:

---
Title: "The 10 Best Dinnerware Sets of 2026: Trends, Reviews & Buying Guide"
Description: "We tested the top dinnerware sets of 2026. From durable Corelle to luxurious Wedgwood bone china, here are the best plates for every budget and style."
Date: 2026-02-08
Layout: "_PostLayout"
Image: "/images/hero-best-dinnerware-2026.webp"
Tags: [Buying Guide, 2026 Trends, Bone China, Stoneware, Luxury]
---

Other content below ......

你需要在文件顶部以 Markdown 格式设置元数据,然后主题就会读取它们。

7. 部署

每次你更改内容或主题时,Statiq 都会生成所有的 HTML 文件,因此,如果你只是将全部或更新的文件上传到 FTP,那也会是个问题。

如果你的主机提供 SSH 和 Linux 操作系统,那么下面的部署方式将非常适合你!

你可以使用 rsync 仅将更改的文件上传到你的服务器,而且你不需要逐个选择或检查哪些文件应该被上传。

如果你使用的是 Mac OS,那么 rsync 是一个内置命令;如果你使用的是 Windows,那么你需要安装 Git Bash 或 WSL。

要检查你的主机是否支持 rsync,你可以使用以下命令:

ssh username@your-server.com "rsync --version"

好了,你可以使用下面的脚本将你的 HTML 网站部署到服务器上:

#!/bin/bash

# 1. the ssh key 
KEY_PATH="~/.ssh/ssh.key"

# 2. VPS Information
USER="root"      
IP="VPS IP"     
REMOTE_DIR="/opt/www/sites/yourdomain.com/" 

echo "Generating static website..."
dotnet run

echo "Syncing output to server..."

rsync -avz --delete --rsync-path="sudo rsync" -e "ssh -i $KEY_PATH" output/ ${USER}@${IP}:${REMOTE_DIR}

echo "Deployment complete!"

就是这么简单!😁

完整的项目和主题在此

你可以在下方找到我的完整项目:

Statiq Tableware 主题

8. 结论

对我来说,发现 Statiq 是一个改变游戏规则的时刻。它证明了我不需要为了构建一个快速、安全的网站而去学习一个新的 JavaScript 框架。Statiq 强大的生成管道和 Lunr.js 轻量级的简单性相结合,为我的需求创造了完美的架构。

无论你是在构建作品集、公司博客,还是文档网站,这个技术栈都提供了令人难以置信的速度和灵活性。我鼓励你克隆代码库,调整管道,并构建属于你自己的东西。.NET 静态网站社区正在不断壮大,现在正是加入的最佳时机。

版权声明:
作者:winson
链接:https://www.coderblog.cc/2026/03/a-static-site-generator-that-speaks-c-getting-started-with-statiq/
来源:代码部落中文站
文章版权归作者所有,未经允许请勿转载。

THE END
分享
二维码
< <上一篇
下一篇>>
文章目录
关闭
目 录