fix(coding-agent): render CJK emphasis in HTML export (#11168)

This commit is contained in:
Ramiz Wachtler 2026-10-20 13:20:37 +02:00 • committed by GitHub
parent fbc456d827
commit 26293e74f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 108 additions and 30 deletions

View file

@ -15,6 +15,7 @@
- Fixed compaction and branch summaries failing with "generation hit the token cap" when reasoning used most of the fixed summary output limit. Summaries now use the model's output limit, and `compaction.reserveTokens` only controls when compaction starts ([#9512](https://github.com/earendil-works/pi/issues/9512), [#9075](https://github.com/earendil-works/pi/issues/9075)).
- Fixed `models.schema.json` missing descriptions for provider and model fields, so editors showed no hints for them ([#10790](https://github.com/earendil-works/pi/issues/10790))
- Fixed `pi update` failing with "Managed install marker is missing or invalid" when `managed-install.json` starts with a UTF-8 BOM, which Windows PowerShell 5.1 adds when it rewrites the file.
- Fixed HTML export showing literal asterisks for Markdown bold and italic next to CJK punctuation ([#3353](https://github.com/earendil-works/pi/issues/3353), [#10154](https://github.com/earendil-works/pi/issues/10154))
## [1.3.0] - 2026-10-15

View file

@ -146,6 +146,7 @@ function generateHtml(sessionData: SessionData, themeName?: string): string {
const templateCss = readFileSync(join(templateDir, "template.css"), "utf-8");
const templateJs = readFileSync(join(templateDir, "template.js"), "utf-8");
const markedJs = readFileSync(join(templateDir, "vendor", "marked.min.js"), "utf-8");
const markedCjkFriendlyJs = readFileSync(join(templateDir, "vendor", "marked-cjk-friendly.min.js"), "utf-8");
const hljsJs = readFileSync(join(templateDir, "vendor", "highlight.min.js"), "utf-8");
const themeVars = generateThemeVars(themeName);
@ -171,6 +172,7 @@ function generateHtml(sessionData: SessionData, themeName?: string): string {
.replace("{{JS}}", templateJs)
.replace("{{SESSION_DATA}}", sessionDataBase64)
.replace("{{MARKED_JS}}", markedJs)
.replace("{{MARKED_CJK_FRIENDLY_JS}}", markedCjkFriendlyJs)
.replace("{{HIGHLIGHT_JS}}", hljsJs);
}

View file

@ -43,6 +43,7 @@
<!-- Vendored libraries -->
<script>{{MARKED_JS}}</script>
<script>{{MARKED_CJK_FRIENDLY_JS}}</script>
<!-- highlight.js -->
<script>{{HIGHLIGHT_JS}}</script>

View file

@ -1614,7 +1614,8 @@
text: match[2],
tokens: this.lexer.inlineTokens(match[2])
};
}
},
emStrong: markedCjkFriendly().tokenizer.emStrong
},
renderer: {
// Sanitize link URLs with a scheme allow-list. Browsers strip C0

View file

@ -0,0 +1,6 @@
/**
* marked-cjk-friendly v0.1.2
* Copyright (c) 2024 Tatsunori Uchino, and authors and contributors of original packages (MIT License)
* https://github.com/tats-u/markdown-cjk-friendly/tree/main/packages/marked-cjk-friendly
*/
(function(){const d="\\u1100-\\u11ff\\u20a9\\u2329-\\u232a\\u2630-\\u2637\\u268a-\\u268f\\u2e80-\\u2e99\\u2e9b-\\u2ef3\\u2f00-\\u2fd5\\u2ff0-\\u303e\\u3041-\\u3096\\u3099-\\u30ff\\u3105-\\u312f\\u3131-\\u318e\\u3190-\\u31e5\\u31ef-\\u321e\\u3220-\\u3247\\u3250-\\ua48c\\ua490-\\ua4c6\\ua960-\\ua97c\\uac00-\\ud7a3\\ud7b0-\\ud7c6\\ud7cb-\\ud7fb\\uf900-\\ufaff\\ufe10-\\ufe19\\ufe30-\\ufe52\\ufe54-\\ufe66\\ufe68-\\ufe6b\\uff01-\\uffbe\\uffc2-\\uffc7\\uffca-\\uffcf\\uffd2-\\uffd7\\uffda-\\uffdc\\uffe0-\\uffe6\\uffe8-\\uffee\\u{16fe0}-\\u{16fe4}\\u{16ff0}-\\u{16ff6}\\u{17000}-\\u{18cda}\\u{18cff}-\\u{18d20}\\u{18d80}-\\u{18df2}\\u{18e00}-\\u{19191}\\u{191a0}-\\u{191d2}\\u{1aff0}-\\u{1aff3}\\u{1aff5}-\\u{1affb}\\u{1affd}-\\u{1affe}\\u{1b000}-\\u{1b128}\\u{1b132}\\u{1b150}-\\u{1b152}\\u{1b155}\\u{1b164}-\\u{1b168}\\u{1b170}-\\u{1b2fb}\\u{1d300}-\\u{1d356}\\u{1d360}-\\u{1d376}\\u{1f1ae}\\u{1f200}\\u{1f202}\\u{1f210}-\\u{1f219}\\u{1f21b}-\\u{1f22e}\\u{1f230}-\\u{1f231}\\u{1f237}\\u{1f23b}\\u{1f240}-\\u{1f248}\\u{1f260}-\\u{1f265}\\u{1f7da}\\u{20000}-\\u{3fffd}",k=new RegExp(`[${d}]`,"u"),y=new RegExp(`^((?![*_])[\\s\\p{P}\\p{S}${d}])`,"u");function P(e,t,n){return new RegExp(`^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)${e}(\\*+)(?=[\\s]|$)|${n}(\\*+)(?!\\*)(?=${t}|$)|(?!\\*)${t}(\\*+)(?=${n})|[\\s](\\*+)(?!\\*)(?=${e})|(?!\\*)${e}(\\*+)(?!\\*)(?=${e})|${n}(\\*+)(?=${n})`,"gu")}function B(e,t,n){return new RegExp(`^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)${e}(_+)(?=[\\s]|$)|${n}(_+)(?!_)(?=${t}|$)|(?!_)${t}(_+)(?=${n})|[\\s](_+)(?!_)(?=${e})|(?!_)${e}(_+)(?!_)(?=${e})`,"gu")}function I(e,t,n){return new RegExp(`^[^~]+(?=[^~])|(?!~)${e}(~~?)(?=[\\s]|$)|${n}(~~?)(?!~)(?=${t}|$)|(?!~)${t}(~~?)(?=${n})|[\\s](~~?)(?!~)(?=${e})|(?!~)${e}(~~?)(?!~)(?=${e})|${n}(~~?)(?=${n})`,"gu")}const h=`[\\p{P}\\p{S}${d}]`,D=`[\\s\\p{P}\\p{S}${d}]`,p=`[^\\s\\p{P}\\p{S}${d}]`,G=`(?![*_])[\\p{P}\\p{S}${d}]`,N=`(?![*_])[\\s\\p{P}\\p{S}${d}]`,U=`(?:[^\\s\\p{P}\\p{S}${d}]|[*_])`,F=`(?!~)[\\p{P}\\p{S}${d}]`,O=`(?!~)[\\s\\p{P}\\p{S}${d}]`,z=`(?:[^\\s\\p{P}\\p{S}${d}]|~)`,J=P(h,D,p),K=P(F,O,z),M=B(h,D,p),S=I(G,N,U);function A(e,t,n){let f=y.test(e);if(!f&&e){const u=t.length-n.length;if(u>=1){let c=u-1,a=t.charCodeAt(c);if(a>=65024&&a<=65038&&c>=1&&(c--,a=t.charCodeAt(c)),(a&64512)===56320&&c>=1){const s=t.codePointAt(c-1);s!==void 0&&s>65535&&(s>=917760&&s<=917999?f=!0:f=k.test(String.fromCodePoint(s)))}else f=k.test(String.fromCharCode(a))}}return f}function R(e,t){if(!(e[1]||e[2]||e[3]||e[4]))return!1;const n=String.fromCodePoint(e[0].codePointAt(0)),f=e.index+e[0].length,u=f<t.length?String.fromCodePoint(t.codePointAt(f)):"";return k.test(n)||k.test(u)}function x(e){return e.length>=5}function W(e){return!!(e[1]||e[2]||e[3]||e[4])}function q(e){return x(e)?!!e[4]:!!e[3]}function v(e){return x(e)?e[1]||e[3]||"":e[1]||e[2]||""}function H(){return{tokenizer:{emStrong(e,t,n=""){const{rules:f}=this,u=f.inline.emStrongLDelim.exec(e);if(!u)return!1;const c=x(u);if(c&&!W(u))return;if(q(u)&&n.match(f.other.unicodeAlphaNumeric))return!1;const a=v(u),s=A(n,t,e);if(!a||!n||f.inline.punctuation.exec(n)||s||k.test(a)){const $=[...u[0]].length-1;let r,o,g=$,i=0;const b=c&&n===u[0][0],C=f.inline.emStrongRDelimAst.source.includes("(?!~)");let m;u[0][0]==="*"?m=C?K:J:m=M,m.lastIndex=0;const _=t.slice(-1*e.length+$);let l;for(;(l=m.exec(_))!=null;){if(r=l[1]||l[2]||l[3]||l[4]||l[5]||l[6],!r)continue;o=[...r].length;const L=R(l,_),Q=!!(l[3]||l[4]),E=!!(l[5]||l[6]);if(Q&&!L){g+=o;continue}if(E||L){if($%3&&!(($+o)%3)){i+=o;continue}if(b&&E)break}if(g-=o,g>0)continue;o=Math.min(o,o+g+i);const V=l[0].codePointAt(0)>65535?2:1,j=e.slice(0,$+l.index+V+o);if(Math.min($,o)%2){const T=j.slice(1,-1);return{type:"em",raw:j,text:T,tokens:this.lexer.inlineTokens(T)}}const w=j.slice(2,-2);return{type:"strong",raw:j,text:w,tokens:this.lexer.inlineTokens(w)}}}},del(e,t,n=""){const{rules:f}=this,u=f.inline.delLDelim.exec(e);if(!u)return!1;const c=u[1]||"",a=A(n,t,e);if(!c||!n||f.inline.punctuation.exec(n)||a||k.test(c)){const s=[...u[0]].length-1;let $,r,o=s;S.lastIndex=0;const g=t.slice(-1*e.length+s);let i;for(;(i=S.exec(g))!=null;){if($=i[1]||i[2]||i[3]||i[4]||i[5]||i[6],!$||(r=[...$].length,r!==s))continue;const b=R(i,g);if((i[3]||i[4])&&!b){o+=r;continue}if(o-=r,o>0)continue;r=Math.min(r,r+o);const C=i[0].codePointAt(0)>65535?2:1,m=e.slice(0,s+i.index+C+r),_=m.slice(s,-s);return{type:"del",raw:m,text:_,tokens:this.lexer.inlineTokens(_)}}}}}}}globalThis.markedCjkFriendly=H})();

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,66 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createContext, runInContext } from "node:vm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { exportFromFile } from "../src/core/export-html/index.ts";
import { CURRENT_SESSION_VERSION, type SessionHeader } from "../src/core/session-manager.ts";
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
function loadMarkdownParser(html: string): (text: string) => string {
const sessionData = html.match(/<script id="session-data" type="application\/json">([^<]+)<\/script>/)?.[1];
const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)].map((match) => match[1]);
const viewer = scripts.pop();
if (!sessionData || !viewer) throw new Error("Missing exported session data or viewer script");
const context = createContext({
atob,
TextDecoder,
URLSearchParams,
document: { getElementById: () => ({ textContent: sessionData }), querySelector: () => null },
window: { location: { search: "" } },
});
for (const script of scripts) {
runInContext(script, context);
}
return runInContext(
viewer.replace("function safeMarkedParse(", "return safeMarkedParse; function safeMarkedParse("),
context,
);
}
// #10154
describe("HTML export CJK emphasis", () => {
let tempDir: string;
let parse: (text: string) => string;
beforeAll(async () => {
initTheme("dark");
tempDir = mkdtempSync(join(tmpdir(), "pi-export-cjk-"));
const header = {
type: "session",
version: CURRENT_SESSION_VERSION,
id: "cjk-test",
timestamp: new Date(0).toISOString(),
cwd: tempDir,
} satisfies SessionHeader;
const sessionPath = join(tempDir, "session.jsonl");
writeFileSync(sessionPath, `${JSON.stringify(header)}\n`);
parse = loadMarkdownParser(readFileSync(await exportFromFile(sessionPath, join(tempDir, "out.html")), "utf8"));
});
afterAll(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("renders emphasis delimited next to CJK punctuation", () => {
expect(parse("**这是测试。**后面")).toBe("<p><strong>这是测试。</strong>后面</p>\n");
expect(parse("**一句话说清:**一台机器")).toBe("<p><strong>一句话说清:</strong>一台机器</p>\n");
expect(parse("**要点到此为止。**20 小时")).toBe("<p><strong>要点到此为止。</strong>20 小时</p>\n");
expect(parse("字**「引用」**字")).toBe("<p>字<strong>「引用」</strong>字</p>\n");
expect(parse("*斜体。*后面")).toBe("<p><em>斜体。</em>后面</p>\n");
});
it("keeps CommonMark flanking rules for non-CJK text", () => {
expect(parse("**end.**Then next")).toBe("<p>**end.**Then next</p>\n");
});
});