From 0de0ab4979ccefc904c71617398c1f560be962a2 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 16 Aug 2026 19:29:28 +0800 Subject: [PATCH 1/2] fix(title): apply template to active articles --- app/layout.tsx | 3 ++- components/TerminalBlog.tsx | 15 +++++++++++++-- lib/site-config.ts | 2 +- tests/site-config.test.ts | 12 +++++++++++- 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index 6b666f2..a5a801b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 { const { config } = readSiteConfig(); return { title: formatSiteTitle(config), diff --git a/components/TerminalBlog.tsx b/components/TerminalBlog.tsx index a4f1c82..9bce780 100644 --- a/components/TerminalBlog.tsx +++ b/components/TerminalBlog.tsx @@ -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, @@ -320,6 +320,7 @@ export default function TerminalBlog({ const [themeMode, setThemeMode] = useState("auto"); const [systemTheme, setSystemTheme] = useState<"light" | "dark">("dark"); const [siteConfig, setSiteConfig] = useState(initialConfig); + const [activeArticleName, setActiveArticleName] = useState(""); const [windowFocused, setWindowFocused] = useState(true); const [insertMode, setInsertMode] = useState(true); const [cursorLeft, setCursorLeft] = useState(0); @@ -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]); @@ -824,6 +829,7 @@ export default function TerminalBlog({ if (command === "clear") { setEntries([makeEntry("boot")]); setPendingPassword(null); + setActiveArticleName(""); stagedFilesRef.current.clear(); return; } @@ -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": { @@ -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; } @@ -1638,6 +1648,7 @@ export default function TerminalBlog({ const clearTerminal = () => { setEntries([makeEntry("boot"), ...(cookiePromptPending ? [makeEntry("text", { value: cookieNoticePrompt })] : [])]); setPendingPassword(null); + setActiveArticleName(""); stagedFilesRef.current.clear(); }; diff --git a/lib/site-config.ts b/lib/site-config.ts index a740d0a..bdfa793 100644 --- a/lib/site-config.ts +++ b/lib/site-config.ts @@ -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); } diff --git a/tests/site-config.test.ts b/tests/site-config.test.ts index 70774a6..c247973 100644 --- a/tests/site-config.test.ts +++ b/tests/site-config.test.ts @@ -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", () => { @@ -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"); + }); }); From 52c0460cf3ea7f83631d41f3bb2183c95e3c33e9 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 16 Aug 2026 19:29:29 +0800 Subject: [PATCH 2/2] docs(title): explain template behavior --- CHANGELOG.md | 1 + README.en.md | 2 ++ README.md | 2 ++ 3 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f15f9d..cf6c937 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. ### Security diff --git a/README.en.md b/README.en.md index 55917de..fc7e738 100644 --- a/README.en.md +++ b/README.en.md @@ -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 diff --git a/README.md b/README.md index 04c0f5d..940c687 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,8 @@ Cookie 提示使用以下结构: 启用后,尚未选择的访客会在首次进入时于回滚缓冲区末尾看到提示。输入 `y` 表示同意,输入 `n` 或按 `Ctrl+C` 表示拒绝;选择会保存到 localStorage,后续访问不再重复提示。旧版字符串形式的 `cookieNotice` 仍可读取,并按启用状态处理。 +标题模板会同时用于服务端 metadata 和浏览器标签标题。未打开文章时,`{ArticleName}` 使用站点 `description`;通过 `cat` 或 `less` 打开文章后,它会替换为文章标题,`{BlogName}` 始终使用当前博客名称。 + ## 命令系统 ### 访客命令