@sendevia/pi-ask-author 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,673 +0,0 @@
1
- /**
2
- * @file novel-markdown.ts
3
- * @description 小说语法高亮引擎:把语义定界符(`「」` / `()`)标注为携带哨兵的原生行内代码,折行与配色交还 Pi 原生 Markdown 引擎
4
- *
5
- * 核心架构特性:
6
- * - 声明式语法模板表(`NOVEL_TEMPLATE_*` 派生索引)驱动「定界符 → 语义哨兵 → 行内代码载体 → 主题样式」映射
7
- * - 对话台词「...」与旁白心理(...)由 `MarkdownOptions.transform` 在 marked 解析前完成标注
8
- * - `theme.code` 回调兼作哨兵分类器,未命中哨兵即完整交还原生行内代码样式
9
- * - 折行、嵌套与配色全部由原生 Markdown 引擎回填,插件不触碰任何 ANSI 转义流
10
- * - 零文本改动不变量:输出剥离哨兵与新增反引号后与输入完全等价
11
- *
12
- * 索引约定:全文「扁平索引 / 行内列索引」一律指 UTF-16 码元下标,含义与 `String.prototype.length` / `charAt` / `slice` 一致
13
- *
14
- * 依赖方向:novel-markdown.ts → format.ts / texts.ts / theme.ts(另行引入 model.ts 的 `MenuItem` 类型,无回环依赖);外部依赖 `@earendil-works/pi-tui` 的 `Markdown` / `MarkdownTheme` 与 `@earendil-works/pi-coding-agent` 的 `getMarkdownTheme`
15
- * 宿主主题耦合:围栏代码块由 `getMarkdownTheme()` 的 `highlightCode` 着色,提取颜色读取宿主全局活动主题(须宿主已 `initTheme`,本模块不自行持有主题);行内代码仅在未命中哨兵时走同一全局 `code` 提取颜色
16
- */
17
-
18
- import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
19
- import { Markdown, type MarkdownTheme } from "@earendil-works/pi-tui";
20
- import { cleanText } from "./format.js";
21
- import type { MenuItem } from "./model.js";
22
- import {
23
- NOVEL_MARKER_PREFIX,
24
- NOVEL_TEMPLATE_BY_MARKER,
25
- NOVEL_TEMPLATE_BY_OPENER,
26
- type NovelSyntaxTemplate,
27
- TEXTS,
28
- } from "./texts.js";
29
- import type { Theme } from "./theme.js";
30
-
31
- /**
32
- * 基于 `getMarkdownTheme()` 定制小说预览主题;`code` 回调兼作哨兵分类器(命中即着色并剥离哨兵,未命中交还原生样式)
33
- * 覆盖项统一收敛到注入的 TUI 主题前景色族(仅 `code` 未命中哨兵时回退 `getMarkdownTheme()` 的 `mdCode` 提取颜色,即 Pi 运行时全局活动主题);取自 `getMarkdownTheme()` 的原生实现(代码块由 `highlightCode` 走原生语法高亮)
34
- * @param theme - 当前 TUI 主题实例
35
- * @returns 字段集已由 `getMarkdownTheme()` 展开补齐的完整 `MarkdownTheme`
36
- */
37
- function createNovelMarkdownTheme(theme: Theme): MarkdownTheme {
38
- const base = getMarkdownTheme();
39
- return {
40
- ...base,
41
- heading: (text) => theme.fg("accent", theme.bold(text)),
42
- quote: (text) => theme.fg("muted", text),
43
- quoteBorder: (text) => theme.fg("dim", text),
44
- listBullet: (text) => theme.fg("accent", text),
45
- code: (text) => classifyNovelCode(theme, text) ?? base.code(text),
46
- codeBlockBorder: (text) => theme.fg("dim", text),
47
- bold: (text) => theme.bold(text),
48
- italic: (text) => theme.italic(text),
49
- link: (text) => theme.fg("accent", text),
50
- linkUrl: (text) => theme.fg("dim", text),
51
- };
52
- }
53
-
54
- /**
55
- * 解析行内代码文本中的哨兵序列并按语法模板逐段着色;未命中哨兵返回 undefined 交还原生样式
56
- * 内容为「`{@link NOVEL_MARKER_PREFIX}` + 哨兵 + 语义原文」的可重复序列,相邻语义片段打包进同一围栏
57
- * @param theme - 当前 TUI 主题实例
58
- * @param text - `theme.code` 收到的行内代码原文
59
- * @returns 逐段着色后的文本(哨兵前缀与哨兵字符已剥离);文本不以哨兵前缀开头,或哨兵序列损坏(缺失哨兵字符 / 中途再现前缀)时返回 `undefined`,由调用方回退原生行内代码样式
60
- */
61
- function classifyNovelCode(theme: Theme, text: string): string | undefined {
62
- if (!text.startsWith(NOVEL_MARKER_PREFIX)) return undefined;
63
-
64
- const painted: string[] = [];
65
- let cursor = 0;
66
- while (cursor < text.length) {
67
- if (text.charAt(cursor) !== NOVEL_MARKER_PREFIX) return undefined;
68
- const template = NOVEL_TEMPLATE_BY_MARKER.get(text.charAt(cursor + 1));
69
- if (!template) return undefined;
70
- const next = text.indexOf(NOVEL_MARKER_PREFIX, cursor + 2);
71
- const end = next < 0 ? text.length : next;
72
- painted.push(template.paint(theme, text.slice(cursor + 2, end)));
73
- cursor = end;
74
- }
75
-
76
- return painted.length > 0 ? painted.join("") : undefined;
77
- }
78
-
79
- /** 围栏代码块开启行识别正则(CommonMark:≤3 空格缩进 + 3 个及以上反引号/波浪号) */
80
- const FENCE_OPENING_PATTERN = /^ {0,3}(`{3,}|~{3,})/;
81
-
82
- /** 缩进代码行识别正则(≥4 空格缩进后接非空内容) */
83
- const INDENTED_CODE_PATTERN = /^ {4,}\S/;
84
-
85
- /** 原生引擎按原文输出的块级起始行识别正则(HTML 块与表格行),命中即整块放弃标注;`<` 收紧为 CommonMark 合法标签起始;单独的 `$` 与 `\[` 由数学区掩码接管(见 {@link maskCodeRegions}) */
86
- const RAW_BLOCK_PATTERN = /^ {0,3}(?:<(?=[/!?a-zA-Z])|\|)/;
87
-
88
- /** 块级数学区开启行(行首 `$$` 或 `\[…`),与原生 `tokenizeBlockLatex` 的 `start` 接受范围一致 */
89
- const BLOCK_MATH_OPENING_PATTERN = /^ {0,3}(?:\$\$|\\\[)/;
90
-
91
- /**
92
- * 判定行内是否存在行内数学起始定界符(`$x$` / `$$…` / `\(…` / `\[…`)
93
- * 原生 `tokenizeInlineLatex` 区段经 `renderLatex` 按原文渲染、绕过 `theme.code` 回调,哨兵进入其中即泄漏到终端,故一律放弃标注
94
- * `\$` 转义与 `$` 后紧跟空白(原生按字面量处理)不触发
95
- * @param line - 待判定行
96
- * @returns 命中未闭合数学起始符(`$$` 或 `\[`)时为 true
97
- */
98
- function containsMathOpener(line: string): boolean {
99
- for (let index = 0; index < line.length; index++) {
100
- const char = line.charAt(index);
101
- if (char === "\\") {
102
- if (line.charAt(index + 1) === "(" || line.charAt(index + 1) === "[") return true;
103
- index++; // 跳过转义对,`\$` 为字面量美元符
104
- continue;
105
- }
106
- if (char !== "$") continue;
107
- const next = line.charAt(index + 1);
108
- if (next === "" || /\s/.test(next)) continue;
109
- return true;
110
- }
111
- return false;
112
- }
113
-
114
- /** 块引用前缀识别正则(兼容 `> ` 与嵌套 `> > ` 写法) */
115
- const QUOTE_PREFIX_PATTERN = /^(?: {0,3}>[ \t]?)+/;
116
-
117
- /** 链接定义行识别正则(`def` 令牌不带 `text` 字段,原生 `renderToken` 的 default 分支不会输出该行;哨兵进入其中即随令牌一并消失,故整行屏蔽) */
118
- const LINK_DEFINITION_PATTERN = /^ {0,3}\[[^\]\n]*\]:/;
119
-
120
- /**
121
- * 剥离行首块引用前缀(兼容 `> ` 嵌套)
122
- * @param line - 待处理行
123
- * @returns 剥离后的行文本;无引用前缀时原样返回
124
- */
125
- function stripQuotePrefix(line: string): string {
126
- return line.replace(QUOTE_PREFIX_PATTERN, "");
127
- }
128
-
129
- /**
130
- * 判定是否为 GFM 表格分隔行(兼容引用块内表格)
131
- * @param line - 待判定行
132
- * @returns 含至少一个管道符与短横线单元格时为 true
133
- */
134
- function isTableSeparator(line: string): boolean {
135
- if (!line.includes("|")) return false;
136
- return /^ {0,3}\|?[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$/.test(stripQuotePrefix(line));
137
- }
138
-
139
- /** 块级结构行识别正则(标题、列表、引用、表格、围栏),语义跨距跨越即放弃高亮 */
140
- const BLOCK_STRUCTURE_PATTERN = /^ {0,3}(?:#{1,6}[ \t]|[-+*][ \t]|\d{1,9}[.)][ \t]|>|\||`{3,}|~{3,})/;
141
-
142
- /**
143
- * Markdown 行内标记敏感字符模式(强调、链接、原始 HTML、公式、转义、删除线、表格管道符、实体引用)
144
- * 行内代码内容按字面量呈现,语义跨距一旦含这些字符,标注就会吞掉作者书写的标记并改变可见文本,
145
- * 故此类跨距一律放弃标注——「零文本改动」优先于「覆盖率」
146
- */
147
- const MARKDOWN_SENSITIVE_PATTERN = /[`*_\[\]<>$\\|]|~~|&(?:[A-Za-z][A-Za-z0-9]*|#\d+|#[xX][0-9a-fA-F]+);/;
148
-
149
- /** 行索引 → 行首扁平索引的映射表(源码行首索引严格递增;下标单位见文件头「索引约定」) */
150
- type LineStartIndex = readonly number[];
151
-
152
- /** 代码上下文掩码:长度等于 `source.length`(UTF-16 码元数,逐码元 1:1 对应),`1` 表示该码元处于代码块、行内代码或原生引擎按原文渲染的块级上下文中 */
153
- type NovelCodeMask = Uint8Array;
154
-
155
- /** 待闭合的语义帧(记录语法模板与开始定界符位置) */
156
- interface NovelOpenFrame {
157
- /** 命中的语法模板 */
158
- readonly template: NovelSyntaxTemplate;
159
- /** 开始定界符的扁平字符索引 */
160
- readonly start: number;
161
- }
162
-
163
- /** 已配对且通过约束校验的语义跨距(源码行坐标,左闭右开) */
164
- interface NovelSyntaxSpan {
165
- /** 命中的语法模板 */
166
- readonly template: NovelSyntaxTemplate;
167
- /** 起始定界符的扁平字符索引(用于嵌套包含性判定) */
168
- readonly startFlat: number;
169
- /** 起始行索引(0-based) */
170
- readonly startLine: number;
171
- /** 起始列索引(0-based,含) */
172
- readonly startCol: number;
173
- /** 结束行索引(0-based,含) */
174
- readonly endLine: number;
175
- /** 结束列索引(0-based,不含) */
176
- readonly endCol: number;
177
- }
178
-
179
- /** 单行局部的待标注片段(行内列坐标,左闭右开) */
180
- interface NovelLineSegment {
181
- /** 起始列索引(含) */
182
- readonly start: number;
183
- /** 结束列索引(不含) */
184
- readonly end: number;
185
- /** 命中的语法模板 */
186
- readonly template: NovelSyntaxTemplate;
187
- }
188
-
189
- /**
190
- * 计算源码各行行首的扁平索引表(相邻行首差值恒为「本行长度 + 1」,故严格递增)
191
- * @param lines - 源码行数组(行间以单个 `\n` 连接,元素不含行尾换行符)
192
- * @returns 行索引 → 行首扁平索引(UTF-16 码元下标)的只读数组,长度与 `lines` 相同;仅含行首项,末行之后的位置由 {@link resolveLineIndex} 收敛至末行
193
- */
194
- function indexLineStarts(lines: readonly string[]): LineStartIndex {
195
- const starts: number[] = [];
196
- let offset = 0;
197
- for (const line of lines) {
198
- starts.push(offset);
199
- offset += line.length + 1;
200
- }
201
- return starts;
202
- }
203
-
204
- /**
205
- * 计算源码的「代码上下文」掩码:围栏代码块整行、缩进代码行、行内代码片段,以及会被原生引擎按原文输出的块级上下文(表格 / HTML / LaTeX / 链接定义行)
206
- * 分词与标注一律跳过被标记字符:既不会改动作者书写的代码,也杜绝哨兵进入不经主题回调的渲染路径(代价是表格内放弃着色)
207
- * @param source - 完整源码(行间以 `\n` 连接)
208
- * @param lines - 源码行数组
209
- * @param lineStarts - 行首扁平索引表
210
- * @returns 与源码等长的掩码;含未配对反引号时为 `null`
211
- */
212
- function maskCodeRegions(source: string, lines: readonly string[], lineStarts: LineStartIndex): NovelCodeMask | null {
213
- const mask = new Uint8Array(source.length);
214
- /** 将扁平索引区间 `[from, to)` 整段标记为代码上下文(越界区间自动退化) */
215
- const markRange = (from: number, to: number): void => {
216
- for (let index = from; index < to; index++) mask[index] = 1;
217
- };
218
- /** 将整行(不含行尾换行符)标记为代码上下文(越界按空行处理) */
219
- const markLine = (lineIndex: number): void => {
220
- const lineStart = lineStarts[lineIndex] ?? 0;
221
- markRange(lineStart, lineStart + (lines[lineIndex]?.length ?? 0));
222
- };
223
-
224
- // 1. 行级代码与原文渲染上下文
225
- let fenceCloser: RegExp | null = null;
226
- let rawBlock = false;
227
- // 块级数学区(`$$…$$` / `\[…\]`)可跨空行,且未闭合时按 pending 语义吞并余下全部源码
228
- // (原生 `pendingBracket` 无条件吞并,`pendingDollar` 另需 `looksLikePendingDollarMath` 启发式;本掩码采用更宽的保守范围),故独立于「空行即终止」的 rawBlock 状态机
229
- let mathCloser: string | null = null;
230
- let mathContent = "";
231
- for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
232
- const line = lines[lineIndex] ?? "";
233
-
234
- // 空行终止 HTML / 表格等块级上下文;围栏与未闭合的块级数学区不受空行影响
235
- if (line.trim() === "") {
236
- rawBlock = false;
237
- if (fenceCloser !== null) markLine(lineIndex);
238
- if (mathCloser !== null) markLine(lineIndex);
239
- continue;
240
- }
241
-
242
- if (mathCloser !== null) {
243
- mathContent += line;
244
- markLine(lineIndex);
245
- const closerIndex = line.indexOf(mathCloser);
246
- if (closerIndex >= 0) {
247
- const remainder = line.slice(closerIndex + mathCloser.length);
248
- mathCloser = null;
249
- // 闭合后同行余段若仍含数学起始符,后续文本块继续按原文渲染,保持整块屏蔽
250
- if (containsMathOpener(remainder)) rawBlock = true;
251
- }
252
- continue;
253
- }
254
-
255
- if (fenceCloser !== null) {
256
- markLine(lineIndex);
257
- if (fenceCloser.test(line)) fenceCloser = null;
258
- continue;
259
- }
260
-
261
- if (rawBlock) {
262
- markLine(lineIndex);
263
- continue;
264
- }
265
-
266
- // 表格:分隔行命中时连同紧邻表头行一起屏蔽,后续行由 rawBlock 阻断
267
- if (isTableSeparator(line)) {
268
- markLine(lineIndex);
269
- rawBlock = true;
270
- const previous = lines[lineIndex - 1];
271
- if (previous !== undefined && previous.includes("|")) markLine(lineIndex - 1);
272
- continue;
273
- }
274
-
275
- const opening = FENCE_OPENING_PATTERN.exec(line);
276
- const run = opening?.[1];
277
- const infoString = line.slice(opening?.[0].length ?? 0);
278
- // 反引号围栏的信息串中不得再出现反引号(CommonMark 约束),否则按普通行内内容处理
279
- if (run !== undefined && (run[0] === "~" || !infoString.includes("`"))) {
280
- markLine(lineIndex);
281
- const char = run[0] ?? "`";
282
- fenceCloser = new RegExp(`^ {0,3}${char}{${run.length},}[ \\t]*$`);
283
- continue;
284
- }
285
-
286
- if (INDENTED_CODE_PATTERN.test(line)) {
287
- markLine(lineIndex);
288
- continue;
289
- }
290
-
291
- if (RAW_BLOCK_PATTERN.test(stripQuotePrefix(line))) {
292
- markLine(lineIndex);
293
- rawBlock = true;
294
- continue;
295
- }
296
-
297
- // 链接定义行是单行块级结构,仅屏蔽该行本身,不开启跨行 rawBlock
298
- if (LINK_DEFINITION_PATTERN.test(line)) {
299
- markLine(lineIndex);
300
- continue;
301
- }
302
-
303
- // 块级数学区:行首 `$$` / `\[…` 开启至闭合定界符所在行,未闭合时按 pending 语义跨空行吞并至源码末尾
304
- if (BLOCK_MATH_OPENING_PATTERN.test(line)) {
305
- const rest = line.replace(BLOCK_MATH_OPENING_PATTERN, "");
306
- mathContent = rest;
307
- markLine(lineIndex);
308
- if (!rest.includes(rest.startsWith("$$") ? "$$" : "\\]")) {
309
- mathCloser = rest.startsWith("$$") ? "$$" : "\\]";
310
- }
311
- continue;
312
- }
313
-
314
- // 行内数学(`$x$` / `\(…\)` / 行中 `$$` / `\[…`):起始符所在文本块整体放弃标注
315
- if (containsMathOpener(line)) {
316
- markLine(lineIndex);
317
- rawBlock = true;
318
- }
319
- }
320
-
321
- // 2. 行内代码片段:等长反引号串配对,跨行的片段内容整体标记
322
- let cursor = 0;
323
- while (cursor < source.length) {
324
- if (source[cursor] !== "`" || mask[cursor] === 1) {
325
- cursor++;
326
- continue;
327
- }
328
- let runLength = 0;
329
- while (source[cursor + runLength] === "`" && mask[cursor + runLength] === 0) runLength++;
330
- const closing = findInlineCodeClosing(source, mask, cursor + runLength, runLength);
331
- // 未配对反引号会让原生解析结果不可预测,此时放弃全部语义标注
332
- if (closing < 0) return null;
333
- markRange(cursor, closing + runLength);
334
- cursor = closing + runLength;
335
- }
336
-
337
- return mask;
338
- }
339
-
340
- /**
341
- * 查找与开启反引号串等长的行内代码闭合串(CommonMark:闭合串后不得紧跟反引号)
342
- * @param source - 完整源码
343
- * @param mask - 代码上下文掩码
344
- * @param from - 检索起始扁平索引
345
- * @param runLength - 开启串长度
346
- * @returns 闭合串起始索引;未找到为 `-1`(按字面量处理)
347
- */
348
- function findInlineCodeClosing(source: string, mask: NovelCodeMask, from: number, runLength: number): number {
349
- for (let index = from; index < source.length; index++) {
350
- if (source[index] !== "`" || mask[index] === 1) continue;
351
- let run = 0;
352
- while (source[index + run] === "`" && mask[index + run] === 0) run++;
353
- if (run === runLength && source[index + runLength] !== "`") return index;
354
- index += run - 1;
355
- }
356
- return -1;
357
- }
358
-
359
- /**
360
- * 对源码执行小说语法定界符嵌套配对
361
- * 逐字符线性扫描并跳过掩码命中字符;闭合帧时按 `startFlat >= frame.start` 回退剔除其内层已入列跨距(等价于「外层优先」的区间包含语义,且跨距互不重叠)
362
- * 超长 / 跨空行或块级结构行 / 内容含行内标记字符(详见 {@link MARKDOWN_SENSITIVE_PATTERN})的跨距一律放弃高亮
363
- * @param source - 完整源码
364
- * @param mask - 代码上下文掩码
365
- * @param lines - 源码行数组
366
- * @param lineStarts - 行首扁平索引表
367
- * @returns 通过约束校验的语义跨距列表(按闭合顺序,起始位置单调递增)
368
- */
369
- function pairNovelSyntax(
370
- source: string,
371
- mask: NovelCodeMask,
372
- lines: readonly string[],
373
- lineStarts: LineStartIndex,
374
- ): NovelSyntaxSpan[] {
375
- const stack: NovelOpenFrame[] = [];
376
- const spans: NovelSyntaxSpan[] = [];
377
-
378
- for (let index = 0; index < source.length; index++) {
379
- if (mask[index] === 1) continue;
380
- const char = source[index] ?? "";
381
-
382
- const template = NOVEL_TEMPLATE_BY_OPENER.get(char);
383
- if (template !== undefined) {
384
- stack.push({ template, start: index });
385
- continue;
386
- }
387
-
388
- const depth = findClosingDepth(stack, char);
389
- if (depth < 0) continue;
390
-
391
- const frame = stack[depth];
392
- stack.length = depth; // 丢弃更深层未闭合帧,保证回填互不重叠
393
- if (!frame) continue;
394
-
395
- const end = index + 1;
396
- if (end - frame.start > frame.template.maxSpanLength) continue;
397
- // 含行内标记字符时放弃该跨距(详见 {@link MARKDOWN_SENSITIVE_PATTERN})
398
- if (MARKDOWN_SENSITIVE_PATTERN.test(source.slice(frame.start, end))) continue;
399
-
400
- const startLine = resolveLineIndex(lineStarts, frame.start);
401
- const endLine = resolveLineIndex(lineStarts, end - 1);
402
- if (!isSpanWithinParagraph(lines, startLine, endLine)) continue;
403
-
404
- // 外层跨距成立时剔除其完整包含的内层跨距(外层优先)
405
- let inner = spans[spans.length - 1];
406
- while (inner !== undefined && inner.startFlat >= frame.start) {
407
- spans.pop();
408
- inner = spans[spans.length - 1];
409
- }
410
-
411
- spans.push({
412
- template: frame.template,
413
- startFlat: frame.start,
414
- startLine,
415
- startCol: frame.start - (lineStarts[startLine] ?? 0),
416
- endLine,
417
- endCol: end - (lineStarts[endLine] ?? 0),
418
- });
419
- }
420
-
421
- return spans;
422
- }
423
-
424
- /**
425
- * 在未闭合语义帧列表中自顶向下查找可被指定闭定界符闭合的帧层级
426
- * @param stack - 未闭合开定界符列表(自底向上)
427
- * @param closer - 待匹配的闭定界符
428
- * @returns 帧层级深度(0-based);无匹配帧返回 `-1`
429
- */
430
- function findClosingDepth(stack: readonly NovelOpenFrame[], closer: string): number {
431
- for (let depth = stack.length - 1; depth >= 0; depth--) {
432
- if (stack[depth]?.template.closer === closer) return depth;
433
- }
434
- return -1;
435
- }
436
-
437
- /**
438
- * 二分查找扁平索引所在行索引(行首索引表严格递增)
439
- * @param lineStarts - 行首扁平索引表
440
- * @param flatIndex - 目标扁平索引(可越过末行行首,此时收敛至末行)
441
- * @returns 满足 `lineStarts[i] <= flatIndex` 的最大行索引;空表返回 0
442
- */
443
- function resolveLineIndex(lineStarts: LineStartIndex, flatIndex: number): number {
444
- let low = 0;
445
- let high = Math.max(0, lineStarts.length - 1);
446
- while (low < high) {
447
- const mid = (low + high + 1) >> 1;
448
- if ((lineStarts[mid] ?? 0) <= flatIndex) low = mid;
449
- else high = mid - 1;
450
- }
451
- return low;
452
- }
453
-
454
- /**
455
- * 判定语义跨距是否完全位于同一文本块内(内部无空行且无块级结构行)
456
- * @param lines - 源码行数组
457
- * @param startLine - 起始行索引(0-based)
458
- * @param endLine - 结束行索引(0-based,含)
459
- * @returns 单行跨距恒为 true;多行跨距仅校验 `(startLine, endLine]` 区间的空行与 {@link BLOCK_STRUCTURE_PATTERN} 块级结构行(起始行不参与校验:其块级结构在原生引擎中仍按行内内容解析,标注不破坏结构)
460
- */
461
- function isSpanWithinParagraph(lines: readonly string[], startLine: number, endLine: number): boolean {
462
- if (startLine === endLine) return true;
463
- for (let lineIndex = startLine + 1; lineIndex <= endLine; lineIndex++) {
464
- const line = lines[lineIndex] ?? "";
465
- if (line.trim() === "" || BLOCK_STRUCTURE_PATTERN.test(line)) return false;
466
- }
467
- return true;
468
- }
469
-
470
- /**
471
- * 把一组首尾相接的语义片段包装为单个携带哨兵序列的原生行内代码(固定单反引号围栏;相邻片段合并)
472
- * @param line - 该行原始文本
473
- * @param group - 首尾相接的语义片段组(按位置升序,左闭右开且互不重叠)
474
- * @returns 单反引号围栏包裹的片段序列(每段为「哨兵前缀 + 哨兵字符 + 语义原文」,逐字透传、不插入任何空白)
475
- */
476
- function wrapNovelCode(line: string, group: readonly NovelLineSegment[]): string {
477
- const runs: string[] = [];
478
- for (const segment of group) {
479
- runs.push(`${NOVEL_MARKER_PREFIX}${segment.template.marker}${line.slice(segment.start, segment.end)}`);
480
- }
481
- return `\`${runs.join("")}\``;
482
- }
483
-
484
- /**
485
- * 在单行内插入语义标注,未命中区间原样透传(片段紧邻反引号时放弃该片段标注)
486
- * @param line - 该行原始文本
487
- * @param segments - 该行局部待标注片段(按位置升序、互不重叠)
488
- * @returns 标注后的行文本
489
- */
490
- function annotateNovelLine(line: string, segments: readonly NovelLineSegment[]): string {
491
- const chunks: string[] = [];
492
- let cursor = 0;
493
- let index = 0;
494
-
495
- while (index < segments.length) {
496
- // 非空收窄:外层 `while` 条件已保证 `index` 在界内,`head` 仅可能因越界读取而为 undefined
497
- const head = segments[index];
498
- if (!head) break;
499
- const group: NovelLineSegment[] = [head];
500
- // 合并首尾相接的片段为单个行内代码
501
- let tail = head;
502
- while (index + 1 < segments.length) {
503
- const next = segments[index + 1];
504
- if (!next || next.start !== tail.end) break;
505
- index++;
506
- tail = next;
507
- group.push(next);
508
- }
509
-
510
- const start = head.start;
511
- const end = tail.end;
512
- const leading = line.slice(cursor, start);
513
- const body = line.slice(start, end);
514
- // 防御性兜底:片段紧邻反引号时插入围栏会与之合并而破坏配对;正常情况下掩码层已排除此情形,
515
- // 此分支仅在掩码与原生解析存在边界差异时生效——宁可不标注也不改文本
516
- const touchingBacktick = line.charAt(start - 1) === "`" || line.charAt(end) === "`";
517
-
518
- chunks.push(leading, touchingBacktick ? body : wrapNovelCode(line, group));
519
- cursor = end;
520
- index++;
521
- }
522
-
523
- chunks.push(line.slice(cursor));
524
- return chunks.join("");
525
- }
526
-
527
- /**
528
- * 判定扁平区间内是否存在代码上下文字符
529
- * @param mask - 代码上下文掩码(长度等于源码 UTF-16 码元数)
530
- * @param from - 区间起点(含,扁平索引)
531
- * @param to - 区间终点(不含,扁平索引)
532
- * @returns 区间内存在掩码为 `1` 的码元时为 true;空区间恒为 false
533
- */
534
- function hasCodeCharacter(mask: NovelCodeMask, from: number, to: number): boolean {
535
- for (let index = from; index < to; index++) {
536
- if (mask[index] === 1) return true;
537
- }
538
- return false;
539
- }
540
-
541
- /**
542
- * 把已配对的语义跨距逐行标注为携带哨兵的原生行内代码并重组源码
543
- * 跨行跨距按源码行分别标注,换行语义、块级结构与折行行为保持原样,并天然获得原生引擎对折行片段的样式续色
544
- * 与代码上下文重叠的行片段一律放弃标注(行内代码无法嵌套),作者书写的反引号仍由原生引擎呈现
545
- * @param lines - 源码行数组
546
- * @param lineStarts - 行首扁平索引表
547
- * @param mask - 代码上下文掩码
548
- * @param spans - 通过约束校验的语义跨距列表
549
- * @returns 重新以 `\n` 连接的行文本;无片段行原样返回,已标注行仅在语义片段两侧插入哨兵与原生化反引号
550
- */
551
- function annotateNovelSource(
552
- lines: readonly string[],
553
- lineStarts: LineStartIndex,
554
- mask: NovelCodeMask,
555
- spans: readonly NovelSyntaxSpan[],
556
- ): string {
557
- const buckets = new Map<number, NovelLineSegment[]>();
558
-
559
- for (const span of spans) {
560
- const spanStart = (lineStarts[span.startLine] ?? 0) + span.startCol;
561
- const spanEnd = (lineStarts[span.endLine] ?? 0) + span.endCol;
562
- for (let lineIndex = span.startLine; lineIndex <= span.endLine; lineIndex++) {
563
- const lineStart = lineStarts[lineIndex] ?? 0;
564
- const lineEnd = lineStart + (lines[lineIndex]?.length ?? 0);
565
- const from = Math.max(spanStart, lineStart);
566
- const to = Math.min(spanEnd, lineEnd);
567
- if (to <= from) continue;
568
- if (hasCodeCharacter(mask, from, to)) continue;
569
- const segment: NovelLineSegment = { start: from - lineStart, end: to - lineStart, template: span.template };
570
- const bucket = buckets.get(lineIndex);
571
- if (bucket) bucket.push(segment);
572
- else buckets.set(lineIndex, [segment]);
573
- }
574
- }
575
-
576
- return lines
577
- .map((line, lineIndex) => {
578
- const bucket = buckets.get(lineIndex);
579
- if (!bucket) return line;
580
- bucket.sort((a, b) => a.start - b.start);
581
- return annotateNovelLine(line, bucket);
582
- })
583
- .join("\n");
584
- }
585
-
586
- /**
587
- * 小说语法原生化 transform:把源码中的语义跨距标注为携带哨兵的原生行内代码
588
- * 作为 `MarkdownOptions.transform` 传入原生 `Markdown` 组件,在 marked 解析前执行「代码上下文掩码 → 定界符嵌套配对 → 逐行行内标注」三步扫描,折行、缩进与配色由原生引擎回填
589
- *
590
- * 复杂度:掩码与标注均摊 O(n)(n 为源码 UTF-16 码元数,区间标记与分行切片的扫描区间互不重叠);
591
- * 配对扫描为 O(n·d),d 为同时未闭合的开定界符层级深度——闭定界符查找 {@link findClosingDepth} 对每个非开定界符码元自顶向下遍历整个列表,
592
- * 故「大量未闭合 `「` / `(` 之后接续正文」的最坏情形退化为平方级,正常成对文本仍为线性
593
- *
594
- * 不变量:输出源码剥离哨兵与新增反引号后与输入完全等价,且不改变换行与块级结构语义;为此含未配对反引号时整体放弃标注,
595
- * 语义跨距含行内标记字符时放弃该跨距(详见 {@link MARKDOWN_SENSITIVE_PATTERN})
596
- *
597
- * @param source - 面板组装的原始 Markdown 源码
598
- * @returns 标注了语义哨兵的原生 Markdown 源码
599
- */
600
- function markNovelSyntax(source: string): string {
601
- if (source.length === 0) return source;
602
- const lines = source.split("\n");
603
- const lineStarts = indexLineStarts(lines);
604
- const mask = maskCodeRegions(source, lines, lineStarts);
605
- if (mask === null) return source;
606
- const spans = pairNovelSyntax(source, mask, lines, lineStarts);
607
- if (spans.length === 0) return source;
608
- return annotateNovelSource(lines, lineStarts, mask, spans);
609
- }
610
-
611
- /**
612
- * 把面板标题与提示行序列组装为单个 Markdown 区块
613
- * 固定为「{@link TEXTS.view.sectionTitle} 三级标题行 + 空行 + 每条提示独占一行」,每行(含最后一条)保留行尾换行,便于以 `\n` 续接后续区块;`tips` 为空时退化为仅含标题与空行
614
- * @param title - 面板标题文案
615
- * @param tips - 面板提示行文案序列,按数组顺序逐行输出
616
- * @returns 组装后的 Markdown 区块字符串
617
- */
618
- function buildTipsSection(title: string, tips: readonly string[]): string {
619
- return `${TEXTS.view.sectionTitle(title)}\n\n${tips.map((tip) => `${tip}\n`).join("")}`;
620
- }
621
-
622
- /**
623
- * 构建选项详情/正文草稿预览框的完整未切片内容行列表(原生 Markdown 渲染 + 小说语法语义标注)
624
- * @param item - 当前选中的菜单项
625
- * @param customText - 当前填写的题目自由补充说明
626
- * @param optionNote - 当前选项填写的专属补充说明
627
- * @param targetWidth - 预览框渲染总列宽 (cols),含 `Markdown` 组件自身左右各 1 列 `paddingX`(正文可用宽度为其减 2)
628
- * @param theme - TUI 主题实例
629
- * @returns 未切片的完整内容行(每行已补足至 `targetWidth`),供组件层做视口切片;`item` 缺省时返回空数组
630
- */
631
- export function buildFullPreviewContentLines(
632
- item: MenuItem | undefined,
633
- customText: string | undefined,
634
- optionNote: string | undefined,
635
- targetWidth: number,
636
- theme: Theme,
637
- ): string[] {
638
- if (!item) return [];
639
-
640
- let mdSource = "";
641
-
642
- if (item.type === "option") {
643
- // 1. 选项专属作者补充说明(若已填写)
644
- if (optionNote) {
645
- mdSource += `${TEXTS.view.sectionTitle(TEXTS.view.optionNotePanelTitle)}\n\n${TEXTS.view.fieldLabel(TEXTS.view.customNotePanelCurrent)}\n\n${cleanText(optionNote)}\n\n`;
646
- }
647
-
648
- // 2. 情节设定推演说明
649
- if (item.description) {
650
- mdSource += `${TEXTS.view.sectionTitle(TEXTS.view.storyDescriptionPanelTitle)}\n\n${cleanText(item.description)}\n\n`;
651
- }
652
-
653
- // 3. 正文草稿/分镜头预览
654
- if (item.preview) {
655
- mdSource += `${TEXTS.view.sectionTitle(TEXTS.view.storyDraftPanelTitle)}\n\n${cleanText(item.preview)}\n\n`;
656
- } else if (!item.description && !optionNote) {
657
- mdSource += `${TEXTS.view.italicLine(TEXTS.view.previewNoDraft.trim())}\n\n`;
658
- }
659
- } else if (item.type === "custom_note") {
660
- mdSource += buildTipsSection(TEXTS.view.customNotePanelTitle, TEXTS.view.customNotePanelTips);
661
- if (customText) {
662
- mdSource += `\n${TEXTS.view.fieldLabel(TEXTS.view.customNotePanelCurrent)}\n\n${cleanText(customText)}\n\n`;
663
- }
664
- } else {
665
- mdSource += buildTipsSection(TEXTS.view.confirmPanelTitle, TEXTS.view.confirmPanelTips);
666
- }
667
-
668
- const mdTheme = createNovelMarkdownTheme(theme);
669
- // paddingX = 1 提供终端边距;transform 在原生解析前完成语义标注,折行与配色由 Pi Markdown 引擎回填
670
- const md = new Markdown(mdSource.trim(), 1, 0, mdTheme, undefined, { transform: markNovelSyntax });
671
-
672
- return md.render(targetWidth);
673
- }