Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions examples/alipay/pages/streaming.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
// 与真实聊天接口的前端消费方式一致。页面卸载或关闭开关时必须取消定时器。
function createStreamPlayer(options) {
const content = options.content;
const chunkSize = options.chunkSize == null ? 1 : options.chunkSize;
const interval = options.interval == null ? 120 : options.interval;
const chunkSize = options.chunkSize == null ? 4 : options.chunkSize;
const interval = options.interval == null ? 30 : options.interval;
const chars = Array.from(content);
const chunks = [];
for (let i = 0; i < chars.length; i += chunkSize) {
Expand All @@ -14,8 +14,8 @@ function createStreamPlayer(options) {
let runId = 0;

function delayAfter(chunk) {
if (/\n\s*$/.test(chunk)) return interval + 180;
if (/[。!?;:.!?]\s*$/.test(chunk)) return interval + 100;
if (/\n\s*$/.test(chunk)) return interval + 40;
if (/[。!?;:.!?]\s*$/.test(chunk)) return interval + 20;
return interval;
}

Expand Down
8 changes: 4 additions & 4 deletions examples/wechat/pages/streaming.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
// 与真实聊天接口的前端消费方式一致。页面卸载或关闭开关时必须取消定时器。
function createStreamPlayer(options) {
const content = options.content;
const chunkSize = options.chunkSize == null ? 1 : options.chunkSize;
const interval = options.interval == null ? 120 : options.interval;
const chunkSize = options.chunkSize == null ? 4 : options.chunkSize;
const interval = options.interval == null ? 30 : options.interval;
const chars = Array.from(content);
const chunks = [];
for (let i = 0; i < chars.length; i += chunkSize) {
Expand All @@ -14,8 +14,8 @@ function createStreamPlayer(options) {
let runId = 0;

function delayAfter(chunk) {
if (/\n\s*$/.test(chunk)) return interval + 180;
if (/[。!?;:.!?]\s*$/.test(chunk)) return interval + 100;
if (/\n\s*$/.test(chunk)) return interval + 40;
if (/[。!?;:.!?]\s*$/.test(chunk)) return interval + 20;
return interval;
}

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./es/Markdown/index": "./dist/es/Markdown/index.js",
"./es/MiniNodeRenderer/index": "./dist/es/MiniNodeRenderer/index.js",
"./es/*": "./dist/es/*",
"./shared/*": "./dist/shared/*",
"./components/*": "./dist/components/*",
Expand Down
35 changes: 6 additions & 29 deletions scripts/copy-component-assets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,29 +55,6 @@ function copyKatexTtfFonts(baseDest) {
}
}

function writeKatexTtfDataModule(baseDest) {
const srcDir = join(root, 'node_modules', 'katex', 'dist', 'fonts');
if (!existsSync(srcDir)) return;
const entries = [];
for (const f of readdirSync(srcDir).sort()) {
if (!f.endsWith('.ttf')) continue;
const base64 = readFileSync(join(srcDir, f)).toString('base64');
entries.push(` ${JSON.stringify(f)}: ${JSON.stringify('data:font/ttf;base64,' + base64)}`);
}
writeFileSync(
join(baseDest, 'katex-font-data.js'),
[
"'use strict';",
'',
'// Generated by scripts/copy-component-assets.mjs. Used by Alipay real-device loadFontFace.',
'module.exports = {',
entries.join(',\n'),
'};',
'',
].join('\n'),
);
}

// 1. 静态资源 — keep legacy es/* and the unified components/* entry in sync.
const alipaySrc = join(srcRoot, 'alipay');
const wechatSrc = join(srcRoot, 'wechat');
Expand Down Expand Up @@ -157,10 +134,10 @@ if (existsSync(pluginsSrc)) {
console.log('[x-markdown-mini] plugin styles copied to dist/plugins and dist/miniprogram_dist/plugins');
}

// 1c. KaTeX runtime fonts — publish real ttf files in the npm package.
// They are loaded lazily via loadFontFace only when latex is enabled / formula nodes exist.
copyKatexTtfFonts(distRoot);
// 1c. KaTeX runtime fonts — WeChat only.
// Alipay 真机 loadFontFace 只认白名单 https CDN ttf(见 shared/loadKatexFonts.ts 的 KATEX_FONT_CDN),
// 包内本地 ttf 与 base64 data URI 均不生效,故不再向 Alipay 包根(distRoot)发 ttf、也不再生成
// base64 数据模块(katex-font-data.js)——净减包体。WeChat 的 /miniprogram_npm/.../katex-fonts 是
// 构建npm 真实产出的可解析路径,作为「本地优先 + CDN 兜底」的本地那一档,仍然发布。
copyKatexTtfFonts(distMpRoot);
writeKatexTtfDataModule(distRoot);
writeKatexTtfDataModule(distMpRoot);
console.log('[x-markdown-mini] KaTeX ttf fonts copied and data modules generated');
console.log('[x-markdown-mini] KaTeX ttf fonts copied to miniprogram_dist (WeChat local fallback)');
8 changes: 6 additions & 2 deletions scripts/copy-miniprogram-dist.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,14 @@ function copy(src, dest) {
// Main library CJS bundle
copy(join(distRoot, 'index.js'), join(mpRoot, 'index.js'));

// Shared helper
// Shared helpers
copy(
join(distRoot, 'shared', 'flattenInline.js'),
join(mpRoot, 'shared', 'flattenInline.js'),
);
copy(
join(distRoot, 'shared', 'loadKatexFonts.js'),
join(mpRoot, 'shared', 'loadKatexFonts.js'),
);

console.log('[x-markdown-mini] copied index.js + shared/flattenInline.js to miniprogram_dist');
console.log('[x-markdown-mini] copied index.js + shared/{flattenInline,loadKatexFonts}.js to miniprogram_dist');
5 changes: 5 additions & 0 deletions scripts/prepare-publish.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ const pub = {
import: './index.mjs',
require: './index.js',
},
// 无扩展名子路径显式映射:小程序 usingComponents 不能带扩展名,而 Node exports 的 `*`
// 通配不会自动补扩展名,导致 Minifish 尊重 exports 时解析 `.../es/Markdown/index` 失败、
// 组件加载不出来。这里补上直达 .js 的精确映射(比 `./es/*` 通配更具体,优先命中)。
'./es/Markdown/index': './es/Markdown/index.js',
'./es/MiniNodeRenderer/index': './es/MiniNodeRenderer/index.js',
'./es/*': './es/*',
'./shared/*': './shared/*',
'./components/*': './components/*',
Expand Down
16 changes: 16 additions & 0 deletions src/__tests__/alipayInlineLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,20 @@ describe('Alipay inline links', () => {
expect(sharedElements).toMatch(/\.md-del[\s\S]*?text-decoration:\s*line-through/);
expect(rendererAcss).toContain('.md-link--active');
});

it('never per-char animates a list marker (would split "1." across lines)', () => {
// 流式动画把文本拆成逐字 <text>,但列表 marker(.md-list-marker,仅 28rpx 宽的 flex 项)
// 拆开后 "1" 和 "." 会分行。marker 必须整体渲染,故动画分支门控排除 marker。
const axml = readFileSync(
resolve(root, 'src/components/alipay/MiniNodeRenderer/index.axml'),
'utf8',
);
const sjs = readFileSync(
resolve(root, 'src/components/alipay/MiniNodeRenderer/index.sjs'),
'utf8',
);
expect(axml).toContain('u.isText(node.name) && animation && !u.isMarker(node)');
expect(sjs).toMatch(/function isMarker\(node\)\s*\{[\s\S]*?md-list-marker/);
expect(sjs).toContain('isMarker: isMarker');
});
});
14 changes: 8 additions & 6 deletions src/__tests__/examplesStreaming.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ describe('mini-program streaming examples', () => {
vi.useRealTimers();
});

it(`${platform} defaults to a human-visible LLM-like pace`, () => {
it(`${platform} defaults to a brisk LLM-like pace`, () => {
const helper = resolve(root, `examples/${platform}/pages/streaming.js`);
const { createStreamPlayer } = require(helper) as {
createStreamPlayer: (options: {
Expand All @@ -62,12 +62,13 @@ describe('mini-program streaming examples', () => {
});

player.play();
vi.advanceTimersByTime(100);
// 初始空帧同步发出;首个 chunk 在默认 30ms 间隔后到达(远快于旧的 120ms/字)。
expect(frames.at(-1)).toEqual(['', true, 'streaming']);

vi.advanceTimersByTime(30);
const firstBurst = frames.at(-1)!;
expect(firstBurst[0].length).toBeGreaterThan(0);
// 默认每帧显现多个字符(chunkSize>1),而不是逐字——这就是「快一点」。
expect(firstBurst[0].length).toBeGreaterThan(1);
expect(firstBurst[0].length).toBeLessThan(content.length);
expect(firstBurst[1]).toBe(true);

Expand All @@ -76,7 +77,7 @@ describe('mini-program streaming examples', () => {
vi.useRealTimers();
});

it(`${platform} reveals one character per upstream frame by default`, () => {
it(`${platform} reveals multiple characters per frame by default`, () => {
const helper = resolve(root, `examples/${platform}/pages/streaming.js`);
const { createStreamPlayer } = require(helper) as {
createStreamPlayer: (options: {
Expand All @@ -88,13 +89,14 @@ describe('mini-program streaming examples', () => {

vi.useFakeTimers();
const frames: string[] = [];
// 默认 chunkSize=4:5 字 → ['逐字逐字','逐'],两帧显现(比逐字更快)。
createStreamPlayer({
content: '逐字',
content: '逐字逐字逐',
interval: 20,
onFrame: (content) => frames.push(content),
}).play();
vi.runAllTimers();
expect(frames).toEqual(['', '逐', '逐字']);
expect(frames).toEqual(['', '逐字逐字', '逐字逐字逐']);
vi.useRealTimers();
});
}
Expand Down
56 changes: 32 additions & 24 deletions src/__tests__/platformStyles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ describe('platform presentation consistency', () => {
});

it('assigns Alipay KaTeX fonts via single-class kxf-* rules + unique families', () => {
// 真机根因:(1) loadFontFace 同名 family 只认首字面 → 每字面唯一 family;(2) woff 虽回调
// loaded 但不参与 <text> 排版,支付宝 runtime 必须加载 ttf;(3) KaTeX 经嵌套
// <mini-node-renderer> 渲染,跨组件边界的后代选择器 / view→text 继承都不可靠 → 字体意图由
// plugins/Latex/index.ts 下沉成 kxf-* 类盖到字形 <text>,再用单类选择器赋字体(命中 text 自身)。
// 真机根因:(1) loadFontFace 同名 family 只认首字面 → 每字面唯一 family;(2) 本地路径 / base64
// data URI 只在模拟器生效,真机只认白名单 https CDN ttf;(3) KaTeX 经嵌套 <mini-node-renderer>
// 渲染,跨组件边界的后代选择器 / view→text 继承都不可靠 → 字体意图由 plugins/Latex/index.ts
// 下沉成 kxf-* 类盖到字形 <text>,再用单类选择器赋字体(命中 text 自身)。
const acss = source('../plugins/Latex/style.acss');
const loader = source('../components/shared/loadKatexFonts.ts');
// 必须是「单类」选择器(.kxf-x {),不能是会跨组件边界失效的后代选择器(.katex … text)。
Expand All @@ -48,18 +48,18 @@ describe('platform presentation consistency', () => {
}
expect(loader).toContain("alipayFile: 'KaTeX_Main-Regular.ttf'");
expect(loader).toContain("alipayFile: 'KaTeX_Math-Italic.ttf'");
expect(loader).toMatch(/const ALIPAY_LOCAL_BASES = \[\s*'\/katex-fonts'/);
expect(loader).toMatch(/if \(isAlipay\) \{\s*for \(let i = 0; i < localBases\.length; i\+\+\)/);
expect(loader).toContain('function loadInlineFontData()');
expect(loader).toContain("req('../../katex-font-data.js')");
expect(loader).toContain("data && data[f.alipayFile]");
expect(loader).toContain('if (isAlipay) {');
expect(loader).toContain("CDN + '/' + f.alipayFile");
expect(loader).toContain("'/node_modules/' + PACKAGE_NAME + '/dist/katex-fonts'");
expect(loader).toContain("'/node_modules/' + PACKAGE_NAME + '/katex-fonts'");
expect(loader).toContain("'/miniprogram_npm/' + PACKAGE_NAME + '/dist/miniprogram_dist/katex-fonts'");
// 支付宝走白名单 CDN ttf:映射表覆盖每个 alipayFile,且真机分支只 push CDN url(无本地/base64)。
expect(loader).toContain('export const KATEX_FONT_CDN');
expect(loader).toContain("'KaTeX_Main-Regular.ttf': 'https://mdn.alipayobjects.com");
expect(loader).toContain("'KaTeX_Size4-Regular.ttf': 'https://mdn.alipayobjects.com");
expect(loader).toMatch(/if \(isAlipay\) \{\s*[\s\S]*?if \(cdn\) sources\.push\('url\("' \+ cdn \+ '"\)'\);\s*return sources;/);
// 真机不再依赖包内本地路径 / base64 data URI(模拟器专属,已移除)。
expect(loader).not.toContain('ALIPAY_LOCAL_BASES');
expect(loader).not.toContain('loadInlineFontData');
expect(loader).not.toContain('katex-font-data.js');
expect(loader).not.toContain('/node_modules/');
// 微信仍保留可解析的本地 miniprogram_npm ttf(构建npm 真实产出)作 CDN 之外的兜底。
expect(loader).toContain("'/miniprogram_npm/' + PACKAGE_NAME + '/katex-fonts'");
expect(loader).toMatch(/sources\.push\('url\("' \+ localBases\[i\] \+ '\/' \+ f\.alipayFile \+ '"\)'\);/);
});

it('keeps Alipay typewriter text classes on the real text leaf', () => {
Expand All @@ -74,21 +74,29 @@ describe('platform presentation consistency', () => {
expect(axml).toContain('style="{{u.styleOf(c)}}"');
});

it('keeps all KaTeX font-ready callbacks until fonts finish loading', () => {
it('notifies font-ready at most once and only when not-yet-ready', () => {
// <Markdown> may start font loading before nodes exist; <MiniNodeRenderer> registers
// after it receives KaTeX nodes. Real devices need both callbacks preserved so a
// late renderer can still force a redraw after loadFontFace completes.
// after it receives KaTeX nodes. onKatexFontsReady queues waiters until loadFontFace
// completes, flushes them ONCE, and — critically — does NOT fire when already ready.
// 后者是消除「流式每个 chunk 全页闪」的关键:已就绪的调用方不再触发强制重排。
const loader = source('../components/shared/loadKatexFonts.ts');
expect(loader).toContain('export function areKatexFontsReady()');
expect(loader).toContain('export function ensureKatexFonts()');
expect(loader).toContain('export function onKatexFontsReady(');
expect(loader).toContain('readyCallbacks');
expect(loader).toMatch(/if \(onReady\) readyCallbacks\.push\(onReady\);/);
expect(loader).toMatch(/if \(ready\) \{\s*callSafe\(onReady\);/s);
// 只在尚未就绪时入队;已就绪直接 return(不回调、不重绘)。
expect(loader).toMatch(/if \(ready\) return;\s*readyCallbacks\.push\(cb\);/s);
// 全部字面完成后一次性 flush。
expect(loader).toMatch(/for \(let i = 0; i < callbacks\.length; i\+\+\) callSafe\(callbacks\[i\]\);/);
});

it('build script emits inline ttf font data modules for package roots', () => {
it('build script publishes WeChat-only ttf and no base64 data module', () => {
// 支付宝真机走 CDN ttf,包内本地 ttf / base64 data URI 均不生效 → 不再向 Alipay 包根发 ttf、
// 不再生成 katex-font-data.js。微信的 miniprogram_dist 仍发布本地 ttf 作 CDN 之外的兜底。
const buildScript = source('../../scripts/copy-component-assets.mjs');
expect(buildScript).toContain('writeKatexTtfDataModule(distRoot)');
expect(buildScript).toContain('writeKatexTtfDataModule(distMpRoot)');
expect(buildScript).toContain('data:font/ttf;base64,');
expect(buildScript).toContain('copyKatexTtfFonts(distMpRoot)');
expect(buildScript).not.toContain('copyKatexTtfFonts(distRoot)');
expect(buildScript).not.toContain('writeKatexTtfDataModule');
expect(buildScript).not.toContain('data:font/ttf;base64,');
});
});
31 changes: 20 additions & 11 deletions src/__tests__/plugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,26 +187,28 @@ describe('Latex — tokenizer', () => {
});

it('registers KaTeX fonts globally only when latex is enabled', () => {
// 真机修复:组件作用域 @font-face 在支付宝真机不生效,改用 loadFontFace 全局注册;
// 且必须仅在开启 latex 时调用——没用公式的页面不应加载这些字体。
// 真机修复:组件作用域 @font-face 在支付宝真机不生效,改用 loadFontFace 全局注册(支付宝走
// 白名单 CDN ttf,微信本地 miniprogram_npm ttf 优先 + CDN 兜底);且仅在开启 latex 时调用。
const loader = readFileSync(
new URL('../components/shared/loadKatexFonts.ts', import.meta.url),
'utf8',
);
expect(loader).toContain('loadFontFace');
expect(loader).toContain('global: true');
expect(loader).toContain('/node_modules/');
expect(loader).toContain('mdn.alipayobjects.com');
expect(loader).toContain('/miniprogram_npm/');
expect(loader).toContain('/katex-fonts');
// 支付宝分支不再引用包内本地路径(模拟器专属,真机不生效)。
expect(loader).not.toContain('/node_modules/');

for (const platform of ['alipay', 'wechat']) {
const src = readFileSync(
new URL(`../components/${platform}/Markdown/index.ts`, import.meta.url),
'utf8',
);
expect(src).toContain('loadKatexFonts');
// 调用必须门控在 latex 判断之后
expect(src).toMatch(/latex\)\s*loadKatexFonts\(/);
expect(src).toContain('ensureKatexFonts');
expect(src).toMatch(/latex[\s\S]{0,200}ensureKatexFonts\(\)/);
}
});

Expand All @@ -215,16 +217,16 @@ describe('Latex — tokenizer', () => {
new URL('../components/alipay/MiniNodeRenderer/index.ts', import.meta.url),
'utf8',
);
expect(renderer).toContain('loadKatexFonts');
expect(renderer).toContain('ensureKatexFonts');
expect(renderer).toContain("indexOf('katex')");
expect(renderer).toMatch(/if\s*\([^)]*\(this\.props\.nodes\)[^)]*\)\s*loadKatexFonts\(/);
expect(renderer).toMatch(/if\s*\([^)]*\(this\.props\.nodes\)[^)]*\)\s*ensureKatexFonts\(/);
// Must reuse the shared all-faces loader, not a crippled local stub that only
// registers one face (the JS-接入 page relies on this for every KaTeX font on
// device — registering only KaTeX_Math left every other glyph in the system font).
expect(renderer).toMatch(
/import\s*\{[^}]*loadKatexFonts[^}]*\}\s*from\s*'\.\.\/\.\.\/shared\/loadKatexFonts\.js'/,
/from\s*'\.\.\/\.\.\/shared\/loadKatexFonts\.js'/,
);
expect(renderer).not.toMatch(/function\s+loadKatexFonts\s*\(/);
expect(renderer).not.toMatch(/function\s+ensureKatexFonts\s*\(/);

// The shared loader must register the full KaTeX face set, not just one family.
const loader = readFileSync(
Expand All @@ -236,7 +238,7 @@ describe('Latex — tokenizer', () => {
}
});

it('guards Alipay KaTeX reflow callbacks after component unmount', () => {
it('guards Alipay KaTeX reflow: once-only, idle-gated, unmount-safe', () => {
const files = [
'../components/alipay/Markdown/index.ts',
'../components/alipay/MiniNodeRenderer/index.ts',
Expand All @@ -247,7 +249,14 @@ describe('Latex — tokenizer', () => {
expect(src, file).toContain('mounted: false');
expect(src, file).toContain('this.mounted = true;');
expect(src, file).toContain('this.mounted = false;');
expect(src, file).toContain('if (this.mounted) this._katexFontReflow();');
// 每实例至多重排一次 + 空闲态门控(消除流式全页闪)。
expect(src, file).toContain('_maybeKatexReflow');
expect(src, file).toContain('areKatexFontsReady()');
expect(src, file).toMatch(/if \(this\._isStreaming\) return;/);
// 就绪回调里 unmount / 已重排 / 仍在流式 均跳过,再执行卸载-重挂重绘。
expect(src, file).toMatch(
/if \(!this\.mounted \|\| this\._katexReflowed \|\| this\._isStreaming\) return;\s*this\._katexReflowed = true;\s*this\._katexFontReflow\(\);/s,
);
expect(src, file).toMatch(
/this\.setData\(\{ katexReflow: false \}, \(\) => \{\s*if \(this\.mounted\) this\.setData\(\{ katexReflow: true \}\);/s,
);
Expand Down
Loading
Loading