fix: isolate canvas CJK strings and guard build encoding

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-12 09:32:59 +08:00
parent 2ecd300ada
commit 288ebf4c90
7 changed files with 431 additions and 53 deletions

View file

@ -0,0 +1,84 @@
#!/usr/bin/env node
/**
* Fail the build if canvas UI CJK is corrupted or missing.
* Root cause we guard against: Windows tools rewriting TopologyCanvas.tsx
* with "?" for multibyte UTF-8, and Latin-1 middot 0xb7 breaking UTF-8.
*/
import { readFileSync, existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const errors = [];
function mustUtf8(rel) {
const path = join(root, rel);
if (!existsSync(path)) {
errors.push(`missing: ${rel}`);
return null;
}
const buf = readFileSync(path);
try {
return new TextDecoder("utf-8", { fatal: true }).decode(buf);
} catch (e) {
errors.push(`${rel}: invalid UTF-8 (${e.message})`);
return null;
}
}
const labels = mustUtf8("src/canvas-ui-text.ts");
if (labels) {
const required = [
"选择",
"平移",
"连线",
"单点",
"整枝",
"气泡",
"卡片",
"撤销",
"未命名",
];
for (const s of required) {
if (!labels.includes(s)) errors.push(`canvas-ui-text.ts missing: ${s}`);
}
if (/^\s*\w+:\s*["']\?{2,}["']/m.test(labels)) {
errors.push("canvas-ui-text.ts contains corrupted ?? string literals");
}
}
const canvas = mustUtf8("src/TopologyCanvas.tsx");
if (canvas) {
// Toolbar / controls must use canvasUi — no inline ?? placeholders.
const badUi = [
...canvas.matchAll(/\["select",\s*"(\?+)"/g),
...canvas.matchAll(/\["pan",\s*"(\?+)"/g),
...canvas.matchAll(/\["link",\s*"(\?+)"/g),
];
if (badUi.length) {
errors.push(
"TopologyCanvas.tsx has corrupted tool labels (??). Use canvasUi from canvas-ui-text.ts",
);
}
if (!canvas.includes("canvas-ui-text") && !canvas.includes("canvasUi")) {
errors.push("TopologyCanvas.tsx must import canvasUi from canvas-ui-text.ts");
}
// Detect classic corruption: many consecutive ? inside quotes in JSX title/aria
const titleQ = [...canvas.matchAll(/title=\{?["'](\?{2,})["']\}?/g)];
if (titleQ.length >= 3) {
errors.push(
`TopologyCanvas.tsx looks CJK-corrupted (${titleQ.length} title="??" hits). Restore via canvasUi.`,
);
}
}
if (errors.length) {
console.error("[check-canvas-cjk] FAILED:");
for (const e of errors) console.error(" -", e);
console.error(
"Fix: edit src/canvas-ui-text.ts (UTF-8 only). Never pipe CJK through PowerShell Set-Content.",
);
process.exit(1);
}
console.log("[check-canvas-cjk] OK");

View file

@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
"""Diagnose TopologyCanvas.tsx encoding / CJK corruption."""
from __future__ import annotations
import re
from pathlib import Path
p = Path(__file__).resolve().parents[1] / "src" / "TopologyCanvas.tsx"
raw = p.read_bytes()
print("size", len(raw))
print("bom", raw[:3] == b"\xef\xbb\xbf")
try:
text = raw.decode("utf-8")
print("utf8: OK")
except UnicodeDecodeError as e:
print("utf8: FAIL", e)
print("bad-byte context:", raw[max(0, e.start - 30) : e.start + 30])
text = raw.decode("latin1")
quoted = re.findall(r'"\?{2,}"', text)
print("quoted ?? count:", len(quoted))
print("cjk chars:", len(re.findall(r"[\u4e00-\u9fff]", text)))
for i, line in enumerate(text.splitlines(), 1):
if '["select"' in line or "title=" in line and "?" in line and "??" in line:
if i >= 1180 and i <= 1360:
print(f"{i}: {line.strip()[:100]}")

View file

@ -0,0 +1,209 @@
# -*- coding: utf-8 -*-
"""Rewrite TopologyCanvas.tsx to use canvasUi (no inline CJK). Tolerates broken latin1/0xb7 files."""
from __future__ import annotations
import re
from pathlib import Path
p = Path(__file__).resolve().parents[1] / "src" / "TopologyCanvas.tsx"
raw = p.read_bytes()
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
text = raw.decode("latin1").replace("\xb7", "·")
# Drop prior broken import if re-run
text = re.sub(
r'^import \{ canvasUi as U \} from "\./canvas-ui-text";\n',
"",
text,
count=1,
flags=re.M,
)
# Insert import after last relative import near top
if 'from "./canvas-ui-text"' not in text:
m = re.search(r'(import .+ from "\./[^"]+";\n)', text)
# insert after first block of local imports — after image-cache / node-props style imports
insert_at = None
for m in re.finditer(r'^import .+ from "\./.+";\n', text, flags=re.M):
insert_at = m.end()
if insert_at is None:
raise SystemExit("no local import found to anchor canvas-ui-text import")
text = (
text[:insert_at]
+ 'import { canvasUi as U } from "./canvas-ui-text";\n'
+ text[insert_at:]
)
subs = [
# comments
(r"// iframe \?+ Tab \?+", "// iframe: notify parent Tab fullscreen when needed"),
(r"// Free place .+?pin every dragged id", "// Free place -> pin every dragged id"),
(r"// Contain: \?+", "// Contain: fit image inside box, no crop"),
# placeholders / creates
(r'(wrapTextLines\(ctx, n\.text \|\| )"\?+"', r'\1U.unnamed'),
(r'(n\.text \|\| )"\?+"', r"\1U.unnamed"),
(r'(wrapTextLines\(ctx, n\.text \|\| )U\.unnamed', r"\1U.unnamed"), # noop safe
(r'text: "\?+", parentId: parent\b', "text: U.newNode, parentId: parent"),
(r': \{ type: "create_node", text: "\?+" \},', ': { type: "create_node", text: U.newNode },'),
(
r'text: "\?+",\n parentId: selectedIds\[0\],\n sidePref: -1',
"text: U.leftChild,\n parentId: selectedIds[0],\n sidePref: -1",
),
(
r'text: "\?+",\n parentId: selectedIds\[0\],\n sidePref: 1',
"text: U.rightChild,\n parentId: selectedIds[0],\n sidePref: 1",
),
(
r'text: "\?+",\n parentId: parentOfSel',
"text: U.sibling,\n parentId: parentOfSel",
),
(
r'text: "\?+",\n \.\.\.\(selectedIds\[0\]',
"text: U.newNode,\n ...(selectedIds[0]",
),
(r'label: "\?+"', "label: U.relationLabel"),
# toolbar
(
r'(className="canvas-toolbar"\n role="toolbar"\n aria-label=)"\?+"',
r'\1{U.toolbarAria}',
),
(
r'(className="canvas-tool-btn"\n title=)"\?+"(\n onClick=\{requestBackToRoster\}\n >\n )\?+(\n </button>)',
r"\1{U.backRosterTitle}\2{U.backRoster}\3",
),
(r'\["select", "\?+", "V"\]', '["select", U.toolSelect, "V"]'),
(r'\["pan", "\?+", "H"\]', '["pan", U.toolPan, "H"]'),
(r'\["link", "\?+", "C"\]', '["link", U.toolLink, "C"]'),
# also if already Chinese from partial fix
(r'\["select", "选择", "V"\]', '["select", U.toolSelect, "V"]'),
(r'\["pan", "平移", "H"\]', '["pan", U.toolPan, "H"]'),
(r'\["link", "连线", "C"\]', '["link", U.toolLink, "C"]'),
# drag titles/labels — match mixed ? + latin
(
r'(dragScope === "node" \? " is-active" : ""\}`\}\n title=)"[^"]*"',
r'\1{U.dragNodeTitle}',
),
(
r'(aria-pressed=\{dragScope === "node"\}\n onClick=\{\(\) => setDragScope\("node"\)\}\n >\n )(?:\?+|单点)(\n </button>)',
r"\1{U.dragNode}\2",
),
(
r'(dragScope === "branch" \? " is-active" : ""\}`\}\n title=)"[^"]*"',
r'\1{U.dragBranchTitle}',
),
(
r'(aria-pressed=\{dragScope === "branch"\}\n onClick=\{\(\) => setDragScope\("branch"\)\}\n >\n )(?:\?+|整枝)(\n </button>)',
r"\1{U.dragBranch}\2",
),
(
r'(className=\{`canvas-tool-btn\$\{cardView \? "" : " is-active"\}`\}\n title=)"[^"]*"',
r'\1{U.bubbleTitle}',
),
(
r'(void setPrefs\(\{ nodeViewMode: "bubble" \}\);\n \}\}\n >\n )(?:\?+|气泡)(\n </button>)',
r"\1{U.bubble}\2",
),
(
r'(className=\{`canvas-tool-btn\$\{cardView \? " is-active" : ""\}`\}\n title=)"[^"]*"',
r'\1{U.cardTitle}',
),
(
r'(void setPrefs\(\{ nodeViewMode: "card" \}\);\n \}\}\n >\n )(?:\?+|卡片)(\n </button>\n <button\n type="button"\n className="canvas-tool-btn"\n title=)"[^"]*"',
r"\1{U.card}\2{U.unpinTitle}",
),
(
r'(pinned: false \},\n \]\);\n \}\}\n >\n )(?:\?+|解钉)(\n </button>)',
r"\1{U.unpin}\2",
),
(
r'(className="canvas-tool-btn"\n title=)"[^"]*"(\n disabled=\{!lastChangeSetId\}\n onClick=\{\(\) => void undo\(\)\}\n >\n )(?:\?+|撤销)(\n </button>)',
r"\1{U.undo}\2{U.undo}\3",
),
# controls
(
r'(className="canvas-controls"\n role="toolbar"\n aria-label=)"\?+"',
r"\1{U.controlsAria}",
),
(
r'title=\{fullscreen \? "[^"]*" : "[^"]*"\}\n aria-label=\{fullscreen \? "[^"]*" : "[^"]*"\}',
"title={fullscreen ? U.exitFullscreen : U.fullscreen}\n aria-label={fullscreen ? U.exitFullscreen : U.fullscreen}",
),
(
r'title="[^"]* \(\+\)"\n aria-label="[^"]*"',
"title={U.zoomInTitle}\n aria-label={U.zoomIn}",
),
(
r'title="[^"]* \(-\)"\n aria-label="[^"]*"',
"title={U.zoomOutTitle}\n aria-label={U.zoomOut}",
),
(
r'title="[^"]* \(F\)"\n aria-label="[^"]*"',
"title={U.fitTitle}\n aria-label={U.fit}",
),
(
r'title=\{locked \? "[^"]* \(L\)" : "[^"]* \(L\)"\}\n aria-label=\{locked \? "[^"]*" : "[^"]*"\}',
"title={locked ? U.unlockTitle : U.lockTitle}\n aria-label={locked ? U.unlock : U.lock}",
),
# handles
(
r'const text = side < 0 \? "[^"]*" : "[^"]*";',
"const text = side < 0 ? U.leftChild : U.rightChild;",
),
(
r'(className="node-handle node-handle--left"\n style=\{\{ left: cx - hw - 14, top: cy - 10 \}\}\n title=)"[^"]*"',
r"\1{U.addLeftChildTitle}",
),
(
r'(className="node-handle node-handle--right"\n style=\{\{ left: cx \+ hw - 2, top: cy - 10 \}\}\n title=)"[^"]*"',
r"\1{U.addRightChildTitle}",
),
# hint
(
r"""\{locked
\? "[^"]*"
: dragScope === "node"
\? "[^"]*"
: "[^"]*"\}""",
"""{locked
? U.hintLocked
: dragScope === "node"
? U.hintNodeDrag
: U.hintBranchDrag}""",
),
]
for pat, repl in subs:
text, n = re.subn(pat, repl, text, count=0)
print(f"x{n}: {pat[:50]}")
# Fix remaining Chinese literals if any known ones remain (after partial restores)
zh_map = {
'"未命名"': "U.unnamed",
'"新节点"': "U.newNode",
'"左侧子主题"': "U.leftChild",
'"右侧子主题"': "U.rightChild",
'"兄弟节点"': "U.sibling",
'"关联"': "U.relationLabel",
}
for a, b in zh_map.items():
if a in text:
text = text.replace(a, b)
print("zh->U:", a)
# aria-label leftovers as string attrs that should be expressions
text = text.replace('aria-label="画布工具"', "aria-label={U.toolbarAria}")
text = text.replace('aria-label="视图控制"', "aria-label={U.controlsAria}")
remaining = re.findall(r'"\?{2,}"', text)
btn = re.findall(r">\n\s+\?{2,}\n\s+</button>", text)
print("quoted ?? left:", remaining)
print("button ?? left:", len(btn))
print("has import:", 'canvas-ui-text' in text)
p.write_text(text, encoding="utf-8", newline="\n")
print("wrote", p)
if remaining or btn:
raise SystemExit(2)
print("SUCCESS")

View file

@ -2,9 +2,8 @@
from pathlib import Path
d = Path(__file__).resolve().parents[1] / "dist" / "assets"
js_path = next(d.glob("index-*.js"))
js = js_path.read_text(encoding="utf-8")
js = next(d.glob("index-*.js")).read_text(encoding="utf-8")
needles = ["选择", "平移", "连线", "单点", "整枝", "气泡", "卡片", "解钉", "撤销"]
print(js_path.name)
print(next(d.glob("index-*.js")).name)
print({n: (n in js) for n in needles})
print("corrupt select:", '["select","??"' in js or '["select", "??"' in js)
print("corrupt select:", '["select","??"' in js)