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
+110
-35
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>a</kbd></td><td>文章</td></tr>
|
||||||
<tr><td><kbd>g</kbd> <kbd>g</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>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>
|
<tr><td><kbd>Esc</kbd></td><td>关闭浮层</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -33,6 +33,10 @@ const routes: Record<string, string> = {
|
|||||||
// 路由表由 data-routes 注入(module 脚本里 currentScript 恒为 null,不能走内联取参)
|
// 路由表由 data-routes 注入(module 脚本里 currentScript 恒为 null,不能走内联取参)
|
||||||
const helpEl = document.getElementById('kbd-help') as HTMLElement & { dataset: { routes?: string } };
|
const helpEl = document.getElementById('kbd-help') as HTMLElement & { dataset: { routes?: string } };
|
||||||
const routes: Record<string, string> = JSON.parse(helpEl?.dataset.routes ?? '{}');
|
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 chord = '';
|
||||||
let chordTimer: number | undefined;
|
let chordTimer: number | undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
// en 轨薄壳:复用 /terminal 全部实现,仅切 locale(文案源见 terminal.astro 的 DICT)
|
||||||
|
import TerminalPage from '../terminal.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<TerminalPage locale="en" />
|
||||||
+99
-34
@@ -3,10 +3,32 @@
|
|||||||
// 命令注册表唯一事实源;输出一律 DOM API + textContent(无 innerHTML,XSS 纪律)
|
// 命令注册表唯一事实源;输出一律 DOM API + textContent(无 innerHTML,XSS 纪律)
|
||||||
// 实现集提炼自 Phase 5b 预研(satnaing/terminal-portfolio 实读 + CodeNerve 反例)
|
// 实现集提炼自 Phase 5b 预研(satnaing/terminal-portfolio 实读 + CodeNerve 反例)
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
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 开始探索">
|
<BaseLayout title="terminal" description={T.desc}>
|
||||||
<section class="term-shell" id="term-shell" aria-label="交互终端彩蛋">
|
<section class="term-shell" id="term-shell" data-locale={locale} aria-label={T.aria_shell}>
|
||||||
<div class="term-bar" aria-hidden="true">
|
<div class="term-bar" aria-hidden="true">
|
||||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||||||
<span class="term-title">wait4x@blog: ~</span>
|
<span class="term-title">wait4x@blog: ~</span>
|
||||||
@@ -15,11 +37,11 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
<div class="term-body">
|
<div class="term-body">
|
||||||
<!-- 欢迎语静态渲染:无 JS / 构建期首屏即有内容 -->
|
<!-- 欢迎语静态渲染:无 JS / 构建期首屏即有内容 -->
|
||||||
<div class="term-out" id="term-out" aria-live="polite">
|
<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>
|
</div>
|
||||||
|
|
||||||
<noscript>
|
<noscript>
|
||||||
<p class="line dim">此彩蛋需要 JavaScript:静态站无运行时框架,仅此页例外。</p>
|
<p class="line dim">{T.noscript}</p>
|
||||||
</noscript>
|
</noscript>
|
||||||
|
|
||||||
<form id="term-form" class="term-form" autocomplete="off">
|
<form id="term-form" class="term-form" autocomplete="off">
|
||||||
@@ -29,7 +51,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
id="term-input"
|
id="term-input"
|
||||||
class="term-input"
|
class="term-input"
|
||||||
type="text"
|
type="text"
|
||||||
aria-label="终端命令输入"
|
aria-label={T.aria_input}
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
autocapitalize="off"
|
autocapitalize="off"
|
||||||
/>
|
/>
|
||||||
@@ -40,39 +62,81 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
<script>
|
<script>
|
||||||
// ClientRouter 软导航会整体换 DOM:全部监听挂 document 常驻 + pathname 守卫短路,
|
// ClientRouter 软导航会整体换 DOM:全部监听挂 document 常驻 + pathname 守卫短路,
|
||||||
// 元素引用一律现取不缓存(站内 CommandPalette/Header 同款模式,见各自注释)
|
// 元素引用一律现取不缓存(站内 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 运行时选取(构建期不内联)
|
||||||
{ name: 'clear', desc: '清屏(等同 Ctrl+L)' },
|
const DICT = {
|
||||||
{ name: 'exit', desc: '退出终端,返回首页' },
|
zh: {
|
||||||
{ name: 'help', desc: '列出可用命令' },
|
cmds: [
|
||||||
{ name: 'ls', desc: '列出站内页面(可点击跳转)' },
|
{ name: 'clear', desc: '清屏(等同 Ctrl+L)' },
|
||||||
{ name: 'open', desc: 'open gitea —— 打开代码托管(新标签页)' },
|
{ name: 'exit', desc: '退出终端,返回首页' },
|
||||||
{ name: 'sudo', desc: '试试看' },
|
{ name: 'help', desc: '列出可用命令' },
|
||||||
{ name: 'theme', desc: '切换亮/暗主题' },
|
{ name: 'ls', desc: '列出站内页面(可点击跳转)' },
|
||||||
{ name: 'whoami', desc: '我是谁' },
|
{ name: 'open', desc: 'open gitea —— 打开代码托管(新标签页)' },
|
||||||
];
|
{ name: 'sudo', desc: '试试看' },
|
||||||
|
{ name: 'theme', desc: '切换亮/暗主题' },
|
||||||
|
{ name: 'whoami', desc: '我是谁' },
|
||||||
|
],
|
||||||
|
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 的外部目标:白名单硬编码,永不拼接用户输入
|
// 唯一允许 open 的外部目标:白名单硬编码,永不拼接用户输入
|
||||||
const OPEN_TARGETS: Record<string, string> = {
|
const OPEN_TARGETS: Record<string, string> = {
|
||||||
gitea: 'https://git.wait4x.online/wait4x',
|
gitea: 'https://git.wait4x.online/wait4x',
|
||||||
};
|
};
|
||||||
|
|
||||||
const SITE_ROUTES: { path: string; label: string }[] = [
|
|
||||||
{ path: '/', label: '首页' },
|
|
||||||
{ path: '/archives/', label: '文章' },
|
|
||||||
{ path: '/gallery/', label: '图库' },
|
|
||||||
{ path: '/moments/', label: '瞬间' },
|
|
||||||
{ path: '/about/', label: '关于' },
|
|
||||||
{ path: '/en/', label: 'English home' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 模块级仅存终端语义状态(不存 DOM 引用);history 顺序旧→新
|
// 模块级仅存终端语义状态(不存 DOM 引用);history 顺序旧→新
|
||||||
const history: string[] = [];
|
const history: string[] = [];
|
||||||
let histPos = 0;
|
let histPos = 0;
|
||||||
|
|
||||||
function isTerminalPage(): boolean {
|
function isTerminalPage(): boolean {
|
||||||
return location.pathname === PATH || location.pathname === '/terminal';
|
return TERM_PATHS.has(location.pathname.replace(/\/$/, ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
function out(): HTMLElement | null {
|
function out(): HTMLElement | null {
|
||||||
@@ -120,18 +184,19 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
histPos = history.length;
|
histPos = history.length;
|
||||||
|
|
||||||
const [cmd, ...args] = line.split(/\s+/);
|
const [cmd, ...args] = line.split(/\s+/);
|
||||||
|
const t = L();
|
||||||
switch (cmd) {
|
switch (cmd) {
|
||||||
case 'help': {
|
case 'help': {
|
||||||
// 注册表即帮助:单一事实源
|
// 注册表即帮助:单一事实源
|
||||||
const width = Math.max(...COMMANDS.map((c) => c.name.length)) + 2;
|
const width = Math.max(...t.cmds.map((c) => c.name.length)) + 2;
|
||||||
for (const c of COMMANDS) print(`${c.name.padEnd(width)}${c.desc}`);
|
for (const c of t.cmds) print(`${c.name.padEnd(width)}${c.desc}`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'whoami':
|
case 'whoami':
|
||||||
print('wait4x —— 阅读 · 技术 · 摄影,博客在 gitea 开源(open gitea)');
|
print(t.whoami);
|
||||||
break;
|
break;
|
||||||
case 'ls':
|
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;
|
break;
|
||||||
case 'theme': {
|
case 'theme': {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
@@ -145,7 +210,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
const target = args[0];
|
const target = args[0];
|
||||||
const url = target ? OPEN_TARGETS[target] : undefined;
|
const url = target ? OPEN_TARGETS[target] : undefined;
|
||||||
if (!url) {
|
if (!url) {
|
||||||
print(`用法: open <target>;可用: ${Object.keys(OPEN_TARGETS).join(', ')}`, 'dim');
|
print(`${t.usage}${Object.keys(OPEN_TARGETS).join(', ')}`, 'dim');
|
||||||
} else {
|
} else {
|
||||||
window.open(url, '_blank', 'noopener');
|
window.open(url, '_blank', 'noopener');
|
||||||
print(`opening ${url} …`, 'dim');
|
print(`opening ${url} …`, 'dim');
|
||||||
@@ -162,11 +227,11 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
case 'exit':
|
case 'exit':
|
||||||
print('logout', 'dim');
|
print('logout', 'dim');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
location.href = '/';
|
location.href = t.home;
|
||||||
}, 600);
|
}, 600);
|
||||||
break;
|
break;
|
||||||
default:
|
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 {
|
function tabComplete(current: string): void {
|
||||||
if (!current) return;
|
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) {
|
if (hits.length === 1) {
|
||||||
const el = input();
|
const el = input();
|
||||||
if (el) el.value = hits[0];
|
if (el) el.value = hits[0];
|
||||||
|
|||||||
Reference in new issue
Block a user