feat(terminal): /en/terminal 双语薄壳——命令行为零分叉

- terminal.astro 抽 locale prop:frontmatter 静态文案 + script 层
  zh/en 双 DICT(命令描述/ls 路由表/whoami/exit 目标),双轨共享
  一份 chunk;路径守卫 Set{/terminal,/en/terminal}
- en/terminal.astro 四行薄壳 <TerminalPage locale="en" />
- Keyboard 帮助表 terminal 词条按 /en/ 前缀改写(其余表格文案
  双语化留给独立车道,注释已标)

车道:op-artalk(自验 15/15);收编验收 build+双轨 playwright 过——
首验 FAIL 系验收方误用 textContent 吞了内联脚本中文注释,innerText
复核零中文,非车道缺陷

验证: pnpm astro build 30 页 exit 0(双产物);双轨 playwright 验收
      (en 可见文案零中文/ls en 路由表/zh 回归/Keyboard 词条)全过

Spec: none (页型双语薄壳,车道级小活)

Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-10-25 20:24:13 +08:00
1 parent f57b1bde5f
commit b18d019a2e
3 files changed
+101 -26

No files matched your search

+5 -1
View File
@@ -21,7 +21,7 @@ const routes: Record<string, string> = {
<tr><td><kbd>g</kbd> <kbd>a</kbd></td><td>文章</td></tr>
<tr><td><kbd>g</kbd> <kbd>g</kbd></td><td>图库</td></tr>
<tr><td><kbd>g</kbd> <kbd>m</kbd></td><td>瞬间</td></tr>
<tr><td><kbd>/terminal</kbd></td><td>终端彩蛋</td></tr>
<tr><td><kbd>/terminal</kbd></td><td class="k-term-desc">终端彩蛋</td></tr>
<tr><td><kbd>Esc</kbd></td><td>关闭浮层</td></tr>
</tbody>
</table>
@@ -33,6 +33,10 @@ const routes: Record<string, string> = {
// 路由表由 data-routes 注入(module 脚本里 currentScript 恒为 null,不能走内联取参)
const helpEl = document.getElementById('kbd-help') as HTMLElement & { dataset: { routes?: string } };
const routes: Record<string, string> = JSON.parse(helpEl?.dataset.routes ?? '{}');
// en 轨仅切换终端行词条(帮助表其余文案的双语化另行车道)
if (location.pathname === '/en' || location.pathname.startsWith('/en/')) {
document.querySelector('.k-term-desc')?.replaceChildren(document.createTextNode('terminal'));
}
let chord = '';
let chordTimer: number | undefined;
+6
View File
@@ -0,0 +1,6 @@
---
// en 轨薄壳:复用 /terminal 全部实现,仅切 locale(文案源见 terminal.astro 的 DICT)
import TerminalPage from '../terminal.astro';
---
<TerminalPage locale="en" />
+90 -25
View File
@@ -3,10 +3,32 @@
// 命令注册表唯一事实源;输出一律 DOM API + textContent(无 innerHTML,XSS 纪律)
// 实现集提炼自 Phase 5b 预研(satnaing/terminal-portfolio 实读 + CodeNerve 反例)
import BaseLayout from '../layouts/BaseLayout.astro';
interface Props {
locale?: 'zh' | 'en';
}
const { locale = 'zh' } = Astro.props;
// 静态层文案(script 层另有一套 dict,由 data-locale 运行时选取)
const T = locale === 'en'
? {
desc: "wait4x's terminal easter egg: type help to explore",
welcome: 'wait4x terminal v1.0 —— type help to list available commands',
noscript: 'This easter egg needs JavaScript: the site is static with no runtime framework; this page is the only exception.',
aria_shell: 'interactive terminal easter egg',
aria_input: 'terminal command input',
}
: {
desc: 'wait4x 的终端彩蛋:help 开始探索',
welcome: 'wait4x terminal v1.0 —— 键入 help 查看可用命令',
noscript: '此彩蛋需要 JavaScript:静态站无运行时框架,仅此页例外。',
aria_shell: '交互终端彩蛋',
aria_input: '终端命令输入',
};
---
<BaseLayout title="terminal" description="wait4x 的终端彩蛋:help 开始探索">
<section class="term-shell" id="term-shell" aria-label="交互终端彩蛋">
<BaseLayout title="terminal" description={T.desc}>
<section class="term-shell" id="term-shell" data-locale={locale} aria-label={T.aria_shell}>
<div class="term-bar" aria-hidden="true">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
<span class="term-title">wait4x@blog: ~</span>
@@ -15,11 +37,11 @@ import BaseLayout from '../layouts/BaseLayout.astro';
<div class="term-body">
<!-- 欢迎语静态渲染:无 JS / 构建期首屏即有内容 -->
<div class="term-out" id="term-out" aria-live="polite">
<p class="line">wait4x terminal v1.0 —— 键入 help 查看可用命令</p>
<p class="line">{T.welcome}</p>
</div>
<noscript>
<p class="line dim">此彩蛋需要 JavaScript:静态站无运行时框架,仅此页例外。</p>
<p class="line dim">{T.noscript}</p>
</noscript>
<form id="term-form" class="term-form" autocomplete="off">
@@ -29,7 +51,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
id="term-input"
class="term-input"
type="text"
aria-label="终端命令输入"
aria-label={T.aria_input}
spellcheck="false"
autocapitalize="off"
/>
@@ -40,9 +62,13 @@ import BaseLayout from '../layouts/BaseLayout.astro';
<script>
// ClientRouter 软导航会整体换 DOM:全部监听挂 document 常驻 + pathname 守卫短路,
// 元素引用一律现取不缓存(站内 CommandPalette/Header 同款模式,见各自注释)
const PATH = '/terminal/';
// 双轨路由:zh /terminal、en /en/terminal(薄壳复用本组件)
const TERM_PATHS = new Set(['/terminal', '/en/terminal']);
const COMMANDS: { name: string; desc: string }[] = [
// 打包共享 module 供两轨复用:文案由 data-locale 运行时选取(构建期不内联)
const DICT = {
zh: {
cmds: [
{ name: 'clear', desc: '清屏(等同 Ctrl+L)' },
{ name: 'exit', desc: '退出终端,返回首页' },
{ name: 'help', desc: '列出可用命令' },
@@ -51,28 +77,66 @@ import BaseLayout from '../layouts/BaseLayout.astro';
{ name: 'sudo', desc: '试试看' },
{ name: 'theme', desc: '切换亮/暗主题' },
{ name: 'whoami', desc: '我是谁' },
];
// 唯一允许 open 的外部目标:白名单硬编码,永不拼接用户输入
const OPEN_TARGETS: Record<string, string> = {
gitea: 'https://git.wait4x.online/wait4x',
};
const SITE_ROUTES: { path: string; label: string }[] = [
],
routes: [
{ path: '/', label: '首页' },
{ path: '/archives/', label: '文章' },
{ path: '/gallery/', label: '图库' },
{ path: '/moments/', label: '瞬间' },
{ path: '/about/', label: '关于' },
{ path: '/en/', label: 'English home' },
];
],
whoami: 'wait4x —— 阅读 · 技术 · 摄影,博客在 gitea 开源(open gitea)',
notFound: (c: string) => `command not found: ${c}(help 查看可用命令)`,
usage: `用法: open <target>;可用: `,
home: '/',
},
en: {
cmds: [
{ name: 'clear', desc: 'clear the screen (same as Ctrl+L)' },
{ name: 'exit', desc: 'exit the terminal, back to home' },
{ name: 'help', desc: 'list available commands' },
{ name: 'ls', desc: 'list site pages (clickable)' },
{ name: 'open', desc: 'open gitea —— open the code host (new tab)' },
{ name: 'sudo', desc: 'try it' },
{ name: 'theme', desc: 'toggle light/dark theme' },
{ name: 'whoami', desc: 'who am I' },
],
routes: [
{ path: '/en/', label: 'Home' },
{ path: '/en/archives/', label: 'Posts' },
{ path: '/en/gallery/', label: 'Gallery' },
{ path: '/en/moments/', label: 'Moments' },
{ path: '/en/about/', label: 'About' },
{ path: '/', label: '中文首页' },
],
whoami: 'wait4x —— reading · code · photography; this blog is open source on gitea (open gitea)',
notFound: (c: string) => `command not found: ${c} (try help)`,
usage: `usage: open <target>; available: `,
home: '/en/',
},
};
function L() {
const shell = document.getElementById('term-shell');
return (shell?.dataset.locale as keyof typeof DICT) === 'en' ? DICT.en : DICT.zh;
}
function commands() {
return L().cmds;
}
// 唯一允许 open 的外部目标:白名单硬编码,永不拼接用户输入
const OPEN_TARGETS: Record<string, string> = {
gitea: 'https://git.wait4x.online/wait4x',
};
// 模块级仅存终端语义状态(不存 DOM 引用);history 顺序旧→新
const history: string[] = [];
let histPos = 0;
function isTerminalPage(): boolean {
return location.pathname === PATH || location.pathname === '/terminal';
return TERM_PATHS.has(location.pathname.replace(/\/$/, ''));
}
function out(): HTMLElement | null {
@@ -120,18 +184,19 @@ import BaseLayout from '../layouts/BaseLayout.astro';
histPos = history.length;
const [cmd, ...args] = line.split(/\s+/);
const t = L();
switch (cmd) {
case 'help': {
// 注册表即帮助:单一事实源
const width = Math.max(...COMMANDS.map((c) => c.name.length)) + 2;
for (const c of COMMANDS) print(`${c.name.padEnd(width)}${c.desc}`);
const width = Math.max(...t.cmds.map((c) => c.name.length)) + 2;
for (const c of t.cmds) print(`${c.name.padEnd(width)}${c.desc}`);
break;
}
case 'whoami':
print('wait4x —— 阅读 · 技术 · 摄影,博客在 gitea 开源(open gitea)');
print(t.whoami);
break;
case 'ls':
for (const r of SITE_ROUTES) printLink(r.path, r.label);
for (const r of t.routes) printLink(r.path, r.label);
break;
case 'theme': {
const root = document.documentElement;
@@ -145,7 +210,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
const target = args[0];
const url = target ? OPEN_TARGETS[target] : undefined;
if (!url) {
print(`用法: open <target>;可用: ${Object.keys(OPEN_TARGETS).join(', ')}`, 'dim');
print(`${t.usage}${Object.keys(OPEN_TARGETS).join(', ')}`, 'dim');
} else {
window.open(url, '_blank', 'noopener');
print(`opening ${url} …`, 'dim');
@@ -162,11 +227,11 @@ import BaseLayout from '../layouts/BaseLayout.astro';
case 'exit':
print('logout', 'dim');
setTimeout(() => {
location.href = '/';
location.href = t.home;
}, 600);
break;
default:
print(`command not found: ${cmd}(help 查看可用命令)`, 'err');
print(t.notFound(cmd), 'err');
}
}
@@ -180,7 +245,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
function tabComplete(current: string): void {
if (!current) return;
const hits = COMMANDS.filter((c) => c.name.startsWith(current)).map((c) => c.name);
const hits = commands().filter((c) => c.name.startsWith(current)).map((c) => c.name);
if (hits.length === 1) {
const el = input();
if (el) el.value = hits[0];