Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- Bilingual interface, theme persistence, virtual scrollback, and responsive file drawer.
- Short `config` virtual path for editing site configuration from the terminal.
- Configurable first-visit cookie and local-storage consent prompt with persistent `y`, `n`, and `Ctrl+C` handling.
- Live title-template updates for opened articles, with the configured site description as the default article name.
- Production Docker image and Compose deployment with persistent content and database volumes.

### Security
Expand Down
2 changes: 2 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,8 @@ The cookie notice uses this structure:

When enabled, a visitor without a stored choice sees the notice at the end of the scrollback on first entry. Enter `y` to accept, or enter `n` / press `Ctrl+C` to decline. The choice is stored in localStorage so later visits do not repeat the prompt. Legacy string values for `cookieNotice` remain readable and are treated as enabled.

The title template drives both server metadata and the browser tab title. Before an article is opened, `{ArticleName}` uses the site `description`; after `cat` or `less` opens an article, it uses that article's title. `{BlogName}` always uses the current blog name.

## Command System

### Visitor commands
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,8 @@ Cookie 提示使用以下结构:

启用后,尚未选择的访客会在首次进入时于回滚缓冲区末尾看到提示。输入 `y` 表示同意,输入 `n` 或按 `Ctrl+C` 表示拒绝;选择会保存到 localStorage,后续访问不再重复提示。旧版字符串形式的 `cookieNotice` 仍可读取,并按启用状态处理。

标题模板会同时用于服务端 metadata 和浏览器标签标题。未打开文章时,`{ArticleName}` 使用站点 `description`;通过 `cat` 或 `less` 打开文章后,它会替换为文章标题,`{BlogName}` 始终使用当前博客名称。

## 命令系统

### 访客命令
Expand Down
3 changes: 2 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import "./globals.css";
import { formatSiteTitle } from "@/lib/site-config";
import { readSiteConfig } from "@/lib/site-config-server";
import type { Metadata } from "next";
import type { ReactNode } from "react";

export const dynamic = "force-dynamic";

export async function generateMetadata() {
export async function generateMetadata(): Promise<Metadata> {
const { config } = readSiteConfig();
return {
title: formatSiteTitle(config),
Expand Down
15 changes: 13 additions & 2 deletions components/TerminalBlog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import {
validateCommandArguments,
type CommandDefinition,
} from "@/lib/command-registry";
import { defaultSiteConfig, type SiteConfig } from "@/lib/site-config";
import { defaultSiteConfig, formatSiteTitle, type SiteConfig } from "@/lib/site-config";
import {
COOKIE_CONSENT_STORAGE_KEY,
isCookieConsentCancelShortcut,
Expand Down Expand Up @@ -320,6 +320,7 @@ export default function TerminalBlog({
const [themeMode, setThemeMode] = useState<ThemeMode>("auto");
const [systemTheme, setSystemTheme] = useState<"light" | "dark">("dark");
const [siteConfig, setSiteConfig] = useState<SiteConfig>(initialConfig);
const [activeArticleName, setActiveArticleName] = useState("");
const [windowFocused, setWindowFocused] = useState(true);
const [insertMode, setInsertMode] = useState(true);
const [cursorLeft, setCursorLeft] = useState(0);
Expand Down Expand Up @@ -474,6 +475,10 @@ export default function TerminalBlog({
document.body.style.background = resolvedTheme === "light" ? "#ffffff" : "#000000";
}, [language, resolvedTheme]);

useEffect(() => {
document.title = formatSiteTitle(siteConfig, activeArticleName);
}, [activeArticleName, siteConfig]);

useEffect(() => {
if (currentPath !== "/") setExpandedFolder(currentPath);
}, [currentPath]);
Expand Down Expand Up @@ -824,6 +829,7 @@ export default function TerminalBlog({
if (command === "clear") {
setEntries([makeEntry("boot")]);
setPendingPassword(null);
setActiveArticleName("");
stagedFilesRef.current.clear();
return;
}
Expand Down Expand Up @@ -950,7 +956,10 @@ export default function TerminalBlog({
`File not found in this directory: ${articleArgs.join(" ")}`,
),
);
else out.push(makeEntry(requestedMode === "source" ? "source" : "article", { article }));
else {
setActiveArticleName(article.title);
out.push(makeEntry(requestedMode === "source" ? "source" : "article", { article }));
}
break;
}
case "less": {
Expand All @@ -959,6 +968,7 @@ export default function TerminalBlog({
error(`less: ${args.join(" ")}: file not found`);
break;
}
setActiveArticleName(article.title);
openPager({ fileName: `${article.id}.md`, value: articleToBuffer(article) });
break;
}
Expand Down Expand Up @@ -1638,6 +1648,7 @@ export default function TerminalBlog({
const clearTerminal = () => {
setEntries([makeEntry("boot"), ...(cookiePromptPending ? [makeEntry("text", { value: cookieNoticePrompt })] : [])]);
setPendingPassword(null);
setActiveArticleName("");
stagedFilesRef.current.clear();
};

Expand Down
2 changes: 1 addition & 1 deletion lib/site-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,5 +89,5 @@ export function mergeSiteConfig(value: unknown): SiteConfig {
export function formatSiteTitle(config: SiteConfig, articleName = "") {
return config.titleTemplate
.replaceAll("{BlogName}", config.blogName)
.replaceAll("{ArticleName}", articleName || "Field notes from the command line");
.replaceAll("{ArticleName}", articleName.trim() || config.description);
}
12 changes: 11 additions & 1 deletion tests/site-config.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { defaultSiteConfig, mergeSiteConfig } from "../lib/site-config";
import { defaultSiteConfig, formatSiteTitle, mergeSiteConfig } from "../lib/site-config";

describe("site configuration", () => {
it("normalizes the cookie notice object", () => {
Expand All @@ -19,4 +19,14 @@ describe("site configuration", () => {
message: defaultSiteConfig.cookieNotice.message,
});
});

it("formats configured site and article titles", () => {
const config = mergeSiteConfig({
blogName: "Example Blog",
description: "Example description",
titleTemplate: "{ArticleName} :: {BlogName}",
});
expect(formatSiteTitle(config)).toBe("Example description :: Example Blog");
expect(formatSiteTitle(config, "Article title")).toBe("Article title :: Example Blog");
});
});
Loading