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:
1 parent
f57b1bde5f
commit
b18d019a2e
3 files changed
+101
-26
No files matched your search
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
// en 轨薄壳:复用 /terminal 全部实现,仅切 locale(文案源见 terminal.astro 的 DICT)
|
||||
import TerminalPage from '../terminal.astro';
|
||||
---
|
||||
|
||||
<TerminalPage locale="en" />
|
||||
+90
-25
@@ -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];
|
||||
|
||||
Reference in new issue
Block a user