mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-24 18:50:15 +00:00
v0.50.264 polish followups: i18n parity + assistant-output readability
Closes #1442 (server-side _LOGIN_LOCALE missing ja/pt/ko) Closes #1443 (promote _isImeEnter helper to 6 other Safari Enter guards) Closes #1446 (glued-bold-heading lift for LLM thinking-block output) Closes #1447 (markdown heading visual hierarchy in chat messages) All four issues were filed by the Opus pre-release advisor on the v0.50.264 batch or by Cygnus via Discord (relayed by @AvidFuturist, May 1 2026). They share a common shape — narrow, well-scoped, independent of each other, all adding regression tests. == #1442: _LOGIN_LOCALE parity (api/routes.py + static/i18n.js) == Added entries for ja/pt/ko to the server-side _LOGIN_LOCALE dict that renders the localized login page BEFORE the JS i18n bundle loads. With v0.50.264 shipping Japanese as the 8th built-in locale, ja/pt/ko users were seeing the English login page even with their language preference set. While auditing static/i18n.js for English leakage, also fixed: - ko: 10 user-facing login/sign-out/password keys still in English - es: 3 sign-out/auth-disabled keys still in English Tests: tests/test_login_locale_parity.py (20 tests) — pins both invariants: (a) every locale in i18n.js LOCALES has a matching _LOGIN_LOCALE entry (b) every locale's login-flow keys (13 of them) are translated, not English == #1443: window._isImeEnter promotion == PR #1441 fixed the Safari IME-composition Enter race in the chat composer (`#msg`) by widening the guard from `e.isComposing` to a `_isImeEnter(e)` helper that combines three signals (isComposing || keyCode===229 || _imeComposing flag). Six other Enter-input handlers were left on the original narrow guard and would still drop IME composition Enters on Safari for Japanese/Chinese/Korean users. Promoted the helper to `window._isImeEnter` (defined in static/boot.js) and replaced the `e.isComposing` guards at all six sites: - static/sessions.js: session rename, project create, project rename - static/ui.js: app dialog (confirm/prompt), message edit, workspace rename The state-free part of the helper (`isComposing || keyCode===229`) handles Safari's race for any focused input without needing per-input composition listeners — only `#msg` keeps the local `_imeComposing` flag. Tests: - tests/test_issue1443_ime_helper_promotion.py (9 tests) — pins each site + verifies no raw `e.isComposing` Enter-guards remain in sessions.js/ui.js - tests/test_ime_composition.py — alternation regex extended to accept the windowed helper form (loosen-test-on-shape-change pattern from v0.50.264 reflection notes) == #1446: glued-bold-heading lift (static/ui.js renderMd + Python mirror) == LLMs in thinking/reasoning mode emit "section headers" glued to the end of the previous paragraph with no whitespace: Para 1 text.**Heading to Para 2** Para 2 text.**Heading to Para 3** The renderer correctly produces inline `<strong>` per CommonMark, but it looks like trailing emphasis on the body text rather than a section break. Cygnus reported this as "Markdown feedback 2 of 3." Added a single regex pre-pass in renderMd(): s.replace(/([.!?])\*\*([^*\n]{1,80})\*\*\n\n/g, '$1\n\n**$2**\n\n') Constraints chosen to avoid false positives: - Trigger only on `[.!?]` IMMEDIATELY before `**` (no space) — almost always an LLM-glued heading, not intentional emphasis - Inner text ≤80 chars, no `*` or newline (single-line only) - Trailing `\n\n` required — preserves "this is **important** to know." mid-paragraph emphasis untouched - Position: after rawPreStash restore, before fence_stash restore — fenced code blocks stay protected (their content is `\x00P` / `\x00F` tokens when the lift runs) Mirrored in tests/test_sprint16.py render_md() so both stay in sync. Tests: tests/test_issue1446_glued_heading_lift.py (17 tests, 5 of which drive the actual ui.js renderMd via node) — covers all 3 trigger forms (.!?), all 4 preserve-emphasis cases the issue spec'd, fenced/inline code protection, chained glued headings, source-level position pin, regex shape pin. == #1447: markdown heading visual hierarchy (static/style.css) == Pre-fix sizes in `.msg-body`: h1 18px, h2 16px, h3 14px (= body), h4 13px, h5 12px, h6 11px So h3 was indistinguishable from body and h4/h5/h6 were SMALLER than body. Cygnus's report: "Markdown feedback 3 of 3 — Headings seem to be missing across the board in Hermes. They're there, but all plaintext." New sizes: h1 24px (border-bottom) h2 20px (border-bottom) h3 17px h4 15px h5 14px (uppercase, tracked) h6 13px (uppercase, tracked, muted) All headings now `font-weight:700` + `color:var(--strong)` for stronger ink. h5/h6 use uppercase + letter-spacing for "label-style" affordance instead of being smaller-than-body. Synced .preview-md (file preview pane) to match exactly so a markdown file preview and a chat message render identically. Added missing h4/h5/h6 rules to .preview-md (it only had h1-h3 before). Updated data-font-size="small"/"large" h1-h6 overrides to scale proportionally with the new defaults. Hierarchy preserved at all three font-size settings. Tests: tests/test_issue1447_heading_hierarchy.py (9 tests) — pins the size hierarchy, the bottom borders on h1/h2, the uppercase affordance on h5/h6, the .preview-md sync, and the small/large override scaling. == Verification == pytest tests/ -q → 3748 passed (+56 new) bash ~/WebUI/scripts/run-browser-tests.sh → 20 + 11 PASS bash ~/WebUI/scripts/webui_qa_agent.sh 8789 → 23/23 PASS Visual confirmation in browser at port 8789: - Heading hierarchy clearly visible at all 6 levels - Glued-bold lift produces separate paragraphs as designed - window._isImeEnter accessible from any module after boot.js - Login page renders ja/pt/ko strings correctly (curl -s /login)
This commit is contained in:
@@ -906,6 +906,36 @@ _LOGIN_LOCALE = {
|
||||
"invalid_pw": "\u5bc6\u78bc\u932f\u8aa4",
|
||||
"conn_failed": "\u9023\u63a5\u5931\u6557",
|
||||
},
|
||||
# Strings mirror static/i18n.js login_* keys for the corresponding locale.
|
||||
# See issue #1442. When adding a new locale to LOCALES in i18n.js, also add
|
||||
# the matching entry here — tests/test_login_locale_parity.py enforces this.
|
||||
"ja": {
|
||||
"lang": "ja-JP",
|
||||
"title": "\u30b5\u30a4\u30f3\u30a4\u30f3",
|
||||
"subtitle": "\u30d1\u30b9\u30ef\u30fc\u30c9\u3092\u5165\u529b\u3057\u3066\u7d9a\u884c",
|
||||
"placeholder": "\u30d1\u30b9\u30ef\u30fc\u30c9",
|
||||
"btn": "\u30b5\u30a4\u30f3\u30a4\u30f3",
|
||||
"invalid_pw": "\u30d1\u30b9\u30ef\u30fc\u30c9\u304c\u7121\u52b9\u3067\u3059",
|
||||
"conn_failed": "\u63a5\u7d9a\u5931\u6557",
|
||||
},
|
||||
"pt": {
|
||||
"lang": "pt-BR",
|
||||
"title": "Entrar",
|
||||
"subtitle": "Digite sua senha para continuar",
|
||||
"placeholder": "Senha",
|
||||
"btn": "Entrar",
|
||||
"invalid_pw": "Senha inv\u00e1lida",
|
||||
"conn_failed": "Falha na conex\u00e3o",
|
||||
},
|
||||
"ko": {
|
||||
"lang": "ko-KR",
|
||||
"title": "\ub85c\uadf8\uc778",
|
||||
"subtitle": "\uacc4\uc18d\ud558\ub824\uba74 \ube44\ubc00\ubc88\ud638\ub97c \uc785\ub825\ud558\uc138\uc694",
|
||||
"placeholder": "\ube44\ubc00\ubc88\ud638",
|
||||
"btn": "\ub85c\uadf8\uc778",
|
||||
"invalid_pw": "\ube44\ubc00\ubc88\ud638\uac00 \uc62c\ubc14\ub974\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4",
|
||||
"conn_failed": "\uc5f0\uacb0 \uc2e4\ud328",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -813,6 +813,14 @@ $('msg').addEventListener('input',()=>{
|
||||
// manual flag and reset it on the next tick to swallow that trailing Enter.
|
||||
// Also reset on blur so the flag can never get stuck in a true state if
|
||||
// compositionend never fires (focus loss with some IME implementations).
|
||||
//
|
||||
// The `_imeComposing` flag is bound to the chat composer (`#msg`); other
|
||||
// inputs (session/project rename, app dialog, message edit, workspace rename)
|
||||
// rely on the state-free `e.isComposing || e.keyCode === 229` part of
|
||||
// `_isImeEnter`, which is sufficient for the Safari race because keyCode 229
|
||||
// is the canonical "still composing" signal regardless of which field is
|
||||
// focused. Promote `_isImeEnter` to `window` so other modules can reuse it
|
||||
// without duplicating the full IIFE per input (issue #1443).
|
||||
let _imeComposing=false;
|
||||
(()=>{const _c=$('msg');if(!_c)return;
|
||||
_c.addEventListener('compositionstart',()=>{_imeComposing=true;});
|
||||
@@ -820,6 +828,7 @@ let _imeComposing=false;
|
||||
_c.addEventListener('blur',()=>{_imeComposing=false;});
|
||||
})();
|
||||
function _isImeEnter(e){return e.isComposing||e.keyCode===229||_imeComposing;}
|
||||
window._isImeEnter=_isImeEnter;
|
||||
$('msg').addEventListener('keydown',e=>{
|
||||
// Autocomplete navigation when dropdown is open
|
||||
const dd=$('cmdDropdown');
|
||||
|
||||
+17
-13
@@ -3146,10 +3146,10 @@ const LOCALES = {
|
||||
active_conversation_none: 'No active conversation selected.',
|
||||
active_conversation_meta: (title, count) => `${title} · ${count} message${count === 1 ? '' : 's'}`,
|
||||
settings_unsaved_changes: 'You have unsaved changes.',
|
||||
sign_out_failed: 'Sign out failed: ',
|
||||
disable_auth_confirm_title: 'Disable password protection',
|
||||
sign_out_failed: 'Error al cerrar sesión: ',
|
||||
disable_auth_confirm_title: 'Desactivar protección por contraseña',
|
||||
disable_auth_confirm_message: 'Anyone will be able to access this instance.',
|
||||
auth_disabled: 'Auth disabled — password protection removed',
|
||||
auth_disabled: 'Autenticación desactivada — protección por contraseña eliminada',
|
||||
disable_auth_failed: 'Failed to disable auth: ',
|
||||
bg_error_single: (title) => `"${title}" has encountered an error`,
|
||||
bg_error_multi: (count) => `${count} sessions have encountered an error`,
|
||||
@@ -6596,6 +6596,10 @@ const LOCALES = {
|
||||
voice_thinking: 'Thinking…', // TODO: translate
|
||||
voice_toggle: 'Voice input', // TODO: translate
|
||||
subagent_children: 'Subagent sessions', // TODO: translate
|
||||
// login-flow keys (issue #1442)
|
||||
sign_out_failed: 'Falha ao sair: ',
|
||||
auth_disabled: 'Autenticação desativada — proteção por senha removida',
|
||||
disable_auth_confirm_title: 'Desativar proteção por senha',
|
||||
},
|
||||
ko: {
|
||||
_lang: 'ko',
|
||||
@@ -6979,15 +6983,15 @@ const LOCALES = {
|
||||
settings_saved: '설정 저장됨',
|
||||
settings_save_failed: '설정 저장 실패: ',
|
||||
settings_load_failed: '설정 로드 실패: ',
|
||||
settings_saved_pw: 'Settings saved — password protection enabled and this browser stays signed in',
|
||||
settings_saved_pw_updated: 'Settings saved — password updated',
|
||||
settings_saved_pw: '설정이 저장되었습니다 — 비밀번호 보호가 활성화되었으며 이 브라우저는 로그인 상태로 유지됩니다',
|
||||
settings_saved_pw_updated: '설정이 저장되었습니다 — 비밀번호가 업데이트되었습니다',
|
||||
// login page (used server-side via /api/i18n/login endpoint)
|
||||
login_title: '로그인',
|
||||
login_subtitle: '계속하려면 비밀번호를 입력하세요.',
|
||||
login_placeholder: 'Password',
|
||||
login_btn: 'Sign in',
|
||||
login_invalid_pw: 'Invalid password',
|
||||
login_conn_failed: 'Connection failed',
|
||||
login_placeholder: '비밀번호',
|
||||
login_btn: '로그인',
|
||||
login_invalid_pw: '비밀번호가 올바르지 않습니다',
|
||||
login_conn_failed: '연결 실패',
|
||||
// Sidebar & Tabs
|
||||
tab_chat: '채팅',
|
||||
tab_tasks: '작업',
|
||||
@@ -7043,7 +7047,7 @@ const LOCALES = {
|
||||
settings_desc_check_updates: 'WebUI 또는 Agent의 새 버전이 있으면 배너를 표시합니다. 백그라운드에서 주기적으로 git fetch를 실행합니다.',
|
||||
settings_desc_bot_name: 'UI 전체에 표시되는 Assistant 이름입니다. 기본값은 Hermes입니다.',
|
||||
settings_desc_password: '새 비밀번호를 설정하거나 변경하려면 입력하세요. 현재 설정을 유지하려면 비워 두세요.',
|
||||
password_placeholder: 'Enter new password…',
|
||||
password_placeholder: '새 비밀번호 입력…',
|
||||
disable_auth: '인증 비활성화',
|
||||
sign_out: '로그아웃',
|
||||
// Providers panel
|
||||
@@ -7308,10 +7312,10 @@ const LOCALES = {
|
||||
active_conversation_none: 'No active conversation selected.',
|
||||
active_conversation_meta: (title, count) => `${title} · ${count} message${count === 1 ? '' : 's'}`,
|
||||
settings_unsaved_changes: 'You have unsaved changes.',
|
||||
sign_out_failed: 'Sign out failed: ',
|
||||
disable_auth_confirm_title: 'Disable password protection',
|
||||
sign_out_failed: '로그아웃 실패: ',
|
||||
disable_auth_confirm_title: '비밀번호 보호 비활성화',
|
||||
disable_auth_confirm_message: 'Anyone will be able to access this instance.',
|
||||
auth_disabled: 'Auth disabled — password protection removed',
|
||||
auth_disabled: '인증 비활성화 — 비밀번호 보호가 제거되었습니다',
|
||||
disable_auth_failed: 'Failed to disable auth: ',
|
||||
bg_error_single: (title) => `"${title}" has encountered an error`,
|
||||
bg_error_multi: (count) => `${count} sessions have encountered an error`,
|
||||
|
||||
+3
-3
@@ -1692,7 +1692,7 @@ function renderSessionListFromCache(){
|
||||
};
|
||||
inp.onkeydown=e2=>{
|
||||
if(e2.key==='Enter'){
|
||||
if(e2.isComposing){return;}
|
||||
if(window._isImeEnter&&window._isImeEnter(e2)){return;}
|
||||
e2.preventDefault();
|
||||
e2.stopPropagation();
|
||||
finish(true);
|
||||
@@ -1986,7 +1986,7 @@ function _startProjectCreate(bar, addBtn){
|
||||
};
|
||||
inp.onkeydown=(e)=>{
|
||||
if(e.key==='Enter'){
|
||||
if(e.isComposing){return;}
|
||||
if(window._isImeEnter&&window._isImeEnter(e)){return;}
|
||||
e.preventDefault();
|
||||
finish(true);
|
||||
}
|
||||
@@ -2014,7 +2014,7 @@ function _startProjectRename(proj, chip){
|
||||
};
|
||||
inp.onkeydown=(e)=>{
|
||||
if(e.key==='Enter'){
|
||||
if(e.isComposing){return;}
|
||||
if(window._isImeEnter&&window._isImeEnter(e)){return;}
|
||||
e.preventDefault();
|
||||
finish(true);
|
||||
}
|
||||
|
||||
+30
-18
@@ -35,18 +35,18 @@
|
||||
/* Chat message body */
|
||||
:root[data-font-size="small"] .msg-body { font-size: 12px; }
|
||||
:root[data-font-size="large"] .msg-body { font-size: 16px; }
|
||||
:root[data-font-size="small"] .msg-body h1 { font-size: 15px; }
|
||||
:root[data-font-size="large"] .msg-body h1 { font-size: 21px; }
|
||||
:root[data-font-size="small"] .msg-body h2 { font-size: 13px; }
|
||||
:root[data-font-size="large"] .msg-body h2 { font-size: 19px; }
|
||||
:root[data-font-size="small"] .msg-body h3 { font-size: 12px; }
|
||||
:root[data-font-size="large"] .msg-body h3 { font-size: 16px; }
|
||||
:root[data-font-size="small"] .msg-body h4 { font-size: 11px; }
|
||||
:root[data-font-size="large"] .msg-body h4 { font-size: 15px; }
|
||||
:root[data-font-size="small"] .msg-body h5 { font-size: 11px; }
|
||||
:root[data-font-size="large"] .msg-body h5 { font-size: 14px; }
|
||||
:root[data-font-size="small"] .msg-body h6 { font-size: 10px; }
|
||||
:root[data-font-size="large"] .msg-body h6 { font-size: 13px; }
|
||||
:root[data-font-size="small"] .msg-body h1 { font-size: 21px; }
|
||||
:root[data-font-size="large"] .msg-body h1 { font-size: 27px; }
|
||||
:root[data-font-size="small"] .msg-body h2 { font-size: 17px; }
|
||||
:root[data-font-size="large"] .msg-body h2 { font-size: 23px; }
|
||||
:root[data-font-size="small"] .msg-body h3 { font-size: 15px; }
|
||||
:root[data-font-size="large"] .msg-body h3 { font-size: 20px; }
|
||||
:root[data-font-size="small"] .msg-body h4 { font-size: 13px; }
|
||||
:root[data-font-size="large"] .msg-body h4 { font-size: 17px; }
|
||||
:root[data-font-size="small"] .msg-body h5 { font-size: 12px; }
|
||||
:root[data-font-size="large"] .msg-body h5 { font-size: 16px; }
|
||||
:root[data-font-size="small"] .msg-body h6 { font-size: 11px; }
|
||||
:root[data-font-size="large"] .msg-body h6 { font-size: 15px; }
|
||||
:root[data-font-size="small"] .msg-body code { font-size: 10.5px; }
|
||||
:root[data-font-size="large"] .msg-body code { font-size: 14.5px; }
|
||||
:root[data-font-size="small"] .msg-body pre code { font-size: 11px; }
|
||||
@@ -707,9 +707,17 @@
|
||||
.msg-body{font-size:14px;line-height:1.75;color:var(--text);padding-left:30px;max-width:680px;overflow-wrap:anywhere;}
|
||||
.msg-body p{margin-bottom:10px;}.msg-body p:last-child{margin-bottom:0;}
|
||||
.msg-body ul,.msg-body ol{margin:6px 0 10px 20px;}.msg-body li{margin-bottom:3px;}
|
||||
.msg-body h1,.msg-body h2,.msg-body h3,.msg-body h4,.msg-body h5,.msg-body h6{margin:16px 0 6px;font-weight:600;}
|
||||
.msg-body h1{font-size:18px;}.msg-body h2{font-size:16px;}.msg-body h3{font-size:14px;}
|
||||
.msg-body h4{font-size:13px;}.msg-body h5{font-size:12px;}.msg-body h6{font-size:11px;color:var(--muted);}
|
||||
.msg-body h1,.msg-body h2,.msg-body h3,.msg-body h4,.msg-body h5,.msg-body h6{font-weight:700;color:var(--strong,var(--text));line-height:1.3;}
|
||||
.msg-body h1{font-size:24px;margin:24px 0 12px;border-bottom:1px solid var(--border);padding-bottom:6px;}
|
||||
.msg-body h2{font-size:20px;margin:22px 0 10px;border-bottom:1px solid var(--border);padding-bottom:4px;}
|
||||
.msg-body h3{font-size:17px;margin:20px 0 8px;}
|
||||
.msg-body h4{font-size:15px;margin:18px 0 8px;}
|
||||
.msg-body h5{font-size:14px;margin:16px 0 6px;text-transform:uppercase;letter-spacing:0.04em;}
|
||||
.msg-body h6{font-size:13px;margin:14px 0 6px;color:var(--muted);text-transform:uppercase;letter-spacing:0.04em;}
|
||||
/* First-heading-of-message: collapse the top margin so it doesn't push the
|
||||
entire message down (the row already has spacing). */
|
||||
.msg-body > h1:first-child,.msg-body > h2:first-child,.msg-body > h3:first-child,
|
||||
.msg-body > h4:first-child,.msg-body > h5:first-child,.msg-body > h6:first-child{margin-top:0;}
|
||||
.msg-body strong{color:var(--strong);font-weight:600;}.msg-body em{color:var(--em);font-style:italic;}
|
||||
.msg-body code{font-family:"SF Mono","Fira Code",ui-monospace,monospace;font-size:12.5px;background:var(--code-inline-bg);padding:1px 5px;border-radius:4px;color:var(--code-text);}
|
||||
.msg-body pre{background:var(--code-bg);border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;margin:10px 0;}
|
||||
@@ -1096,9 +1104,13 @@
|
||||
/* Markdown rendered preview */
|
||||
.preview-md{font-size:13px;line-height:1.7;color:var(--text);flex:1;overflow-y:auto;min-height:0;}
|
||||
.preview-md p{margin-bottom:10px;}.preview-md p:last-child{margin-bottom:0;}
|
||||
.preview-md h1{font-size:18px;font-weight:700;margin:16px 0 8px;color:var(--strong);border-bottom:1px solid var(--border);padding-bottom:6px;}
|
||||
.preview-md h2{font-size:15px;font-weight:600;margin:14px 0 6px;color:var(--strong);}
|
||||
.preview-md h3{font-size:13px;font-weight:600;margin:12px 0 4px;color:var(--strong);}
|
||||
.preview-md h1,.preview-md h2,.preview-md h3,.preview-md h4,.preview-md h5,.preview-md h6{font-weight:700;color:var(--strong,var(--text));line-height:1.3;}
|
||||
.preview-md h1{font-size:24px;margin:24px 0 12px;border-bottom:1px solid var(--border);padding-bottom:6px;}
|
||||
.preview-md h2{font-size:20px;margin:22px 0 10px;border-bottom:1px solid var(--border);padding-bottom:4px;}
|
||||
.preview-md h3{font-size:17px;margin:20px 0 8px;}
|
||||
.preview-md h4{font-size:15px;margin:18px 0 8px;}
|
||||
.preview-md h5{font-size:14px;margin:16px 0 6px;text-transform:uppercase;letter-spacing:0.04em;}
|
||||
.preview-md h6{font-size:13px;margin:14px 0 6px;color:var(--muted);text-transform:uppercase;letter-spacing:0.04em;}
|
||||
.preview-md ul,.preview-md ol{margin:4px 0 10px 18px;}.preview-md li{margin-bottom:3px;}
|
||||
.preview-md code{font-family:"SF Mono",ui-monospace,monospace;font-size:11.5px;background:var(--code-inline-bg);padding:1px 5px;border-radius:4px;color:var(--code-text);}
|
||||
.preview-md pre{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;padding:10px 12px;overflow-x:auto;margin:8px 0;}
|
||||
|
||||
+28
-3
@@ -1629,6 +1629,31 @@ function renderMd(raw){
|
||||
s=s.replace(/<code>([^<]*?)<\/code>/gi,(_,t)=>'`'+t+'`');
|
||||
s=s.replace(/<br\s*\/?>/gi,'\n');
|
||||
s=s.replace(/\x00R(\d+)\x00/g,(_,i)=>rawPreStash[+i]);
|
||||
// ── Glued-bold-heading lift (issue #1446) ────────────────────────────────
|
||||
// LLMs in thinking/reasoning mode frequently emit a "section header" glued
|
||||
// to the end of the previous paragraph with no whitespace, like:
|
||||
//
|
||||
// Para 1 text.**Heading to Para 2**
|
||||
//
|
||||
// Para 2 text.**Heading to Para 3**
|
||||
//
|
||||
// CommonMark renders that correctly as paragraph-end inline bold, but the
|
||||
// visual effect is a run-on label rather than a section break. Lift the
|
||||
// glued bold into its own paragraph when it follows a sentence terminator
|
||||
// and is followed by a blank line.
|
||||
//
|
||||
// Constraints (avoid false positives):
|
||||
// - Trigger only on a sentence terminator (.!?) IMMEDIATELY before `**`
|
||||
// (no space) — that pattern is almost always a glued heading, not
|
||||
// intentional emphasis.
|
||||
// - Inner text length ≤ 80 chars — long bold runs are usually emphasis
|
||||
// prose, not headings.
|
||||
// - Trailing `\n\n` required — preserves mid-paragraph emphasis like
|
||||
// "this is **important**." untouched.
|
||||
// - Inner text must not contain newlines or `*` (single-line bold only).
|
||||
// - Runs after fenced code, math, and raw <pre> are stashed, so code
|
||||
// content is protected (see pipeline notes).
|
||||
s=s.replace(/([.!?])\*\*([^*\n]{1,80})\*\*\n\n/g,'$1\n\n**$2**\n\n');
|
||||
// Inline backtick spans: restore <code> tags produced in the stash callback above.
|
||||
// Must happen BEFORE bold/italic so **`code`** → <strong><code>code</code></strong>.
|
||||
s=s.replace(/\x00F(\d+)\x00/g,(_,i)=>fence_stash[+i]);
|
||||
@@ -2487,7 +2512,7 @@ function _ensureAppDialogBindings(){
|
||||
return;
|
||||
}
|
||||
if(e.key==='Enter'){
|
||||
if(e.isComposing) return;
|
||||
if(window._isImeEnter&&window._isImeEnter(e)) return;
|
||||
const target=e.target;
|
||||
const isTextarea=target&&target.tagName==='TEXTAREA';
|
||||
if(!isTextarea){
|
||||
@@ -4104,7 +4129,7 @@ function editMessage(btn) {
|
||||
bar.querySelector('.msg-edit-cancel').onclick = () => cancelEdit(row, originalText, body);
|
||||
|
||||
ta.addEventListener('keydown', e => {
|
||||
if(e.key==='Enter' && !e.shiftKey) { if(e.isComposing) return; e.preventDefault(); bar.querySelector('.msg-edit-send').click(); }
|
||||
if(e.key==='Enter' && !e.shiftKey) { if(window._isImeEnter&&window._isImeEnter(e)) return; e.preventDefault(); bar.querySelector('.msg-edit-send').click(); }
|
||||
if(e.key==='Escape') { e.preventDefault(); cancelEdit(row, originalText, body); }
|
||||
});
|
||||
}
|
||||
@@ -5006,7 +5031,7 @@ function _renderTreeItems(container, entries, depth){
|
||||
};
|
||||
inp.onkeydown=(e2)=>{
|
||||
if(e2.key==='Enter'){
|
||||
if(e2.isComposing){return;}
|
||||
if(window._isImeEnter&&window._isImeEnter(e2)){return;}
|
||||
e2.preventDefault();
|
||||
finish(true);
|
||||
}
|
||||
|
||||
@@ -9,16 +9,20 @@ SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _ime_guarded_enter_pattern(event_var_pattern, require_no_shift=False):
|
||||
"""Accept both the original `e.isComposing` guard AND the broader
|
||||
`_isImeEnter(e)` helper introduced in PR #1441 (which folds in
|
||||
`keyCode===229` and a manual `_imeComposing` flag for Safari).
|
||||
"""Accept the IME guard in any of three shapes that have been used in the codebase:
|
||||
|
||||
1. Original `e.isComposing` (pre-#1441)
|
||||
2. Module-local `_isImeEnter(e)` (PR #1441 — chat composer in boot.js)
|
||||
3. Window-exposed `window._isImeEnter(e)` (issue #1443 — promoted to ui.js,
|
||||
sessions.js so all 6 Enter-input sites use the same Safari-aware guard).
|
||||
"""
|
||||
no_shift = rf"\s*&&\s*!\s*{event_var_pattern}\.shiftKey" if require_no_shift else ""
|
||||
# Either: if(e.isComposing) ... OR if(_isImeEnter(e)) ...
|
||||
# Either: if(e.isComposing) ... | if(_isImeEnter(e)) ... | if(window._isImeEnter&&window._isImeEnter(e)) ...
|
||||
guard = (
|
||||
rf"if\s*\(\s*"
|
||||
rf"(?:{event_var_pattern}\.isComposing"
|
||||
rf"|_isImeEnter\(\s*{event_var_pattern}\s*\))"
|
||||
rf"|_isImeEnter\(\s*{event_var_pattern}\s*\)"
|
||||
rf"|window\._isImeEnter\s*&&\s*window\._isImeEnter\s*\(\s*{event_var_pattern}\s*\))"
|
||||
rf"\s*\)\s*"
|
||||
)
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Regression tests for issue #1443 — promote `_isImeEnter` to all Safari-affected Enter guards.
|
||||
|
||||
PR #1441 (v0.50.264) widened the chat composer's IME-Enter guard from `e.isComposing`
|
||||
to a `_isImeEnter(e)` helper in `static/boot.js`. The helper combines three signals
|
||||
(`e.isComposing || e.keyCode === 229 || _imeComposing`) so it catches the Safari race
|
||||
where the committing keydown for an IME composition fires AFTER `compositionend` with
|
||||
`isComposing=false`.
|
||||
|
||||
Six other Enter-input handlers were left on the original `e.isComposing` guard:
|
||||
|
||||
- `static/sessions.js` — session rename (~line 1693)
|
||||
- `static/sessions.js` — project create (~line 1987)
|
||||
- `static/sessions.js` — project rename (~line 2015)
|
||||
- `static/ui.js` — app dialog (confirm/prompt) (~line 2482)
|
||||
- `static/ui.js` — message edit (Enter to save) (~line 4106)
|
||||
- `static/ui.js` — workspace rename (~line 5007)
|
||||
|
||||
Issue #1443 promotes the helper to `window._isImeEnter` (defined in boot.js) and
|
||||
replaces the 6 `e.isComposing` guards with `window._isImeEnter(e)`. These tests pin
|
||||
each site so a future cleanup that strips the windowed call trips a test.
|
||||
|
||||
The state-free part of the helper (`e.isComposing || e.keyCode === 229`) is what the
|
||||
6 non-composer sites rely on — it works for any focused input on Safari without needing
|
||||
per-input composition listeners or a per-input flag.
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
|
||||
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _windowed_guard(ev: str) -> str:
|
||||
"""Return regex for `if(window._isImeEnter && window._isImeEnter(<ev>))` shape."""
|
||||
return (
|
||||
rf"if\s*\(\s*window\._isImeEnter\s*&&\s*"
|
||||
rf"window\._isImeEnter\s*\(\s*{ev}\s*\)\s*\)\s*"
|
||||
)
|
||||
|
||||
|
||||
# ── Promotion: `window._isImeEnter` is exported from boot.js ─────────────────
|
||||
|
||||
|
||||
def test_isimeenter_helper_is_exposed_on_window():
|
||||
"""boot.js must attach `_isImeEnter` to `window` so other modules can reuse it."""
|
||||
assert re.search(
|
||||
r"window\._isImeEnter\s*=\s*_isImeEnter\s*;?",
|
||||
BOOT_JS,
|
||||
), (
|
||||
"boot.js must export `window._isImeEnter = _isImeEnter` so "
|
||||
"static/sessions.js and static/ui.js can call the same Safari-aware "
|
||||
"helper without duplicating the IIFE per input (issue #1443)."
|
||||
)
|
||||
|
||||
|
||||
# ── No raw `e.isComposing` guards remain in the 6 non-composer sites ─────────
|
||||
|
||||
|
||||
def test_no_isComposing_guards_remain_in_sessions_js():
|
||||
"""sessions.js must not contain a raw `e.isComposing` Enter-guard anymore."""
|
||||
leaks = re.findall(r"\b(?:e2?)\.isComposing\b", SESSIONS_JS)
|
||||
assert not leaks, (
|
||||
f"sessions.js still contains {len(leaks)} raw `e.isComposing` guard(s); "
|
||||
f"all Enter-input handlers should route through window._isImeEnter "
|
||||
f"(issue #1443)."
|
||||
)
|
||||
|
||||
|
||||
def test_no_isComposing_guards_remain_in_ui_js():
|
||||
"""ui.js must not contain a raw `e.isComposing` Enter-guard anymore."""
|
||||
leaks = re.findall(r"\b(?:e2?)\.isComposing\b", UI_JS)
|
||||
assert not leaks, (
|
||||
f"ui.js still contains {len(leaks)} raw `e.isComposing` guard(s); "
|
||||
f"all Enter-input handlers should route through window._isImeEnter "
|
||||
f"(issue #1443)."
|
||||
)
|
||||
|
||||
|
||||
# ── Each of the 6 specific sites uses the promoted helper ────────────────────
|
||||
|
||||
|
||||
def test_session_rename_uses_windowed_helper():
|
||||
"""Session rename (sessions.js ~1693) must use window._isImeEnter."""
|
||||
# The session rename block: `inp.onkeydown=e2=>{ if(e2.key==='Enter'){ <guard> ...
|
||||
pattern = re.compile(
|
||||
r"inp\.onkeydown\s*=\s*e2\s*=>\s*\{\s*"
|
||||
r"if\s*\(\s*e2\.key\s*===\s*'Enter'\s*\)\s*\{\s*"
|
||||
+ _windowed_guard("e2"),
|
||||
re.DOTALL,
|
||||
)
|
||||
assert pattern.search(SESSIONS_JS), (
|
||||
"Session rename Enter handler in static/sessions.js must use "
|
||||
"window._isImeEnter(e2) (issue #1443)."
|
||||
)
|
||||
|
||||
|
||||
def test_project_create_and_rename_use_windowed_helper():
|
||||
"""Project create + project rename (sessions.js ~1987 and ~2015) both use window._isImeEnter."""
|
||||
# Both project blocks share the shape `inp.onkeydown=(e)=>{...}` (note the parens).
|
||||
pattern = re.compile(
|
||||
r"inp\.onkeydown\s*=\s*\(\s*e\s*\)\s*=>\s*\{\s*"
|
||||
r"if\s*\(\s*e\.key\s*===\s*'Enter'\s*\)\s*\{\s*"
|
||||
+ _windowed_guard("e"),
|
||||
re.DOTALL,
|
||||
)
|
||||
matches = pattern.findall(SESSIONS_JS)
|
||||
assert len(matches) >= 2, (
|
||||
f"Project create AND project rename Enter handlers in static/sessions.js "
|
||||
f"must both use window._isImeEnter(e); found {len(matches)} of 2 expected "
|
||||
f"(issue #1443)."
|
||||
)
|
||||
|
||||
|
||||
def test_app_dialog_uses_windowed_helper():
|
||||
"""App dialog confirm/prompt (ui.js ~2482) must use window._isImeEnter."""
|
||||
# Pattern: `document.addEventListener('keydown',e=>{ ... if(e.key==='Enter'){
|
||||
# if(window._isImeEnter && window._isImeEnter(e)) return;`
|
||||
pattern = re.compile(
|
||||
r"document\.addEventListener\(\s*'keydown'\s*,\s*e\s*=>\s*\{[\s\S]*?"
|
||||
r"if\s*\(\s*e\.key\s*===\s*'Enter'\s*\)\s*\{\s*"
|
||||
+ _windowed_guard("e"),
|
||||
re.DOTALL,
|
||||
)
|
||||
assert pattern.search(UI_JS), (
|
||||
"App dialog confirm/prompt Enter handler in static/ui.js must use "
|
||||
"window._isImeEnter(e) (issue #1443)."
|
||||
)
|
||||
|
||||
|
||||
def test_message_edit_uses_windowed_helper():
|
||||
"""Message edit Enter-to-save (ui.js ~4106) must use window._isImeEnter."""
|
||||
# Pattern: `ta.addEventListener('keydown', e => { if(e.key==='Enter' && !e.shiftKey)
|
||||
# { if(window._isImeEnter && window._isImeEnter(e)) return;`
|
||||
pattern = re.compile(
|
||||
r"ta\.addEventListener\(\s*'keydown'\s*,\s*e\s*=>\s*\{\s*"
|
||||
r"if\s*\(\s*e\.key\s*===\s*'Enter'\s*&&\s*!\s*e\.shiftKey\s*\)\s*\{\s*"
|
||||
+ _windowed_guard("e"),
|
||||
re.DOTALL,
|
||||
)
|
||||
assert pattern.search(UI_JS), (
|
||||
"Message edit Enter-to-save handler in static/ui.js must use "
|
||||
"window._isImeEnter(e) (issue #1443)."
|
||||
)
|
||||
|
||||
|
||||
def test_workspace_rename_uses_windowed_helper():
|
||||
"""Workspace rename (ui.js ~5007) must use window._isImeEnter."""
|
||||
# Pattern: `inp.onkeydown=(e2)=>{ if(e2.key==='Enter'){ if(window._isImeEnter && ...
|
||||
pattern = re.compile(
|
||||
r"inp\.onkeydown\s*=\s*\(\s*e2\s*\)\s*=>\s*\{\s*"
|
||||
r"if\s*\(\s*e2\.key\s*===\s*'Enter'\s*\)\s*\{\s*"
|
||||
+ _windowed_guard("e2"),
|
||||
re.DOTALL,
|
||||
)
|
||||
assert pattern.search(UI_JS), (
|
||||
"Workspace rename Enter handler in static/ui.js must use "
|
||||
"window._isImeEnter(e2) (issue #1443)."
|
||||
)
|
||||
|
||||
|
||||
# ── Helper still has the 3-guard shape (regression on PR #1441) ──────────────
|
||||
|
||||
|
||||
def test_isimeenter_still_has_three_guards():
|
||||
"""The helper itself must still combine all three guards. Promotion to `window`
|
||||
must not have stripped any of them."""
|
||||
pattern = re.compile(
|
||||
r"function\s+_isImeEnter\s*\(\s*e\s*\)\s*\{[^}]*"
|
||||
r"e\.isComposing"
|
||||
r"[^}]*"
|
||||
r"e\.keyCode\s*===\s*229"
|
||||
r"[^}]*"
|
||||
r"_imeComposing"
|
||||
r"[^}]*\}",
|
||||
re.DOTALL,
|
||||
)
|
||||
assert pattern.search(BOOT_JS), (
|
||||
"_isImeEnter must still combine e.isComposing, keyCode===229, and "
|
||||
"_imeComposing flag after promotion to window (PR #1441 + issue #1443)."
|
||||
)
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Regression tests for issue #1446 — glued-bold-heading lift in renderMd().
|
||||
|
||||
LLMs in thinking/reasoning mode frequently emit content shaped like:
|
||||
|
||||
Paragraph 1 text text.**Heading to Paragraph 2**
|
||||
|
||||
Paragraph 2 text text.**Heading to Paragraph 3**
|
||||
|
||||
Paragraph 3 text...
|
||||
|
||||
The renderer correctly produces (per CommonMark):
|
||||
|
||||
<p>Paragraph 1 text text.<strong>Heading to Paragraph 2</strong></p>
|
||||
<p>Paragraph 2 text text.<strong>Heading to Paragraph 3</strong></p>
|
||||
|
||||
But the visual effect is a "trailing emphasis on the body text" rather than a
|
||||
section header for what follows. Cygnus reported this in Discord (May 1 2026,
|
||||
relayed by @AvidFuturist).
|
||||
|
||||
Fix: pre-pass in `renderMd()` (and the Python mirror) lifts the glued bold into
|
||||
its own paragraph when it sits at the end of a paragraph, follows a sentence
|
||||
terminator (`.!?`), is reasonably short (≤80 chars), and is followed by a blank
|
||||
line. Mid-paragraph emphasis like "this is **important** to know." is preserved.
|
||||
|
||||
Behavioral tests below split into two sections:
|
||||
|
||||
- Python mirror tests use ``render_md`` from ``test_sprint16`` and verify
|
||||
the lift logic in cases where the mirror is faithful to the JS.
|
||||
- Node-driver tests at the bottom run against the ACTUAL ``static/ui.js``
|
||||
via ``node`` and pin the cases that depend on the JS-specific stash
|
||||
structure (fenced code, inline backticks) — these would false-fail
|
||||
against the simpler Python mirror.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.test_sprint16 import render_md
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
UI_JS_PATH = REPO / "static" / "ui.js"
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
# ── Behavior tests via the Python mirror ─────────────────────────────────────
|
||||
|
||||
|
||||
def test_glued_bold_after_period_lifts_to_own_paragraph():
|
||||
"""Sentence-glued **Heading** with period before it must be lifted to its own paragraph."""
|
||||
src = "Para text.**Bold heading**\n\nNext para."
|
||||
out = render_md(src)
|
||||
assert "<p>Para text.</p>" in out, f"Period sentence not isolated: {out!r}"
|
||||
assert "<p><strong>Bold heading</strong></p>" in out, (
|
||||
f"Lifted bold not in its own paragraph: {out!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_glued_bold_after_question_mark_lifts():
|
||||
"""Glued-bold after `?` should also lift — common in LLM reasoning mode."""
|
||||
src = "Why does this happen?**The answer**\n\nNext para."
|
||||
out = render_md(src)
|
||||
assert "<p>Why does this happen?</p>" in out, out
|
||||
assert "<p><strong>The answer</strong></p>" in out, out
|
||||
|
||||
|
||||
def test_glued_bold_after_exclamation_lifts():
|
||||
"""Glued-bold after `!` should also lift — emphatic transition."""
|
||||
src = "Found it!**Section title**\n\nMore text."
|
||||
out = render_md(src)
|
||||
assert "<p>Found it!</p>" in out, out
|
||||
assert "<p><strong>Section title</strong></p>" in out, out
|
||||
|
||||
|
||||
# ── Preserve-emphasis cases (no false positives) ─────────────────────────────
|
||||
|
||||
|
||||
def test_mid_paragraph_bold_unchanged():
|
||||
"""Bold mid-sentence with no period before and no `\\n\\n` after must NOT be lifted."""
|
||||
src = "This is **important** to know."
|
||||
out = render_md(src)
|
||||
assert "<p>This is <strong>important</strong> to know.</p>" in out, out
|
||||
|
||||
|
||||
def test_trailing_bold_without_period_unchanged():
|
||||
"""Bold at end of paragraph WITHOUT a sentence-terminator before it must stay inline."""
|
||||
src = "Some text **emphasis** here."
|
||||
out = render_md(src)
|
||||
assert "<strong>emphasis</strong>" in out
|
||||
assert "<p><strong>emphasis</strong></p>" not in out
|
||||
|
||||
|
||||
def test_trailing_bold_with_period_after_bold_unchanged():
|
||||
"""`text **important**.\\n\\n` (period AFTER bold) must NOT trigger the lift —
|
||||
the regex requires period IMMEDIATELY before the `**`."""
|
||||
src = "This is **important**.\n\nNext."
|
||||
out = render_md(src)
|
||||
assert "<p>This is <strong>important</strong>.</p>" in out, out
|
||||
assert "<p><strong>important</strong></p>" not in out
|
||||
|
||||
|
||||
def test_glued_bold_without_blank_line_unchanged():
|
||||
"""`text.**Bold**\\nMore text` (single newline, no blank line) must NOT be lifted —
|
||||
the regex requires `\\n\\n` after."""
|
||||
src = "Para.**Bold**\nMore text on next line."
|
||||
out = render_md(src)
|
||||
assert "<strong>Bold</strong>" in out
|
||||
assert "<p><strong>Bold</strong></p>" not in out
|
||||
|
||||
|
||||
def test_long_bold_phrase_not_lifted():
|
||||
"""Bold runs longer than 80 chars are likely emphasis prose, not headings — don't lift."""
|
||||
long_bold = "x" * 100
|
||||
src = f"Para.**{long_bold}**\n\nNext."
|
||||
out = render_md(src)
|
||||
assert f"<strong>{long_bold}</strong>" in out
|
||||
assert f"<p><strong>{long_bold}</strong></p>" not in out
|
||||
|
||||
|
||||
def test_intentional_block_final_bold_with_no_glue_unchanged():
|
||||
"""`text **bold**\\n\\n` (space before `**`, no glued period) must NOT be lifted."""
|
||||
src = "Para text **bold**\n\nNext."
|
||||
out = render_md(src)
|
||||
assert "<p>Para text <strong>bold</strong></p>" in out, out
|
||||
|
||||
|
||||
# ── Multi-occurrence + paragraph chain ───────────────────────────────────────
|
||||
|
||||
|
||||
def test_chain_of_glued_headings_all_lifted():
|
||||
"""Chained glued-heading paragraphs should all lift — common LLM thinking-mode shape."""
|
||||
src = (
|
||||
"First text.**Heading A**\n\n"
|
||||
"Second text.**Heading B**\n\n"
|
||||
"Third text.\n"
|
||||
)
|
||||
out = render_md(src)
|
||||
assert "<p>First text.</p>" in out, out
|
||||
assert "<p><strong>Heading A</strong></p>" in out, out
|
||||
assert "<p>Second text.</p>" in out, out
|
||||
assert "<p><strong>Heading B</strong></p>" in out, out
|
||||
assert "<p>Third text.</p>" in out, out
|
||||
|
||||
|
||||
# ── Source-level structural check on ui.js ───────────────────────────────────
|
||||
|
||||
|
||||
def test_lift_pass_present_in_ui_js_at_correct_position():
|
||||
"""The lift regex must be present in ui.js, between rawPreStash restore and fence_stash restore.
|
||||
|
||||
This pins the position so a future cleanup can't accidentally move the lift
|
||||
to a place where it would corrupt fenced code blocks (which are stashed as
|
||||
\\x00P / \\x00F tokens at this point and don't match the lift regex).
|
||||
"""
|
||||
lift_idx = UI_JS.find(r'(/([.!?])\*\*([^*\n]{1,80})\*\*\n\n/g')
|
||||
assert lift_idx > 0, "Glued-bold-heading lift regex not found in static/ui.js"
|
||||
raw_pre_restore = UI_JS.find("rawPreStash[+i]")
|
||||
fence_restore = UI_JS.find("fence_stash[+i]")
|
||||
assert raw_pre_restore > 0 and fence_restore > 0, "stash restore landmarks missing"
|
||||
assert raw_pre_restore < lift_idx < fence_restore, (
|
||||
"Glued-bold lift must sit between rawPreStash restore and fence_stash restore "
|
||||
"so fenced code is protected. Current ordering broken."
|
||||
)
|
||||
|
||||
|
||||
def test_lift_regex_is_single_line_only():
|
||||
"""The lift's inner-bold-text class must exclude `*` and `\\n` so multi-line/nested bold
|
||||
cannot be matched."""
|
||||
assert re.search(
|
||||
r"\[\^\*\\n\]\{1,80\}",
|
||||
UI_JS,
|
||||
), "Lift regex inner class must be `[^*\\n]{1,80}` (single-line, ≤80 chars)"
|
||||
|
||||
|
||||
# ── Node-driver tests (run against the actual JS) ────────────────────────────
|
||||
|
||||
|
||||
_DRIVER_SRC = r"""
|
||||
const fs = require('fs');
|
||||
const src = fs.readFileSync(process.argv[2], 'utf8');
|
||||
global.window = {};
|
||||
global.document = { createElement: () => ({ innerHTML: '', textContent: '' }) };
|
||||
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => (
|
||||
{'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const _IMAGE_EXTS=/\.(png|jpg|jpeg|gif|webp|bmp|ico|avif)$/i;
|
||||
const _SVG_EXTS=/\.svg$/i;
|
||||
const _AUDIO_EXTS=/\.(mp3|ogg|wav|m4a|aac|flac|wma|opus|webm)$/i;
|
||||
const _VIDEO_EXTS=/\.(mp4|webm|mkv|mov|avi|ogv|m4v)$/i;
|
||||
|
||||
function extractFunc(name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\(');
|
||||
const start = src.search(re);
|
||||
if (start < 0) throw new Error(name + ' not found');
|
||||
let i = src.indexOf('{', start);
|
||||
let depth = 1; i++;
|
||||
while (depth > 0 && i < src.length) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') depth--;
|
||||
i++;
|
||||
}
|
||||
return src.slice(start, i);
|
||||
}
|
||||
eval(extractFunc('renderMd'));
|
||||
|
||||
let buf = '';
|
||||
process.stdin.on('data', c => { buf += c; });
|
||||
process.stdin.on('end', () => { process.stdout.write(renderMd(buf)); });
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def driver_path(tmp_path_factory):
|
||||
if NODE is None:
|
||||
pytest.skip("node not on PATH")
|
||||
p = tmp_path_factory.mktemp("renderer1446_driver") / "driver.js"
|
||||
p.write_text(_DRIVER_SRC, encoding="utf-8")
|
||||
return str(p)
|
||||
|
||||
|
||||
def _render(driver_path: str, markdown: str) -> str:
|
||||
result = subprocess.run(
|
||||
[NODE, driver_path, str(UI_JS_PATH)],
|
||||
input=markdown,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"node driver failed: {result.stderr}")
|
||||
return result.stdout
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_real_renderer_lifts_glued_heading(driver_path):
|
||||
"""Drive the real ui.js renderMd and confirm the lift fires in the basic case."""
|
||||
out = _render(driver_path, "Para text.**Bold heading**\n\nNext para.\n")
|
||||
assert "<p>Para text.</p>" in out, out
|
||||
assert "<p><strong>Bold heading</strong></p>" in out, out
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_real_renderer_protects_fenced_code(driver_path):
|
||||
"""Glued pattern inside fenced code MUST stay literal — fence_stash is active when the lift runs."""
|
||||
src = "```\nsource.**inside-code**\n\nstill-in-code\n```\n"
|
||||
out = _render(driver_path, src)
|
||||
assert "<strong>inside-code</strong>" not in out, out
|
||||
assert "**inside-code**" in out, out
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_real_renderer_protects_inline_code(driver_path):
|
||||
"""Glued pattern inside inline backticks must stay literal."""
|
||||
out = _render(driver_path, "Some `code.**glued**` text.\n")
|
||||
assert "<code>code.**glued**</code>" in out, out
|
||||
assert "<strong>glued</strong>" not in out, out
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_real_renderer_preserves_mid_paragraph_emphasis(driver_path):
|
||||
"""Mid-paragraph emphasis must stay inline in the real renderer."""
|
||||
out = _render(driver_path, "This is **important** to know.\n")
|
||||
assert "<p>This is <strong>important</strong> to know.</p>" in out, out
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_real_renderer_chain_of_glued_headings(driver_path):
|
||||
"""Chain of glued headings — verifies the regex's `g` flag fires multiple times."""
|
||||
src = (
|
||||
"First text.**Heading A**\n\n"
|
||||
"Second text.**Heading B**\n\n"
|
||||
"Third text.\n"
|
||||
)
|
||||
out = _render(driver_path, src)
|
||||
assert "<p>First text.</p>" in out, out
|
||||
assert "<p><strong>Heading A</strong></p>" in out, out
|
||||
assert "<p>Second text.</p>" in out, out
|
||||
assert "<p><strong>Heading B</strong></p>" in out, out
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Regression tests for issue #1447 — markdown heading visual hierarchy.
|
||||
|
||||
Cygnus reported (Discord, May 1 2026, relayed by @AvidFuturist):
|
||||
"Headings seem to be missing across the board in Hermes. They're there,
|
||||
but all plaintext. They get lost so easily in all the plaintext."
|
||||
|
||||
Pre-fix sizes (smaller-than-or-equal to body 14px):
|
||||
h1 18px, h2 16px, h3 14px (= body), h4 13px, h5 12px, h6 11px.
|
||||
|
||||
Post-fix sizes (clear hierarchy above body 14px):
|
||||
h1 24px, h2 20px, h3 17px, h4 15px, h5 14px (uppercase + tracked), h6 13px (uppercase + tracked + muted).
|
||||
|
||||
These tests pin:
|
||||
- Each heading level has a meaningful size delta from the body and from the
|
||||
next-deeper level
|
||||
- h1 and h2 carry a bottom border for "section title" affordance
|
||||
- h5 and h6 carry uppercase + letter-spacing for "label-style" affordance
|
||||
- The .preview-md (file preview pane) sizes match .msg-body so a markdown
|
||||
file preview and a chat message look the same
|
||||
- The data-font-size small/large overrides scale proportionally
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _font_size(scope: str, level: str) -> int:
|
||||
"""Extract the integer font-size (px) for the BARE `<scope> <level>` selector.
|
||||
|
||||
Anchors at the start of a line (after whitespace) so the data-font-size
|
||||
overrides like `[data-font-size="small"] .msg-body h1` are not matched.
|
||||
"""
|
||||
# Match `^<whitespace><scope> <level>{...font-size:Npx...}` (whole rule on one line)
|
||||
pat = re.compile(
|
||||
rf"^\s*{re.escape(scope)}\s+{level}\s*\{{[^}}]*font-size:\s*(\d+)px",
|
||||
re.M,
|
||||
)
|
||||
m = pat.search(CSS)
|
||||
assert m, f"font-size not found for `{scope} {level}` (line-anchored bare selector)"
|
||||
return int(m.group(1))
|
||||
|
||||
|
||||
# ── Hierarchy: each level larger than the next ───────────────────────────────
|
||||
|
||||
|
||||
def test_msg_body_heading_sizes_form_clear_hierarchy():
|
||||
"""h1 > h2 > h3 > h4 in size, with at least 2px between adjacent levels.
|
||||
|
||||
h5 and h6 use uppercase + letter-spacing rather than larger size for their
|
||||
visual distinction (they're "label-style" headings), so they don't strictly
|
||||
need to be larger than h4 — but they must still be at least body size (14px).
|
||||
"""
|
||||
h1 = _font_size(".msg-body", "h1")
|
||||
h2 = _font_size(".msg-body", "h2")
|
||||
h3 = _font_size(".msg-body", "h3")
|
||||
h4 = _font_size(".msg-body", "h4")
|
||||
h5 = _font_size(".msg-body", "h5")
|
||||
h6 = _font_size(".msg-body", "h6")
|
||||
|
||||
assert h1 >= h2 + 3, f"h1 ({h1}) must be at least 3px larger than h2 ({h2})"
|
||||
assert h2 >= h3 + 2, f"h2 ({h2}) must be at least 2px larger than h3 ({h3})"
|
||||
assert h3 >= h4 + 2, f"h3 ({h3}) must be at least 2px larger than h4 ({h4})"
|
||||
# Body is 14px.
|
||||
assert h4 >= 14, f"h4 ({h4}) must not be smaller than body (14px)"
|
||||
assert h5 >= 14, f"h5 ({h5}) must not be smaller than body (14px) — uppercase compensates"
|
||||
assert h6 >= 13, f"h6 ({h6}) must not be much smaller than body (uppercase compensates) — got {h6}"
|
||||
# h3 must be visibly above body — Cygnus's specific complaint.
|
||||
assert h3 > 14, f"h3 ({h3}) must be larger than body (14px) so it is visibly a heading"
|
||||
|
||||
|
||||
def test_msg_body_h1_and_h2_have_bottom_border():
|
||||
"""h1 and h2 carry a bottom border for visible 'section title' affordance.
|
||||
|
||||
This mirrors GitHub/Notion convention and the existing .preview-md h1 rule.
|
||||
"""
|
||||
h1_match = re.search(
|
||||
r"\.msg-body\s+h1\s*\{[^}]*border-bottom:\s*1px\s+solid",
|
||||
CSS,
|
||||
)
|
||||
assert h1_match, ".msg-body h1 must have border-bottom: 1px solid"
|
||||
h2_match = re.search(
|
||||
r"\.msg-body\s+h2\s*\{[^}]*border-bottom:\s*1px\s+solid",
|
||||
CSS,
|
||||
)
|
||||
assert h2_match, ".msg-body h2 must have border-bottom: 1px solid"
|
||||
|
||||
|
||||
def test_msg_body_h5_and_h6_use_label_style_affordance():
|
||||
"""h5 and h6 use uppercase + letter-spacing rather than larger sizes."""
|
||||
h5_match = re.search(
|
||||
r"\.msg-body\s+h5\s*\{[^}]*text-transform:\s*uppercase[^}]*letter-spacing:",
|
||||
CSS,
|
||||
)
|
||||
assert h5_match, ".msg-body h5 must have text-transform:uppercase + letter-spacing"
|
||||
h6_match = re.search(
|
||||
r"\.msg-body\s+h6\s*\{[^}]*text-transform:\s*uppercase[^}]*letter-spacing:",
|
||||
CSS,
|
||||
)
|
||||
assert h6_match, ".msg-body h6 must have text-transform:uppercase + letter-spacing"
|
||||
|
||||
|
||||
def test_msg_body_headings_use_strong_color_and_bold_weight():
|
||||
"""All headings must use bold weight (700) and strong color, not light grey."""
|
||||
base_match = re.search(
|
||||
r"\.msg-body\s+h1,\s*\.msg-body\s+h2,\s*\.msg-body\s+h3,\s*\.msg-body\s+h4,"
|
||||
r"\s*\.msg-body\s+h5,\s*\.msg-body\s+h6\s*\{[^}]*font-weight:\s*700",
|
||||
CSS,
|
||||
)
|
||||
assert base_match, "Combined .msg-body h1..h6 selector must set font-weight:700"
|
||||
# Color must reference --strong (with --text fallback).
|
||||
color_match = re.search(
|
||||
r"\.msg-body\s+h1,\s*\.msg-body\s+h2,\s*\.msg-body\s+h3,\s*\.msg-body\s+h4,"
|
||||
r"\s*\.msg-body\s+h5,\s*\.msg-body\s+h6\s*\{[^}]*color:\s*var\(--strong",
|
||||
CSS,
|
||||
)
|
||||
assert color_match, "Combined heading selector must use color:var(--strong, ...)"
|
||||
|
||||
|
||||
# ── preview-md sync: chat and file preview render headings the same ──────────
|
||||
|
||||
|
||||
def test_preview_md_heading_sizes_match_msg_body():
|
||||
"""Per the issue's 'companion fix' note: .preview-md heading sizes must mirror .msg-body
|
||||
so a markdown file preview and a chat message look identical."""
|
||||
for level in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
||||
msg_size = _font_size(".msg-body", level)
|
||||
preview_size = _font_size(".preview-md", level)
|
||||
assert msg_size == preview_size, (
|
||||
f".preview-md {level} ({preview_size}px) must match .msg-body {level} ({msg_size}px)"
|
||||
)
|
||||
|
||||
|
||||
def test_preview_md_has_h4_h5_h6_rules():
|
||||
"""Pre-fix .preview-md only had h1-h3 rules. Post-fix must have all six."""
|
||||
for level in ("h4", "h5", "h6"):
|
||||
match = re.search(rf"\.preview-md\s+{level}\s*\{{[^}}]*font-size:\s*\d+px", CSS)
|
||||
assert match, f".preview-md {level} rule missing"
|
||||
|
||||
|
||||
# ── data-font-size scaling: small/large stay proportional ────────────────────
|
||||
|
||||
|
||||
def test_data_font_size_small_overrides_scale_with_new_defaults():
|
||||
"""The data-font-size='small' h1 override must NOT be ≤ body 14px (the old 15px h1
|
||||
in small mode was effectively the same as body — the bug Cygnus complained about,
|
||||
just at small font-size).
|
||||
"""
|
||||
# Walk h1..h6 small overrides
|
||||
small_h_sizes = {}
|
||||
for level in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
||||
m = re.search(
|
||||
rf'data-font-size="small"\]\s*\.msg-body\s+{level}\s*\{{[^}}]*font-size:\s*(\d+)px',
|
||||
CSS,
|
||||
)
|
||||
if m:
|
||||
small_h_sizes[level] = int(m.group(1))
|
||||
# Must have all six.
|
||||
assert set(small_h_sizes.keys()) == {"h1", "h2", "h3", "h4", "h5", "h6"}, (
|
||||
f"data-font-size='small' missing override for some levels: got {sorted(small_h_sizes)}"
|
||||
)
|
||||
# Body in small mode is 12px.
|
||||
assert small_h_sizes["h1"] >= 18, f"small h1 too small: {small_h_sizes['h1']}"
|
||||
assert small_h_sizes["h2"] >= 16, f"small h2 too small: {small_h_sizes['h2']}"
|
||||
assert small_h_sizes["h3"] >= 14, f"small h3 too small: {small_h_sizes['h3']}"
|
||||
# Hierarchy preserved.
|
||||
assert small_h_sizes["h1"] > small_h_sizes["h2"]
|
||||
assert small_h_sizes["h2"] > small_h_sizes["h3"]
|
||||
assert small_h_sizes["h3"] > small_h_sizes["h4"]
|
||||
|
||||
|
||||
def test_data_font_size_large_overrides_scale_with_new_defaults():
|
||||
"""The data-font-size='large' h1 override must scale up proportionally with the new defaults."""
|
||||
large_h_sizes = {}
|
||||
for level in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
||||
m = re.search(
|
||||
rf'data-font-size="large"\]\s*\.msg-body\s+{level}\s*\{{[^}}]*font-size:\s*(\d+)px',
|
||||
CSS,
|
||||
)
|
||||
if m:
|
||||
large_h_sizes[level] = int(m.group(1))
|
||||
assert set(large_h_sizes.keys()) == {"h1", "h2", "h3", "h4", "h5", "h6"}
|
||||
# Body in large mode is 16px. h1 must be a meaningful step above.
|
||||
assert large_h_sizes["h1"] >= 26, f"large h1 too small: {large_h_sizes['h1']}"
|
||||
assert large_h_sizes["h2"] >= 22, f"large h2 too small: {large_h_sizes['h2']}"
|
||||
assert large_h_sizes["h3"] >= 19, f"large h3 too small: {large_h_sizes['h3']}"
|
||||
# Hierarchy preserved.
|
||||
assert large_h_sizes["h1"] > large_h_sizes["h2"]
|
||||
assert large_h_sizes["h2"] > large_h_sizes["h3"]
|
||||
assert large_h_sizes["h3"] > large_h_sizes["h4"]
|
||||
|
||||
|
||||
# ── Specific values from the issue spec ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_specific_heading_sizes_match_issue_spec():
|
||||
"""Pin the exact spec'd sizes so unrelated CSS edits don't drift them."""
|
||||
assert _font_size(".msg-body", "h1") == 24
|
||||
assert _font_size(".msg-body", "h2") == 20
|
||||
assert _font_size(".msg-body", "h3") == 17
|
||||
assert _font_size(".msg-body", "h4") == 15
|
||||
# h5 and h6 use uppercase, so size is at body or just below.
|
||||
assert _font_size(".msg-body", "h5") == 14
|
||||
assert _font_size(".msg-body", "h6") == 13
|
||||
@@ -0,0 +1,330 @@
|
||||
"""Regression tests for the server-side `_LOGIN_LOCALE` parity with `static/i18n.js`.
|
||||
|
||||
Issue #1442: when v0.50.264 added `ja` as the 8th built-in locale, Opus pre-release
|
||||
advisor surfaced that `_LOGIN_LOCALE` (in `api/routes.py`) only contained
|
||||
`en/es/de/ru/zh/zh-Hant`. `ja`, `pt`, and `ko` users would see the English login page
|
||||
even after their UI language was set, because `_resolve_login_locale_key()` falls
|
||||
through every check and returns "en" when the locale is missing.
|
||||
|
||||
These tests pin two invariants going forward:
|
||||
|
||||
1. Every locale key registered in `static/i18n.js` LOCALES (top-level) must also
|
||||
exist as a key in `_LOGIN_LOCALE` — so adding a new locale to i18n.js without
|
||||
updating `_LOGIN_LOCALE` is caught at test time.
|
||||
|
||||
2. Every entry in `_LOGIN_LOCALE` must carry the full required string set
|
||||
(`lang/title/subtitle/placeholder/btn/invalid_pw/conn_failed`) and every value
|
||||
must be a non-empty string.
|
||||
|
||||
The companion follow-up — closing the i18n.js login-flow English-leak gaps for
|
||||
`ko` (10 keys) and `es` (3 keys), and adding the 3 missing `pt` keys — is verified
|
||||
in `test_login_flow_translation_parity` below: every locale must have non-English
|
||||
values for the user-facing login/sign-out/password keys.
|
||||
|
||||
See issue #1442.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import importlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
I18N_PATH = REPO / "static" / "i18n.js"
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _load_login_locale() -> dict:
|
||||
"""Import `_LOGIN_LOCALE` from `api.routes` without booting the HTTP server."""
|
||||
sys.path.insert(0, str(REPO))
|
||||
try:
|
||||
routes = importlib.import_module("api.routes")
|
||||
finally:
|
||||
# Don't pop — leaving sys.path[0] is fine for the rest of the suite.
|
||||
pass
|
||||
return routes._LOGIN_LOCALE
|
||||
|
||||
|
||||
def _i18n_top_level_locale_keys() -> list[str]:
|
||||
"""Return the ordered list of top-level locale keys defined in static/i18n.js LOCALES."""
|
||||
src = I18N_PATH.read_text(encoding="utf-8")
|
||||
# Find `const LOCALES = {`
|
||||
m = re.search(r"const\s+LOCALES\s*=\s*\{", src)
|
||||
assert m, "LOCALES object not found in static/i18n.js"
|
||||
body_start = m.end()
|
||||
# Walk braces to find matching close, respecting strings/comments
|
||||
depth = 1
|
||||
i = body_start
|
||||
n = len(src)
|
||||
while i < n and depth > 0:
|
||||
ch = src[i]
|
||||
if ch == "/" and i + 1 < n and src[i + 1] == "/":
|
||||
nl = src.find("\n", i)
|
||||
i = n if nl < 0 else nl + 1
|
||||
continue
|
||||
if ch == "/" and i + 1 < n and src[i + 1] == "*":
|
||||
end = src.find("*/", i + 2)
|
||||
i = n if end < 0 else end + 2
|
||||
continue
|
||||
if ch in ("'", '"'):
|
||||
q = ch
|
||||
i += 1
|
||||
while i < n and src[i] != q:
|
||||
i += 2 if src[i] == "\\" else 1
|
||||
i += 1
|
||||
continue
|
||||
if ch == "`":
|
||||
i += 1
|
||||
while i < n and src[i] != "`":
|
||||
i += 2 if src[i] == "\\" else 1
|
||||
i += 1
|
||||
continue
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
i += 1
|
||||
continue
|
||||
if ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
body_end = i
|
||||
break
|
||||
i += 1
|
||||
continue
|
||||
i += 1
|
||||
else:
|
||||
raise AssertionError("LOCALES object never closed in static/i18n.js")
|
||||
|
||||
body = src[body_start:body_end]
|
||||
|
||||
# Top-level locale keys are at 2-space indent: either `xx: {` or `'xx-Hant': {`.
|
||||
# Use brace-tracking so we only pick up *top-level* keys, not nested ones.
|
||||
keys: list[str] = []
|
||||
j = 0
|
||||
sub_depth = 0
|
||||
blen = len(body)
|
||||
while j < blen:
|
||||
ch = body[j]
|
||||
if ch == "/" and j + 1 < blen and body[j + 1] == "/":
|
||||
nl = body.find("\n", j)
|
||||
j = blen if nl < 0 else nl + 1
|
||||
continue
|
||||
if ch == "/" and j + 1 < blen and body[j + 1] == "*":
|
||||
end = body.find("*/", j + 2)
|
||||
j = blen if end < 0 else end + 2
|
||||
continue
|
||||
if ch in ("'", '"'):
|
||||
q = ch
|
||||
j += 1
|
||||
while j < blen and body[j] != q:
|
||||
j += 2 if body[j] == "\\" else 1
|
||||
j += 1
|
||||
continue
|
||||
if ch == "`":
|
||||
j += 1
|
||||
while j < blen and body[j] != "`":
|
||||
j += 2 if body[j] == "\\" else 1
|
||||
j += 1
|
||||
continue
|
||||
if ch == "{":
|
||||
sub_depth += 1
|
||||
j += 1
|
||||
continue
|
||||
if ch == "}":
|
||||
sub_depth -= 1
|
||||
j += 1
|
||||
continue
|
||||
# Detect top-level key only when sub_depth is 0 and we're at the start
|
||||
# of a fresh line (after a newline) at column 2.
|
||||
if sub_depth == 0 and ch == "\n":
|
||||
# Look at the next characters: ` KEY: {` where KEY is identifier or 'identifier-with-dash'
|
||||
tail = body[j + 1 : j + 200]
|
||||
mk = re.match(
|
||||
r" (?:'(?P<q>[A-Za-z][A-Za-z0-9_-]*)'|(?P<u>[A-Za-z][A-Za-z0-9_]*))\s*:\s*\{",
|
||||
tail,
|
||||
)
|
||||
if mk:
|
||||
keys.append(mk.group("q") or mk.group("u"))
|
||||
j += 1
|
||||
# Deduplicate while preserving order (LOCALES is a single object so no dups expected,
|
||||
# but be defensive in case the file ever picks them up).
|
||||
seen = set()
|
||||
ordered_unique = []
|
||||
for k in keys:
|
||||
if k not in seen:
|
||||
seen.add(k)
|
||||
ordered_unique.append(k)
|
||||
return ordered_unique
|
||||
|
||||
|
||||
def _i18n_locale_block(loc: str) -> str:
|
||||
"""Return the body of a specific top-level locale block in i18n.js."""
|
||||
src = I18N_PATH.read_text(encoding="utf-8")
|
||||
if "-" in loc:
|
||||
head = re.compile(rf"^ '{re.escape(loc)}':\s*\{{", re.M)
|
||||
else:
|
||||
head = re.compile(rf"^ {re.escape(loc)}:\s*\{{", re.M)
|
||||
hm = head.search(src)
|
||||
assert hm, f"locale {loc!r} not found in i18n.js"
|
||||
body_start = hm.end()
|
||||
depth = 1
|
||||
i = body_start
|
||||
n = len(src)
|
||||
while i < n and depth > 0:
|
||||
ch = src[i]
|
||||
if ch == "/" and i + 1 < n and src[i + 1] == "/":
|
||||
nl = src.find("\n", i)
|
||||
i = n if nl < 0 else nl + 1
|
||||
continue
|
||||
if ch == "/" and i + 1 < n and src[i + 1] == "*":
|
||||
end = src.find("*/", i + 2)
|
||||
i = n if end < 0 else end + 2
|
||||
continue
|
||||
if ch in ("'", '"'):
|
||||
q = ch
|
||||
i += 1
|
||||
while i < n and src[i] != q:
|
||||
i += 2 if src[i] == "\\" else 1
|
||||
i += 1
|
||||
continue
|
||||
if ch == "`":
|
||||
i += 1
|
||||
while i < n and src[i] != "`":
|
||||
i += 2 if src[i] == "\\" else 1
|
||||
i += 1
|
||||
continue
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
i += 1
|
||||
continue
|
||||
if ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[body_start:i]
|
||||
i += 1
|
||||
continue
|
||||
i += 1
|
||||
raise AssertionError(f"locale {loc!r} block never closed")
|
||||
|
||||
|
||||
# Required sub-keys for every _LOGIN_LOCALE entry.
|
||||
REQUIRED_LOGIN_KEYS = (
|
||||
"lang",
|
||||
"title",
|
||||
"subtitle",
|
||||
"placeholder",
|
||||
"btn",
|
||||
"invalid_pw",
|
||||
"conn_failed",
|
||||
)
|
||||
|
||||
# Login-flow user-facing keys that must be translated (non-English) in every locale.
|
||||
# Adding a new locale to i18n.js without these translated will leak English to the
|
||||
# user during the very first run / login experience.
|
||||
LOGIN_FLOW_TRANSLATED_KEYS = (
|
||||
"login_title",
|
||||
"login_subtitle",
|
||||
"login_placeholder",
|
||||
"login_btn",
|
||||
"login_invalid_pw",
|
||||
"login_conn_failed",
|
||||
"sign_out",
|
||||
"sign_out_failed",
|
||||
"password_placeholder",
|
||||
"settings_saved_pw",
|
||||
"settings_saved_pw_updated",
|
||||
"auth_disabled",
|
||||
"disable_auth_confirm_title",
|
||||
)
|
||||
|
||||
|
||||
# ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_every_i18n_locale_has_login_locale_entry():
|
||||
"""Every locale in static/i18n.js LOCALES must also exist as a key in _LOGIN_LOCALE."""
|
||||
i18n_keys = _i18n_top_level_locale_keys()
|
||||
login = _load_login_locale()
|
||||
missing = [k for k in i18n_keys if k not in login]
|
||||
assert not missing, (
|
||||
f"_LOGIN_LOCALE is missing entries for these i18n locales: {missing}. "
|
||||
f"Add the matching entry in api/routes.py to keep the login page localized "
|
||||
f"for every supported locale (issue #1442)."
|
||||
)
|
||||
|
||||
|
||||
def test_login_locale_count_matches_or_exceeds_floor():
|
||||
"""_LOGIN_LOCALE must contain at least the 9 launch locales (en, es, de, ru, zh, zh-Hant, ja, pt, ko)."""
|
||||
login = _load_login_locale()
|
||||
assert len(login) >= 9, f"_LOGIN_LOCALE shrank: only {len(login)} entries"
|
||||
for k in ("en", "es", "de", "ru", "zh", "zh-Hant", "ja", "pt", "ko"):
|
||||
assert k in login, f"_LOGIN_LOCALE missing core locale {k!r}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("loc_key", ["en", "es", "de", "ru", "zh", "zh-Hant", "ja", "pt", "ko"])
|
||||
def test_login_locale_entry_well_formed(loc_key: str):
|
||||
"""Each _LOGIN_LOCALE entry must have all required sub-keys and non-empty string values."""
|
||||
login = _load_login_locale()
|
||||
entry = login[loc_key]
|
||||
assert set(entry.keys()) == set(REQUIRED_LOGIN_KEYS), (
|
||||
f"_LOGIN_LOCALE[{loc_key!r}] keys mismatch. "
|
||||
f"Expected {set(REQUIRED_LOGIN_KEYS)}, got {set(entry.keys())}."
|
||||
)
|
||||
for k, v in entry.items():
|
||||
assert isinstance(v, str) and v, f"_LOGIN_LOCALE[{loc_key!r}][{k!r}] is empty/non-str: {v!r}"
|
||||
|
||||
|
||||
def test_login_locale_resolver_handles_new_locales():
|
||||
"""_resolve_login_locale_key() must map ja/pt/ko (and common BCP-47 variants) to their entries."""
|
||||
sys.path.insert(0, str(REPO))
|
||||
from api.routes import _resolve_login_locale_key
|
||||
|
||||
assert _resolve_login_locale_key("ja") == "ja"
|
||||
assert _resolve_login_locale_key("ja-JP") == "ja"
|
||||
assert _resolve_login_locale_key("ja_JP") == "ja"
|
||||
assert _resolve_login_locale_key("pt") == "pt"
|
||||
assert _resolve_login_locale_key("pt-BR") == "pt"
|
||||
assert _resolve_login_locale_key("pt-PT") == "pt"
|
||||
assert _resolve_login_locale_key("ko") == "ko"
|
||||
assert _resolve_login_locale_key("ko-KR") == "ko"
|
||||
# Unknown locale still falls back to en.
|
||||
assert _resolve_login_locale_key("fr") == "en"
|
||||
assert _resolve_login_locale_key("xx-YY") == "en"
|
||||
|
||||
|
||||
def _value_of(seg: str, key: str) -> str | None:
|
||||
m = re.search(rf"\b{re.escape(key)}:\s*'((?:\\.|[^'\\])*)'", seg)
|
||||
if m:
|
||||
return m.group(1)
|
||||
m = re.search(rf'\b{re.escape(key)}:\s*"((?:\\.|[^"\\])*)"', seg)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("loc_key", ["es", "de", "ru", "zh", "zh-Hant", "ja", "pt", "ko"])
|
||||
def test_login_flow_keys_are_translated(loc_key: str):
|
||||
"""Login/sign-out/password keys in static/i18n.js must NOT equal the English value.
|
||||
|
||||
This guards the `ko` 10-key, `es` 3-key, and `pt` 3-key gaps closed in this PR.
|
||||
Adding a new locale that copies English values for these keys leaks English to
|
||||
the user during their very first interaction with the app.
|
||||
"""
|
||||
en_seg = _i18n_locale_block("en")
|
||||
target_seg = _i18n_locale_block(loc_key)
|
||||
leaks = []
|
||||
for k in LOGIN_FLOW_TRANSLATED_KEYS:
|
||||
en_val = _value_of(en_seg, k)
|
||||
loc_val = _value_of(target_seg, k)
|
||||
if en_val and loc_val is not None and loc_val == en_val:
|
||||
leaks.append(f"{k}={loc_val!r}")
|
||||
assert not leaks, (
|
||||
f"Locale {loc_key!r} leaks English for login-flow keys: {leaks}. "
|
||||
f"Translate these in static/i18n.js (issue #1442)."
|
||||
)
|
||||
@@ -69,6 +69,10 @@ def render_md(raw):
|
||||
s = re.sub(r"<i>([\s\S]*?)</i>", lambda m: "*" + m.group(1) + "*", s, flags=re.I)
|
||||
s = re.sub(r"<code>([^<]*?)</code>", lambda m: "`" + m.group(1) + "`", s, flags=re.I)
|
||||
s = re.sub(r"<br\s*/?>", "\n", s, flags=re.I)
|
||||
# Glued-bold-heading lift (issue #1446) — must mirror static/ui.js position:
|
||||
# after raw <pre> restore, before fence_stash restore. Lifts a sentence-glued
|
||||
# bold "stub heading" out into its own paragraph when followed by a blank line.
|
||||
s = re.sub(r"([.!?])\*\*([^*\n]{1,80})\*\*\n\n", r"\1\n\n**\2**\n\n", s)
|
||||
s = re.sub(r"\x00F(\d+)\x00", lambda m: fence_stash[int(m.group(1))], s)
|
||||
|
||||
# Fenced code blocks
|
||||
|
||||
Reference in New Issue
Block a user