dsh-zen-remote 1.1.13 → 1.1.14

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.
Files changed (77) hide show
  1. package/README.md +3 -2
  2. package/README.zh-CN.md +3 -2
  3. package/docs/interface.md +46 -3
  4. package/lib/client.js +3421 -103
  5. package/lib/index.js +13 -0
  6. package/lib/index.js.map +1 -1
  7. package/lib/share-export.js +347 -0
  8. package/lib/share-export.js.map +1 -0
  9. package/lib/types/client/MobileAttachButton.d.ts +8 -1
  10. package/lib/types/client/MobileAttachButton.d.ts.map +1 -1
  11. package/lib/types/client/MobileAttachChips.d.ts.map +1 -1
  12. package/lib/types/client/MobileSessionHeader.d.ts +7 -8
  13. package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
  14. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
  15. package/lib/types/client/effects/keyboard-avoid.d.ts.map +1 -1
  16. package/lib/types/client/effects/keyboard-guard.d.ts +16 -11
  17. package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
  18. package/lib/types/client/host-attach.d.ts +38 -0
  19. package/lib/types/client/host-attach.d.ts.map +1 -0
  20. package/lib/types/client/index.d.ts.map +1 -1
  21. package/lib/types/client/locales.d.ts +23 -0
  22. package/lib/types/client/locales.d.ts.map +1 -1
  23. package/lib/types/client/share/fetch-share.d.ts +77 -0
  24. package/lib/types/client/share/fetch-share.d.ts.map +1 -0
  25. package/lib/types/client/share/markdown.d.ts +97 -0
  26. package/lib/types/client/share/markdown.d.ts.map +1 -0
  27. package/lib/types/client/share/png-stitch.d.ts +96 -0
  28. package/lib/types/client/share/png-stitch.d.ts.map +1 -0
  29. package/lib/types/client/share/rasterize.d.ts +317 -0
  30. package/lib/types/client/share/rasterize.d.ts.map +1 -0
  31. package/lib/types/client/share/share-card.d.ts +93 -0
  32. package/lib/types/client/share/share-card.d.ts.map +1 -0
  33. package/lib/types/client/share/share-constants.d.ts +27 -0
  34. package/lib/types/client/share/share-constants.d.ts.map +1 -0
  35. package/lib/types/client/share/share-flow.d.ts +76 -0
  36. package/lib/types/client/share/share-flow.d.ts.map +1 -0
  37. package/lib/types/client/share/share-preview.d.ts +11 -0
  38. package/lib/types/client/share/share-preview.d.ts.map +1 -0
  39. package/lib/types/client/share/share-sheet.d.ts +17 -0
  40. package/lib/types/client/share/share-sheet.d.ts.map +1 -0
  41. package/lib/types/client/share/text-layout.d.ts +48 -0
  42. package/lib/types/client/share/text-layout.d.ts.map +1 -0
  43. package/lib/types/client/styles/header.css.d.ts +1 -1
  44. package/lib/types/client/styles/header.css.d.ts.map +1 -1
  45. package/lib/types/client/styles/layout.css.d.ts +1 -1
  46. package/lib/types/client/styles/layout.css.d.ts.map +1 -1
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/share-export.d.ts +66 -0
  49. package/lib/types/share-export.d.ts.map +1 -0
  50. package/package.json +7 -2
  51. package/scripts/check-host-shadow.mjs +41 -9
  52. package/scripts/check-keyboard-avoid.mjs +4 -2
  53. package/scripts/check-share-image.mjs +970 -0
  54. package/scripts/check-upload-endpoint.mjs +10 -3
  55. package/src/client/MobileAttachButton.tsx +13 -0
  56. package/src/client/MobileAttachChips.tsx +8 -1
  57. package/src/client/MobileSessionHeader.tsx +90 -30
  58. package/src/client/MobileSessionInfo.tsx +234 -1
  59. package/src/client/effects/keyboard-avoid.ts +14 -7
  60. package/src/client/effects/keyboard-guard.ts +28 -18
  61. package/src/client/host-attach.ts +54 -0
  62. package/src/client/index.tsx +16 -0
  63. package/src/client/locales.ts +46 -0
  64. package/src/client/share/fetch-share.ts +161 -0
  65. package/src/client/share/markdown.ts +609 -0
  66. package/src/client/share/png-stitch.ts +281 -0
  67. package/src/client/share/rasterize.ts +1078 -0
  68. package/src/client/share/share-card.tsx +637 -0
  69. package/src/client/share/share-constants.ts +30 -0
  70. package/src/client/share/share-flow.ts +166 -0
  71. package/src/client/share/share-preview.tsx +337 -0
  72. package/src/client/share/share-sheet.tsx +222 -0
  73. package/src/client/share/text-layout.ts +147 -0
  74. package/src/client/styles/header.css.ts +30 -0
  75. package/src/client/styles/layout.css.ts +5 -2
  76. package/src/index.ts +13 -0
  77. package/src/share-export.ts +431 -0
@@ -0,0 +1,970 @@
1
+ // Self-check for the share-image slicing/rasterizing math (ticket 04, PLAN §5.2;
2
+ // ticket 07 adds the single-PNG stitch layer and the unified planning beneath it).
3
+ //
4
+ // The interesting decisions of the share-image export — slice grouping, width
5
+ // lifting for wide content, scale-to-fit for ultra-wide content, the pixel
6
+ // ratio fallback ladder, head-dropping truncation (the tail is kept), and the
7
+ // physical raster geometry of the SVG document — all live in pure functions
8
+ // inside src/client/share/rasterize.ts, so they are asserted numerically here
9
+ // without any DOM. The DOM half of rasterize.ts only measures and executes
10
+ // these decisions; that split is itself load-bearing and pinned at the bottom
11
+ // of this script (rasterize.ts may only import the pure constants module of
12
+ // its directory so type stripping can load it, and the template constants the
13
+ // width formulas invert live in share-constants.ts, imported by both the
14
+ // template and the rasterizer — asserted directly, no source-regex locking).
15
+ //
16
+ // Run: node scripts/check-share-image.mjs (needs Node >= 23.6 type stripping)
17
+ import assert from 'node:assert/strict'
18
+ import { randomBytes } from 'node:crypto'
19
+ import { readFileSync } from 'node:fs'
20
+ import { inflateSync } from 'node:zlib'
21
+ import {
22
+ MAX_SLICES,
23
+ PIXEL_RATIO_STEPS,
24
+ SLICE_PIXEL_BUDGET,
25
+ ShareRasterizeError,
26
+ buildSvgDocument,
27
+ bucketTurnWidth,
28
+ cardWidthForContent,
29
+ cardWidthForUserBubble,
30
+ planShareSlices,
31
+ planShareSlicesUnified,
32
+ rasterGeometry,
33
+ rasterGeometryStitch,
34
+ ratioChain,
35
+ requiredCardWidth,
36
+ slicePixelCount,
37
+ stitchPixelWidth,
38
+ ultraSliceWidth,
39
+ unifiedCardWidth,
40
+ } from '../src/client/share/rasterize.ts'
41
+ import {
42
+ IEND_CHUNK,
43
+ MAX_IDAT_CHUNK_BYTES,
44
+ PngStitchWriter,
45
+ crc32,
46
+ ihdrData,
47
+ pngChunk,
48
+ splitIdatBytes,
49
+ } from '../src/client/share/png-stitch.ts'
50
+ import {
51
+ BASE_CARD_WIDTH,
52
+ USER_BUBBLE_MAX_RATIO,
53
+ USER_BUBBLE_PAD_X,
54
+ WIDE_SLICE_WIDTH,
55
+ } from '../src/client/share/share-constants.ts'
56
+ import {
57
+ SHARE_EXPORT_ROUTE,
58
+ SHARE_TURNS_PRESETS,
59
+ ShareFetchError,
60
+ buildShareExportQuery,
61
+ } from '../src/client/share/fetch-share.ts'
62
+ import {
63
+ FALLBACK_SHARE_BASE_NAME,
64
+ SHARE_NAME_MAX,
65
+ sanitizeShareFileName,
66
+ shareImageFileName,
67
+ shareSliceFileName,
68
+ } from '../src/client/share/share-flow.ts'
69
+ import { parseMarkdown } from '../src/client/share/markdown.ts'
70
+
71
+ // ---- shipped constants (PLAN §5.2) ------------------------------------------
72
+ // The four template constants come from share-constants.ts — the one module
73
+ // both share-card.tsx (renders them) and rasterize.ts (inverts them in its
74
+ // width formulas) import, so these asserts pin the single source of truth
75
+ // that used to be regex-locked against the template's literals.
76
+ assert.equal(BASE_CARD_WIDTH, 390)
77
+ assert.equal(WIDE_SLICE_WIDTH, 780)
78
+ assert.equal(USER_BUBBLE_MAX_RATIO, 0.82)
79
+ assert.equal(USER_BUBBLE_PAD_X, 14)
80
+ assert.equal(SLICE_PIXEL_BUDGET, 12_000_000, '12M budget = iOS 16.7M canvas cap minus safety margin')
81
+ assert.equal(MAX_SLICES, 24)
82
+ assert.deepEqual(PIXEL_RATIO_STEPS, [2, 1.5, 1])
83
+
84
+ // ---- the pixel-ratio ladder --------------------------------------------------
85
+ // min(devicePixelRatio, 2) decides where the descending chain starts; the
86
+ // chain never comes back empty.
87
+ assert.deepEqual(ratioChain(2), [2, 1.5, 1])
88
+ assert.deepEqual(ratioChain(3), [2, 1.5, 1])
89
+ assert.deepEqual(ratioChain(1.5), [1.5, 1])
90
+ assert.deepEqual(ratioChain(1.2), [1])
91
+ assert.deepEqual(ratioChain(1), [1])
92
+ assert.deepEqual(ratioChain(0.5), [1], 'a nonsensical cap still yields a usable ratio')
93
+
94
+ // ---- physical pixel arithmetic ------------------------------------------------
95
+ // Canvas dimensions round up per side, so 7692 logical px at ratio 2 on a
96
+ // 390px card is exactly inside the budget and 7693 is out.
97
+ assert.equal(slicePixelCount(390, 7692, 2), 780 * 15384)
98
+ assert.ok(slicePixelCount(390, 7692, 2) <= SLICE_PIXEL_BUDGET)
99
+ assert.ok(slicePixelCount(390, 7693, 2) > SLICE_PIXEL_BUDGET)
100
+ assert.equal(slicePixelCount(780, 4000, 1), 3_120_000)
101
+ assert.equal(slicePixelCount(390.5, 100, 2), 781 * 200, 'fractional logical sizes still ceil per side')
102
+
103
+ // ---- physical raster geometry (P1: svg intrinsic = canvas size) ---------------
104
+ // The svg/foreignObject attrs must carry the PHYSICAL target (ceil of logical
105
+ // × scale × ratio): logical attrs make WebKit rasterize the SVG-as-image at
106
+ // its small intrinsic size and the later drawImage upscale it (blurry on 2x),
107
+ // and an unbounded ultra renderWidth would decode an unbounded bitmap before
108
+ // any canvas exists.
109
+ {
110
+ const g = rasterGeometry(390, 714, 1, 2, SLICE_PIXEL_BUDGET)
111
+ assert.equal(g.width, 780)
112
+ assert.equal(g.height, 1428)
113
+ assert.equal(g.transformScale, 2)
114
+ }
115
+ {
116
+ // Ultra geometry: composite transform, physical width capped by the wide width.
117
+ const g = rasterGeometry(938, 714, 780 / 938, 2, SLICE_PIXEL_BUDGET)
118
+ assert.equal(g.width, 1560)
119
+ assert.equal(g.height, 1188)
120
+ assert.ok(Math.abs(g.transformScale - (2 * 780) / 938) < 1e-12)
121
+ }
122
+ {
123
+ // A slice whose fresh-measured size busts the budget clamps back inside it
124
+ // (floor per side, so the product provably fits) — the ultra raster-size
125
+ // guarantee, independent of the planner's estimates.
126
+ const g = rasterGeometry(780, 5000, 1, 2, SLICE_PIXEL_BUDGET)
127
+ assert.equal(g.width, 1368)
128
+ assert.equal(g.height, 8770)
129
+ assert.ok(g.width * g.height <= SLICE_PIXEL_BUDGET)
130
+ assert.ok(g.transformScale < 2)
131
+ }
132
+
133
+ // ---- SVG document: physical intrinsic + scaled logical wrapper ----------------
134
+ {
135
+ const svg = buildSvgDocument('<div xmlns="http://www.w3.org/1999/xhtml">hi</div>', {
136
+ renderWidth: 390, logicalHeight: 714, width: 780, height: 1428, transformScale: 2,
137
+ })
138
+ assert.ok(svg.startsWith('<svg xmlns="http://www.w3.org/2000/svg" width="780" height="1428"><foreignObject width="780" height="1428">'), 'svg and foreignObject carry the physical target')
139
+ assert.ok(svg.includes('<div xmlns="http://www.w3.org/1999/xhtml" style="width:390px;height:714px;transform:scale(2);transform-origin:0 0">'), 'the xhtml wrapper carries the logical size and the scale transform')
140
+ assert.ok(svg.includes('>hi<'), 'the body rides inside the wrapper')
141
+ assert.ok(svg.endsWith('</foreignObject></svg>'))
142
+ // XML-invalid control characters never reach the parser.
143
+ const scrubbed = buildSvgDocument('a\u0000b', { renderWidth: 10, logicalHeight: 10, width: 20, height: 20, transformScale: 2 })
144
+ assert.ok(!scrubbed.includes('\u0000'))
145
+ }
146
+
147
+ // ---- width lifting formulas ----------------------------------------------------
148
+ // Assistant rows span the card content box: content + both card paddings.
149
+ assert.equal(cardWidthForContent(900, 18), 938)
150
+ assert.equal(requiredCardWidth('assistant', 900, 18), 938)
151
+ // User rows sit in a bubble capped at 82% of the row plus its own 14px pads —
152
+ // the percentage inverts into a division.
153
+ assert.equal(cardWidthForUserBubble(900, 18), Math.ceil(928 / 0.82) + 38)
154
+ assert.equal(cardWidthForUserBubble(900, 18), 1170)
155
+ assert.equal(requiredCardWidth('user', 900, 18), 1170)
156
+
157
+ // Bucketing rides on the REQUIRED card width (per role), not the raw content
158
+ // width — a user pre needs ~1.22x its width in card to clear the bubble cap.
159
+ assert.equal(bucketTurnWidth('assistant', 352, 18, 390, 780), 'base')
160
+ assert.equal(bucketTurnWidth('assistant', 353, 18, 390, 780), 'wide')
161
+ assert.equal(bucketTurnWidth('assistant', 742, 18, 390, 780), 'wide')
162
+ assert.equal(bucketTurnWidth('assistant', 743, 18, 390, 780), 'ultra')
163
+ assert.equal(bucketTurnWidth('user', 260, 18, 390, 780), 'base')
164
+ assert.equal(bucketTurnWidth('user', 261, 18, 390, 780), 'wide')
165
+ assert.equal(bucketTurnWidth('user', 580, 18, 390, 780), 'wide')
166
+ assert.equal(bucketTurnWidth('user', 581, 18, 390, 780), 'ultra')
167
+ assert.equal(bucketTurnWidth('assistant', 0, 18, 390, 780), 'base', 'no overflow stays base')
168
+
169
+ // Ultra turns render at natural width and shrink geometrically back to the
170
+ // wide width on output.
171
+ {
172
+ const a = ultraSliceWidth('assistant', 900, 18, 780)
173
+ assert.equal(a.renderWidth, 938)
174
+ assert.ok(Math.abs(a.scale - 780 / 938) < 1e-12)
175
+ assert.ok(a.scale < 1)
176
+ const u = ultraSliceWidth('user', 900, 18, 780)
177
+ assert.equal(u.renderWidth, 1170)
178
+ assert.ok(Math.abs(u.scale - 780 / 1170) < 1e-12)
179
+ }
180
+
181
+ // ---- slice planning -------------------------------------------------------------
182
+ // Fixed chrome geometry the planner must reserve for.
183
+ const EXTRAS = { gap: 14, paddingY: 36, headerHeight: 90, footerHeight: 60, noteHeight: 40 }
184
+ const OPTS = { budgetPx: SLICE_PIXEL_BUDGET, maxSlices: MAX_SLICES, ratios: [2, 1.5, 1] }
185
+ const turn = (height, renderWidth = 390, scale = 1) => ({ height, renderWidth, scale })
186
+
187
+ // Empty card: no slices, no truncation, lowest ratio as the nominal one.
188
+ assert.deepEqual(planShareSlices([], EXTRAS, OPTS), { pixelRatio: 1, slices: [], truncated: false, droppedTurns: 0 })
189
+
190
+ // Short card: one slice at the best ratio, header + footer + gaps accounted.
191
+ {
192
+ const plan = planShareSlices([turn(500)], EXTRAS, OPTS)
193
+ assert.equal(plan.pixelRatio, 2)
194
+ assert.equal(plan.slices.length, 1)
195
+ const [s] = plan.slices
196
+ // 36 padding + (90 header + 14 gap) + 500 turn + (14 gap + 60 footer)
197
+ assert.equal(s.height, 714)
198
+ assert.equal(s.outWidth, 390)
199
+ assert.equal(s.outHeight, 714)
200
+ assert.equal(s.lever, 1)
201
+ assert.ok(s.first && s.last && !s.truncated)
202
+ assert.equal(plan.truncated, false)
203
+ assert.equal(plan.droppedTurns, 0)
204
+ }
205
+
206
+ // Two tall turns split at the budget line; header rides slice 0, footer rides
207
+ // the last, and neither slice crosses 12M physical pixels.
208
+ {
209
+ const plan = planShareSlices([turn(5000), turn(5000)], EXTRAS, OPTS)
210
+ assert.equal(plan.pixelRatio, 2)
211
+ assert.equal(plan.slices.length, 2)
212
+ const [a, b] = plan.slices
213
+ assert.equal(`${a.from}-${a.to}`, '0-0')
214
+ assert.equal(`${b.from}-${b.to}`, '1-1')
215
+ assert.equal(a.height, 36 + 90 + 14 + 5000) // 5140
216
+ assert.equal(b.height, 36 + 5000 + 14 + 60) // 5110
217
+ for (const s of plan.slices) {
218
+ assert.ok(slicePixelCount(s.outWidth, s.outHeight, plan.pixelRatio) <= SLICE_PIXEL_BUDGET)
219
+ }
220
+ }
221
+
222
+ // Degradation ladder. 30 turns of 7600px: at ratio 2 one slice already busts
223
+ // the budget with the header attached, at 1.5 still one-per-slice (30 > 24),
224
+ // at ratio 1 four turns fit per slice -> 8 slices, no truncation.
225
+ {
226
+ const plan = planShareSlices(Array.from({ length: 30 }, () => turn(7600)), EXTRAS, OPTS)
227
+ assert.equal(plan.pixelRatio, 1)
228
+ assert.equal(plan.slices.length, 8)
229
+ assert.equal(plan.truncated, false)
230
+ assert.equal(plan.droppedTurns, 0)
231
+ assert.deepEqual(plan.slices.map((s) => `${s.from}-${s.to}`), [
232
+ '0-3', '4-7', '8-11', '12-15', '16-19', '20-23', '24-27', '28-29',
233
+ ])
234
+ assert.ok(slicePixelCount(plan.slices[0].outWidth, plan.slices[0].outHeight, 1) <= SLICE_PIXEL_BUDGET)
235
+ }
236
+
237
+ // The ladder stops at the FIRST ratio that works: 30 turns of 6500px pair up
238
+ // at 1.5 (15 slices) before ratio 1 is ever needed.
239
+ {
240
+ const plan = planShareSlices(Array.from({ length: 30 }, () => turn(6500)), EXTRAS, OPTS)
241
+ assert.equal(plan.pixelRatio, 1.5)
242
+ assert.equal(plan.slices.length, 15)
243
+ assert.ok(plan.slices.every((s) => s.lever === 1))
244
+ }
245
+
246
+ // Truncation keeps the TAIL: 120 tall turns still need 30 slices at ratio 1
247
+ // -> keep the LAST 24, drop the head, mark the FIRST slice — with range=last
248
+ // the user explicitly named the newest turns, which head-keeping would
249
+ // discard exactly.
250
+ {
251
+ const plan = planShareSlices(Array.from({ length: 120 }, () => turn(7600)), EXTRAS, OPTS)
252
+ assert.equal(plan.pixelRatio, 1)
253
+ assert.equal(plan.slices.length, MAX_SLICES)
254
+ assert.equal(plan.truncated, true)
255
+ assert.equal(plan.droppedTurns, 24)
256
+ assert.equal(plan.slices[0].from, 24, 'the first kept slice starts right after the dropped head')
257
+ assert.equal(plan.slices.at(-1).to, 119, 'the tail runs to the end of the conversation')
258
+ assert.equal(plan.slices[0].truncated, true, 'the marker rides the first kept slice')
259
+ assert.equal(plan.slices[1].truncated, false)
260
+ assert.equal(plan.slices.at(-1).truncated, false)
261
+ // First slice: header + note + 4 turns; last slice: footer + the final turns.
262
+ const first = plan.slices[0]
263
+ assert.equal(first.height, 36 + (90 + 14) + (40 + 14) + 4 * 7600 + 3 * 14)
264
+ const last = plan.slices.at(-1)
265
+ assert.equal(last.height, 36 + 4 * 7600 + 3 * 14 + (14 + 60))
266
+ assert.ok(slicePixelCount(first.outWidth, first.outHeight, 1) <= SLICE_PIXEL_BUDGET)
267
+ assert.ok(slicePixelCount(last.outWidth, last.outHeight, 1) <= SLICE_PIXEL_BUDGET)
268
+ }
269
+
270
+ // Wide-turn isolation: a 780px turn between 390px turns becomes its own slice
271
+ // (a slice never mixes widths), with exact per-slice heights.
272
+ {
273
+ const plan = planShareSlices([turn(300), turn(250, 780), turn(300)], EXTRAS, OPTS)
274
+ assert.equal(plan.pixelRatio, 2)
275
+ assert.deepEqual(plan.slices.map((s) => s.renderWidth), [390, 780, 390])
276
+ assert.deepEqual(plan.slices.map((s) => s.height), [36 + 104 + 300, 36 + 250, 36 + 74 + 300])
277
+ assert.deepEqual(plan.slices.map((s) => s.first), [true, false, false])
278
+ assert.deepEqual(plan.slices.map((s) => s.last), [false, false, true])
279
+ }
280
+
281
+ // Ultra turn: renders at natural width, shrinks back to ~780 logical on
282
+ // output, no extra lever needed.
283
+ {
284
+ const plan = planShareSlices([turn(400, 938, 780 / 938)], EXTRAS, OPTS)
285
+ assert.equal(plan.pixelRatio, 2)
286
+ const [s] = plan.slices
287
+ assert.equal(s.renderWidth, 938)
288
+ assert.ok(Math.abs(s.outWidth - 780) < 0.01)
289
+ assert.ok(s.scale < 1 && s.scale > 0.8)
290
+ assert.equal(s.lever, 1)
291
+ }
292
+
293
+ // An un-splittable turn taller than the budget at EVERY ratio (even ratio 1)
294
+ // keeps completeness over sharpness: one slice, uniformly shrunk (the lever),
295
+ // and the ceiling rounding of the canvas is accounted for — the final physical
296
+ // count is genuinely within budget.
297
+ {
298
+ const plan = planShareSlices([turn(40000)], EXTRAS, OPTS)
299
+ assert.equal(plan.pixelRatio, 1)
300
+ assert.equal(plan.slices.length, 1)
301
+ assert.equal(plan.truncated, false)
302
+ const [s] = plan.slices
303
+ assert.ok(s.lever < 1)
304
+ assert.ok(s.scale < 0.88)
305
+ assert.ok(slicePixelCount(s.outWidth, s.outHeight, 1) <= SLICE_PIXEL_BUDGET, 'post-lever pixel count fits')
306
+ }
307
+
308
+ // Slices are contiguous and ordered, covering [droppedTurns, n-1] — the tail
309
+ // always runs to the very end of the conversation.
310
+ {
311
+ const inputs = Array.from({ length: 120 }, () => turn(7600))
312
+ const plan = planShareSlices(inputs, EXTRAS, OPTS)
313
+ let expected = plan.droppedTurns
314
+ for (const s of plan.slices) {
315
+ assert.equal(s.from, expected)
316
+ assert.ok(s.to >= s.from)
317
+ expected = s.to + 1
318
+ }
319
+ assert.equal(expected, inputs.length)
320
+ }
321
+
322
+ // 780-wide slices ride the ratio ladder too: turns of 5000px at width 780
323
+ // bust the budget at ratio 2 (1560 × 10072 > 12M) but fit at 1.5 — width
324
+ // lifting and ratio degradation compounded in one plan.
325
+ {
326
+ const plan = planShareSlices(Array.from({ length: 6 }, () => turn(5000, 780)), EXTRAS, OPTS)
327
+ assert.equal(plan.pixelRatio, 1.5)
328
+ assert.equal(plan.slices.length, 6)
329
+ assert.ok(plan.slices.every((s) => s.renderWidth === 780 && s.lever === 1))
330
+ assert.equal(plan.slices[0].height, 36 + 90 + 14 + 5000)
331
+ assert.equal(plan.slices.at(-1).height, 36 + 5000 + 14 + 60)
332
+ for (const s of plan.slices) {
333
+ assert.ok(slicePixelCount(s.outWidth, s.outHeight, plan.pixelRatio) <= SLICE_PIXEL_BUDGET)
334
+ }
335
+ }
336
+
337
+ // Ultra scale × budget lever compound: a turn whose ultra output still busts
338
+ // the budget at EVERY ratio gets the lever stacked on top of the geometric
339
+ // downscale (completeness kept; pinch-zoom restores size).
340
+ {
341
+ const plan = planShareSlices([turn(45000, 2000, 0.39)], EXTRAS, OPTS)
342
+ assert.equal(plan.pixelRatio, 1)
343
+ assert.equal(plan.slices.length, 1)
344
+ assert.equal(plan.truncated, false)
345
+ const [s] = plan.slices
346
+ assert.ok(s.lever < 1, 'the budget lever stacks onto the ultra scale')
347
+ assert.ok(s.scale < 0.39)
348
+ assert.ok(s.outWidth < 780)
349
+ assert.ok(slicePixelCount(s.outWidth, s.outHeight, 1) <= SLICE_PIXEL_BUDGET)
350
+ }
351
+
352
+ // ---- error surface -----------------------------------------------------------
353
+ {
354
+ const err = new ShareRasterizeError('probe-failed', 'blank')
355
+ assert.ok(err instanceof Error)
356
+ assert.equal(err.code, 'probe-failed')
357
+ assert.equal(err.detail, 'blank')
358
+ assert.match(err.message, /probe-failed/)
359
+ }
360
+
361
+ // ---- ticket 05: share fetch query builder + error taxonomy -------------------
362
+ assert.equal(SHARE_EXPORT_ROUTE, '/_dsh/mobile-nav/share-export')
363
+ assert.deepEqual(SHARE_TURNS_PRESETS, [3, 5, 10], 'PLAN §6: presets only, no free input')
364
+ assert.equal(
365
+ buildShareExportQuery('session-0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0', { kind: 'all' }),
366
+ '/_dsh/mobile-nav/share-export?session=session-0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0&range=all',
367
+ )
368
+ assert.equal(
369
+ buildShareExportQuery('session-abc', { kind: 'last', turns: 5 }),
370
+ '/_dsh/mobile-nav/share-export?session=session-abc&range=last&turns=5',
371
+ )
372
+ // The session id is URL-encoded: a stray '/' or '?' must not smuggle params.
373
+ assert.equal(
374
+ buildShareExportQuery('session-a/b?c=d', { kind: 'all' }),
375
+ `/_dsh/mobile-nav/share-export?session=${encodeURIComponent('session-a/b?c=d')}&range=all`,
376
+ )
377
+ // range=all never carries turns — the host 400s that pair (rejectStrayTurns).
378
+ assert.ok(!buildShareExportQuery('session-x', { kind: 'all' }).includes('turns='))
379
+ // The route constant must stay glued to the host module it mirrors.
380
+ const hostRouteSrc = readFileSync(new URL('../src/share-export.ts', import.meta.url), 'utf8')
381
+ assert.match(hostRouteSrc, new RegExp(`'${SHARE_EXPORT_ROUTE.replace(/\//g, '\\/')}'`), 'client and host must agree on the share-export route')
382
+ {
383
+ const err = new ShareFetchError('route-missing', 404, 'no envelope')
384
+ assert.ok(err instanceof Error)
385
+ assert.equal(err.code, 'route-missing')
386
+ assert.equal(err.status, 404)
387
+ assert.match(err.message, /route-missing/)
388
+ const net = new ShareFetchError('network', undefined, 'offline')
389
+ assert.equal(net.status, undefined)
390
+ assert.ok(net instanceof ShareFetchError)
391
+ }
392
+
393
+ // ---- ticket 05: portable filename sanitizer ----------------------------------
394
+ assert.equal(sanitizeShareFileName('网关错误率报表'), '网关错误率报表')
395
+ assert.equal(sanitizeShareFileName('a/b\\c|d'), 'a b c|d', 'path separators fold into spaces; other punctuation stays')
396
+ assert.equal(sanitizeShareFileName('two\n\n spaces\tand tab'), 'two spaces and tab')
397
+ assert.equal(sanitizeShareFileName('\u0000control\u001F\u007Fchars'), 'controlchars', 'C0 controls and DEL are stripped')
398
+ // Bidi format characters: zero-width, and they visually reorder the name —
399
+ // `photo<LRM|RLM|LRE|RLE|PDF|LRO>…` must not survive into a filename.
400
+ assert.equal(sanitizeShareFileName('photo\u202Egpj.png'), 'photogpj.png', 'an RLO (right-to-left override) extension spoof is stripped')
401
+ assert.equal(sanitizeShareFileName('a\u200Eb\u200Fc\u202Ad\u202Be\u202Cf\u202Dg'), 'abcdefg', 'LRM/RLM/LRE/RLE/PDF/LRO are stripped too')
402
+ assert.equal(sanitizeShareFileName(' '), FALLBACK_SHARE_BASE_NAME)
403
+ assert.equal(sanitizeShareFileName('///\\\\'), FALLBACK_SHARE_BASE_NAME, 'separator-only titles fall back')
404
+ assert.equal(sanitizeShareFileName(' leading and trailing '), 'leading and trailing')
405
+ // The cap counts CODE POINTS (a UTF-16 slice could split a surrogate pair).
406
+ {
407
+ const emoji = '😀'.repeat(SHARE_NAME_MAX + 5)
408
+ const capped = sanitizeShareFileName(emoji)
409
+ assert.equal(Array.from(capped).length, SHARE_NAME_MAX)
410
+ assert.ok(!capped.includes('\uFFFD'), 'no lone surrogate may survive the cap')
411
+ const cjk = '汉'.repeat(SHARE_NAME_MAX + 10)
412
+ assert.equal(Array.from(sanitizeShareFileName(cjk)).length, SHARE_NAME_MAX)
413
+ }
414
+ assert.equal(shareSliceFileName('share-card', 0), 'share-card-01.png')
415
+ assert.equal(shareSliceFileName('报表', 11), '报表-12.png')
416
+ // Ticket 07: the stitched deliverable is ONE file — plain `<base>.png`, no slice suffix.
417
+ assert.equal(shareImageFileName('报表'), '报表.png')
418
+ assert.equal(shareImageFileName('share-card'), 'share-card.png')
419
+ assert.ok(shareImageFileName(FALLBACK_SHARE_BASE_NAME).endsWith('.png'))
420
+
421
+ // ---- ticket 07: PNG byte layer — CRC32 + chunk framing -------------------------
422
+ // Read a big-endian u32 out of a byte view (length fields and CRCs are both BE).
423
+ const be32 = (bytes, off) => ((bytes[off] << 24) | (bytes[off + 1] << 16) | (bytes[off + 2] << 8) | bytes[off + 3]) >>> 0
424
+ const ENC = new TextEncoder()
425
+ assert.equal(crc32(new Uint8Array(0)), 0)
426
+ assert.equal(crc32(ENC.encode('123456789')), 0xcbf43926, 'the canonical CRC-32 check value')
427
+ assert.equal(crc32(ENC.encode('IEND')), 0xae426082, 'the IEND type CRC — a fixed constant of the PNG ecosystem')
428
+ {
429
+ const data = ENC.encode('hello world')
430
+ const chunk = pngChunk('IDAT', data)
431
+ assert.equal(chunk.length, 12 + data.length)
432
+ assert.equal(be32(chunk, 0), data.length, 'data length, big-endian')
433
+ assert.equal(String.fromCharCode(...chunk.slice(4, 8)), 'IDAT')
434
+ const crcInput = new Uint8Array(4 + data.length)
435
+ crcInput.set(chunk.slice(4, 8))
436
+ crcInput.set(data, 4)
437
+ assert.equal(be32(chunk, 8 + data.length), crc32(crcInput), 'CRC covers type+data, never the length field')
438
+ // Multi-byte lengths really are big-endian, not little.
439
+ const wide = pngChunk('odAT', new Uint8Array(0x1234))
440
+ assert.equal(be32(wide, 0), 0x1234)
441
+ assert.equal(wide[2], 0x12, 'high byte first')
442
+ assert.equal(wide[3], 0x34)
443
+ assert.throws(() => pngChunk('TOOLONG', data), /4 ASCII letters/)
444
+ }
445
+ {
446
+ const d = ihdrData(1560, 12345)
447
+ assert.equal(d.length, 13)
448
+ assert.equal(be32(d, 0), 1560)
449
+ assert.equal(be32(d, 4), 12345)
450
+ assert.deepEqual([...d.slice(8)], [8, 6, 0, 0, 0], '8-bit RGBA (color type 6), deflate, adaptive filter, no interlace')
451
+ }
452
+ {
453
+ assert.equal(IEND_CHUNK.length, 12)
454
+ assert.equal(be32(IEND_CHUNK, 0), 0)
455
+ assert.equal(String.fromCharCode(...IEND_CHUNK.slice(4, 8)), 'IEND')
456
+ assert.equal(be32(IEND_CHUNK, 8), 0xae426082)
457
+ }
458
+ {
459
+ const bytes = ENC.encode('x'.repeat(10))
460
+ assert.deepEqual(splitIdatBytes(bytes, 4).map((p) => p.length), [4, 4, 2], 'IDAT payloads split at the ceiling')
461
+ assert.equal(Buffer.concat(splitIdatBytes(bytes, 4).map(Buffer.from)).toString(), 'x'.repeat(10), 'concatenation is the identity')
462
+ assert.deepEqual(splitIdatBytes(new Uint8Array(0), 4).map((p) => p.length), [0], 'empty input still yields one (empty) payload')
463
+ assert.throws(() => splitIdatBytes(bytes, 0), /invalid IDAT chunk size/)
464
+ }
465
+
466
+ // ---- ticket 07: writer emits a spec-shaped PNG (node:zlib round-trip) ------------
467
+ // Node 22 has CompressionStream + Blob as globals, so the writer runs for real
468
+ // here: every chunk CRC is re-verified and the IDAT stream is inflated back to
469
+ // the exact scanline bytes (filter 0 + the rows that were fed).
470
+ {
471
+ const W = 3
472
+ const H = 5
473
+ const rows = Array.from({ length: H }, (_, y) => Uint8Array.from({ length: W * 4 }, (_, i) => (y * W * 4 + i) % 251))
474
+ const writer = new PngStitchWriter(W, H)
475
+ for (const row of rows) await writer.writeRow(row)
476
+ const png = new Uint8Array(await (await writer.finish()).arrayBuffer())
477
+ assert.deepEqual([...png.slice(0, 8)], [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 'PNG signature')
478
+ const chunks = []
479
+ for (let off = 8; off < png.length;) {
480
+ const len = be32(png, off)
481
+ const type = String.fromCharCode(...png.slice(off + 4, off + 8))
482
+ const crcInput = new Uint8Array(4 + len)
483
+ crcInput.set(png.slice(off + 4, off + 8))
484
+ crcInput.set(png.slice(off + 8, off + 8 + len), 4)
485
+ assert.equal(be32(png, off + 8 + len), crc32(crcInput), `${type} chunk CRC`)
486
+ chunks.push({ type, data: png.slice(off + 8, off + 8 + len) })
487
+ off += 12 + len
488
+ }
489
+ assert.deepEqual(chunks.map((c) => c.type), ['IHDR', 'IDAT', 'IEND'], 'small image = exactly one IDAT; IHDR first, IEND last')
490
+ assert.equal(be32(chunks[0].data, 0), W)
491
+ assert.equal(be32(chunks[0].data, 4), H, 'IHDR height = the row count fed (Σ slice heights)')
492
+ const raw = inflateSync(Buffer.from(chunks[1].data))
493
+ assert.equal(raw.length, H * (1 + W * 4))
494
+ for (let y = 0; y < H; y++) {
495
+ assert.equal(raw[y * (1 + W * 4)], 0, 'scanline filter byte 0 (None)')
496
+ assert.deepEqual([...raw.subarray(y * (1 + W * 4) + 1, (y + 1) * (1 + W * 4))], [...rows[y]])
497
+ }
498
+ // Row/height accounting guards: IHDR height must equal the rows actually fed.
499
+ const underfed = new PngStitchWriter(W, 2)
500
+ await underfed.writeRow(rows[0])
501
+ await assert.rejects(() => underfed.finish(), /declared 2 rows but 1/)
502
+ const badWidth = new PngStitchWriter(W, 1)
503
+ await assert.rejects(() => badWidth.writeRow(new Uint8Array(3)), /expected 12/)
504
+ const overfed = new PngStitchWriter(W, 1)
505
+ await overfed.writeRow(rows[0])
506
+ await assert.rejects(() => overfed.writeRow(rows[1]), /exceeds the declared height/)
507
+ }
508
+
509
+ // ---- ticket 07: multi-IDAT end-to-end — one zlib stream, many chunks (review 07+08) ----
510
+ // The small round-trip above always emits exactly ONE IDAT, so the split path
511
+ // was only pinned at the splitIdatBytes unit level. This block fixes the whole
512
+ // pipeline end to end: a big image of crypto-random rows (incompressible —
513
+ // the deflate stream alone crosses MAX_IDAT_CHUNK_BYTES, like a real
514
+ // long-transcript stitch) must emit ≥2 IDAT chunks, every chunk's CRC must
515
+ // verify, and — the property every real decoder relies on — the CONCATENATED
516
+ // payloads must inflate back to the exact scanline bytes: multiple IDATs are
517
+ // one zlib stream in order, never per-chunk streams.
518
+ {
519
+ const W = 97
520
+ const H = 8192 // H × (1 + W×4) ≈ 3.19 MB of noise → ≥ 3 compressed MiB → ≥ 4 chunks
521
+ const rowBytes = W * 4
522
+ const rows = Array.from({ length: H }, () => randomBytes(rowBytes))
523
+ const writer = new PngStitchWriter(W, H)
524
+ for (const row of rows) await writer.writeRow(row)
525
+ const png = new Uint8Array(await (await writer.finish()).arrayBuffer())
526
+ const idats = []
527
+ for (let off = 8; off < png.length;) {
528
+ const len = be32(png, off)
529
+ const type = String.fromCharCode(...png.slice(off + 4, off + 8))
530
+ assert.equal(be32(png, off + 8 + len), crc32(png.slice(off + 4, off + 8 + len)), `${type} chunk CRC (type+data, not the length field)`)
531
+ if (type === 'IDAT') idats.push(png.slice(off + 8, off + 8 + len))
532
+ off += 12 + len
533
+ }
534
+ assert.ok(idats.length >= 2, `a ~3 MiB random stream must split into multiple IDATs, got ${idats.length}`)
535
+ for (const [index, payload] of idats.entries()) {
536
+ assert.ok(payload.length <= MAX_IDAT_CHUNK_BYTES, `IDAT ${index} respects the split ceiling`)
537
+ assert.ok(payload.length > 0, `IDAT ${index} is never empty (an empty tail would shift the stream)`)
538
+ }
539
+ const raw = inflateSync(Buffer.concat(idats.map(Buffer.from)))
540
+ assert.equal(raw.length, H * (1 + rowBytes), 'the concatenated IDAT payloads inflate to exactly the scanline byte count')
541
+ const expected = Buffer.alloc(H * (1 + rowBytes))
542
+ for (let y = 0; y < H; y++) {
543
+ expected.writeUint8(0, y * (1 + rowBytes)) // filter byte 0 (None)
544
+ rows[y].copy(expected, y * (1 + rowBytes) + 1)
545
+ }
546
+ assert.ok(raw.equals(expected), 'every byte round-trips: filter 0 + the exact rows fed, in order')
547
+ }
548
+
549
+ // ---- ticket 07: unified planning — one global width, ratio and lever -------------
550
+ assert.equal(unifiedCardWidth(false), BASE_CARD_WIDTH)
551
+ assert.equal(unifiedCardWidth(true), WIDE_SLICE_WIDTH)
552
+ // Short card measured at the global width: one slice at the best ratio.
553
+ {
554
+ const plan = planShareSlicesUnified([turn(500, 780)], EXTRAS, OPTS)
555
+ assert.equal(plan.pixelRatio, 2)
556
+ assert.equal(plan.slices.length, 1)
557
+ assert.equal(plan.slices[0].outWidth, 780)
558
+ assert.equal(stitchPixelWidth(plan), 1560)
559
+ }
560
+ // Degradation ladder + tail-keeping truncation keep their semantics at the
561
+ // global width (same inputs the DOM half would produce for a 390 card).
562
+ {
563
+ const plan = planShareSlicesUnified(Array.from({ length: 120 }, () => turn(7600)), EXTRAS, OPTS)
564
+ assert.equal(plan.pixelRatio, 1)
565
+ assert.equal(plan.slices.length, MAX_SLICES)
566
+ assert.equal(plan.truncated, true)
567
+ assert.ok(plan.droppedTurns > 0)
568
+ assert.equal(plan.slices[0].from, plan.droppedTurns, 'the first kept slice starts right after the dropped head')
569
+ assert.equal(plan.slices.at(-1).to, 119, 'the tail runs to the end of the conversation')
570
+ assert.equal(plan.slices[0].truncated, true)
571
+ }
572
+ // GLOBAL lever: one un-splittable giant turn shrinks EVERY slice by the same
573
+ // multiplier — uniform output width is the stitch invariant. The legacy planner
574
+ // shrinks only the offending slice, which is exactly the per-slice width
575
+ // divergence stitching cannot tolerate.
576
+ {
577
+ const plan = planShareSlicesUnified([turn(40000), turn(500)], EXTRAS, OPTS)
578
+ assert.equal(plan.pixelRatio, 1)
579
+ assert.equal(plan.slices.length, 2)
580
+ const [giant, normal] = plan.slices
581
+ assert.ok(giant.lever < 1)
582
+ assert.equal(normal.lever, giant.lever, 'the lever is one global multiplier')
583
+ assert.ok(Math.abs(normal.outWidth - giant.outWidth) < 1e-9, 'every slice lands on the same output width')
584
+ assert.ok(slicePixelCount(giant.outWidth, giant.outHeight, 1) <= SLICE_PIXEL_BUDGET)
585
+ const legacy = planShareSlices([turn(40000), turn(500)], EXTRAS, OPTS)
586
+ assert.equal(legacy.slices[1].lever, 1, 'legacy keeps well-behaved slices native — the contrast the unified planner removes')
587
+ }
588
+ // stitchPixelWidth is THE authoritative width: each slice's own ceil'd arithmetic agrees.
589
+ {
590
+ const plan = planShareSlicesUnified([turn(5000, 780), turn(5000, 780)], EXTRAS, OPTS)
591
+ const width = stitchPixelWidth(plan)
592
+ assert.equal(width, 1170, '5000px turns at 780 fit the budget at ratio 1.5 first')
593
+ for (const s of plan.slices) assert.equal(Math.ceil(s.outWidth * plan.pixelRatio), width)
594
+ assert.equal(stitchPixelWidth({ ...plan, slices: [] }), 0, 'empty plans carry no width')
595
+ }
596
+ // An ultra turn inside a unified plan lands on the same output width by
597
+ // construction (renderWidth × W/renderWidth) — no isolation needed for width.
598
+ {
599
+ const plan = planShareSlicesUnified([turn(400, 938, 780 / 938), turn(500, 780)], EXTRAS, OPTS)
600
+ assert.equal(plan.pixelRatio, 2)
601
+ assert.equal(stitchPixelWidth(plan), 1560)
602
+ for (const s of plan.slices) assert.ok(Math.abs(s.outWidth - 780) < 1e-9)
603
+ }
604
+ // rasterGeometryStitch: the width is pinned to the global one; heights ceil;
605
+ // the budget clamp floors the height and NEVER moves the width.
606
+ {
607
+ const geo = rasterGeometryStitch(780, 714, 1560, SLICE_PIXEL_BUDGET)
608
+ assert.equal(geo.width, 1560)
609
+ assert.equal(geo.height, 1428)
610
+ assert.ok(Math.abs(geo.transformScale - 2) < 1e-12)
611
+ assert.equal(geo.clamped, false)
612
+ }
613
+ {
614
+ const geo = rasterGeometryStitch(780, 7693, 1560, SLICE_PIXEL_BUDGET)
615
+ assert.equal(geo.width, 1560, 'the stitch width is untouchable')
616
+ assert.equal(geo.height, Math.floor(SLICE_PIXEL_BUDGET / 1560))
617
+ assert.ok(geo.width * geo.height <= SLICE_PIXEL_BUDGET)
618
+ assert.equal(geo.clamped, true)
619
+ const ultra = rasterGeometryStitch(938, 714, 1560, SLICE_PIXEL_BUDGET)
620
+ assert.equal(ultra.width, 1560)
621
+ assert.ok(Math.abs(ultra.transformScale - 1560 / 938) < 1e-12, 'the transform folds ultra fit, lever and ratio into globalWidth/renderWidth')
622
+ assert.equal(ultra.height, Math.ceil(714 * (1560 / 938)))
623
+ assert.throws(() => rasterGeometryStitch(780, 714, 0, SLICE_PIXEL_BUDGET), /invalid stitch width/)
624
+ }
625
+
626
+ // ---- ticket 09: markdown parser — leniency beats strictness ---------------------
627
+ // The assistant text renderer feeds off parseMarkdown; every construct below
628
+ // pins its block/span shape, and the fallback rule is the point of the design:
629
+ // an unclosed/implausible marker must come back as PLAIN TEXT, never as a
630
+ // half-parsed span that leaks asterisks into the exported image.
631
+ {
632
+ // Empty and blank-only input yields no blocks (renderer renders nothing).
633
+ assert.deepEqual(parseMarkdown(''), [])
634
+ assert.deepEqual(parseMarkdown('\n \n\t\n'), [])
635
+
636
+ // Headings: six levels, inline content parsed, text trimmed; 7 hashes and a
637
+ // hash without a space are paragraphs.
638
+ assert.deepEqual(parseMarkdown('# 标题'), [{ kind: 'heading', level: 1, spans: [{ kind: 'text', text: '标题' }] }])
639
+ assert.deepEqual(parseMarkdown('###### 六级 **粗**'), [{
640
+ kind: 'heading',
641
+ level: 6,
642
+ spans: [{ kind: 'text', text: '六级 ' }, { kind: 'bold', spans: [{ kind: 'text', text: '粗' }] }],
643
+ }])
644
+ assert.deepEqual(parseMarkdown('####### 七个'), [{ kind: 'para', spans: [{ kind: 'text', text: '####### 七个' }] }])
645
+ assert.deepEqual(parseMarkdown('#nospace'), [{ kind: 'para', spans: [{ kind: 'text', text: '#nospace' }] }])
646
+
647
+ // Paragraphs keep soft line breaks inside one text span (pre-wrap renders them).
648
+ assert.deepEqual(parseMarkdown('第一行\n第二行'), [{ kind: 'para', spans: [{ kind: 'text', text: '第一行\n第二行' }] }])
649
+
650
+ // Fenced code: language tag recorded, tilde fences too, unterminated fences
651
+ // still yield their body, empty fences yield an empty block.
652
+ assert.deepEqual(parseMarkdown('```js\nconst x = 1\n```'), [{ kind: 'code', lang: 'js', lines: ['const x = 1'] }])
653
+ assert.deepEqual(parseMarkdown('~~~\nplain\nbody\n~~~'), [{ kind: 'code', lang: undefined, lines: ['plain', 'body'] }])
654
+ assert.deepEqual(parseMarkdown('```ts\nunterminated'), [{ kind: 'code', lang: 'ts', lines: ['unterminated'] }])
655
+ assert.deepEqual(parseMarkdown('```\n```'), [{ kind: 'code', lang: undefined, lines: [] }])
656
+
657
+ // Indented code (4 spaces / tab): one indent unit stripped, blank lines
658
+ // between indented lines stay inside, and a paragraph is never interrupted.
659
+ assert.deepEqual(parseMarkdown(' indented\n more'), [{ kind: 'code', lang: undefined, lines: ['indented', 'more'] }])
660
+ assert.deepEqual(parseMarkdown(' a\n\n b'), [{ kind: 'code', lang: undefined, lines: ['a', '', 'b'] }])
661
+ assert.deepEqual(parseMarkdown('正文\n 缩进'), [{ kind: 'para', spans: [{ kind: 'text', text: '正文\n 缩进' }] }])
662
+
663
+ // Blockquotes: consecutive > lines, prefix stripped, body block-parsed.
664
+ assert.deepEqual(parseMarkdown('> 引用\n> 第二行'), [{
665
+ kind: 'quote',
666
+ children: [{ kind: 'para', spans: [{ kind: 'text', text: '引用\n第二行' }] }],
667
+ }])
668
+ assert.deepEqual(parseMarkdown('> # 标\n> - 项'), [{
669
+ kind: 'quote',
670
+ children: [
671
+ { kind: 'heading', level: 1, spans: [{ kind: 'text', text: '标' }] },
672
+ { kind: 'list', ordered: false, items: [{ spans: [{ kind: 'text', text: '项' }], number: undefined, children: [] }] },
673
+ ],
674
+ }])
675
+
676
+ // Lists: mixed bullet markers stay one list; ordered numbers are the ones
677
+ // written; two indent levels (2-space multiples); deeper indents cap onto
678
+ // level two; indented non-marker lines lazily continue the deepest item.
679
+ const li = (text, { number = undefined, children = [] } = {}) => ({ spans: [{ kind: 'text', text }], number, children })
680
+ assert.deepEqual(parseMarkdown('- 甲\n- 乙\n+ 丙'), [{ kind: 'list', ordered: false, items: [li('甲'), li('乙'), li('丙')] }])
681
+ assert.deepEqual(parseMarkdown('1. 甲\n2. 乙'), [{ kind: 'list', ordered: true, items: [li('甲', { number: 1 }), li('乙', { number: 2 })] }])
682
+ assert.deepEqual(parseMarkdown('- 甲\n - 甲一\n - 甲二\n- 乙'), [{
683
+ kind: 'list',
684
+ ordered: false,
685
+ items: [
686
+ li('甲', { children: [li('甲一'), li('甲二')] }),
687
+ li('乙'),
688
+ ],
689
+ }])
690
+ assert.deepEqual(parseMarkdown('- 甲\n 续行'), [{ kind: 'list', ordered: false, items: [li('甲\n续行')] }])
691
+ // Loose list: a blank line between same-kind items keeps ONE list; a
692
+ // different marker kind after the blank starts a new one.
693
+ assert.deepEqual(parseMarkdown('- 甲\n\n- 乙'), [{ kind: 'list', ordered: false, items: [li('甲'), li('乙')] }])
694
+ {
695
+ const blocks = parseMarkdown('- 甲\n\n1. 乙')
696
+ assert.equal(blocks.length, 2)
697
+ assert.deepEqual(blocks.map((b) => b.kind), ['list', 'list'])
698
+ }
699
+ // The marker's trailing space is load-bearing: "3.14" is prose.
700
+ assert.deepEqual(parseMarkdown('3.14 是圆周率'), [{ kind: 'para', spans: [{ kind: 'text', text: '3.14 是圆周率' }] }])
701
+
702
+ // Tables: alignment row parsed per column, cells inline-parsed, short rows
703
+ // pad to the header width, extra cells drop, and a pipe line WITHOUT a
704
+ // delimiter row stays a paragraph.
705
+ {
706
+ const [table] = parseMarkdown('| A | B | C |\n| :- | :-: | --: |\n| a | b |\n| a | b | c | d |')
707
+ assert.equal(table.kind, 'table')
708
+ assert.deepEqual(table.align, ['left', 'center', 'right'])
709
+ assert.deepEqual(table.header, [
710
+ [{ kind: 'text', text: 'A' }],
711
+ [{ kind: 'text', text: 'B' }],
712
+ [{ kind: 'text', text: 'C' }],
713
+ ])
714
+ assert.deepEqual(table.rows[0], [[{ kind: 'text', text: 'a' }], [{ kind: 'text', text: 'b' }], []])
715
+ assert.deepEqual(table.rows[1].map((cell) => cell.length), [1, 1, 1])
716
+ }
717
+ {
718
+ const [table] = parseMarkdown('| **粗** | `x` |\n| --- | --- |')
719
+ assert.equal(table.kind, 'table')
720
+ assert.deepEqual(table.header, [
721
+ [{ kind: 'bold', spans: [{ kind: 'text', text: '粗' }] }],
722
+ [{ kind: 'code', text: 'x' }],
723
+ ])
724
+ assert.deepEqual(table.align, [null, null], 'plain --- means unspecified alignment')
725
+ }
726
+ assert.deepEqual(parseMarkdown('a | b'), [{ kind: 'para', spans: [{ kind: 'text', text: 'a | b' }] }])
727
+
728
+ // Thematic breaks: 3+ of one char (- * _) with optional spaces; '** **' is
729
+ // one too (stars + spaces only), so the whitespace-emphasis fallback below
730
+ // uses it inside a sentence instead.
731
+ for (const src of ['---', '***', '___', '- - -', ' ----']) {
732
+ assert.deepEqual(parseMarkdown(src), [{ kind: 'hr' }], `hr: ${src.trim()}`)
733
+ }
734
+
735
+ // Own-line markdown image becomes a placeholder block (alt/src recorded for
736
+ // a future v2); inline inside a sentence it degrades to '!' + a link span.
737
+ assert.deepEqual(parseMarkdown('![架构](https://e.x/a.png)'), [{ kind: 'image', alt: '架构', src: 'https://e.x/a.png' }])
738
+ assert.deepEqual(parseMarkdown('看 ![图](u) 完'), [{
739
+ kind: 'para',
740
+ spans: [
741
+ { kind: 'text', text: '看 !' },
742
+ { kind: 'link', spans: [{ kind: 'text', text: '图' }], href: 'u' },
743
+ { kind: 'text', text: ' 完' },
744
+ ],
745
+ }])
746
+
747
+ // Inline spans of every kind, plus nesting (emphasis inside emphasis, code
748
+ // inside paragraphs protecting its markers, bold link text).
749
+ const inlineOf = (src) => {
750
+ const [block] = parseMarkdown(src)
751
+ assert.equal(block.kind, 'para')
752
+ return block.spans
753
+ }
754
+ assert.deepEqual(inlineOf('**粗**'), [{ kind: 'bold', spans: [{ kind: 'text', text: '粗' }] }])
755
+ assert.deepEqual(inlineOf('*斜*'), [{ kind: 'italic', spans: [{ kind: 'text', text: '斜' }] }])
756
+ assert.deepEqual(inlineOf('***粗斜***'), [{ kind: 'bold', spans: [{ kind: 'italic', spans: [{ kind: 'text', text: '粗斜' }] }] }])
757
+ assert.deepEqual(inlineOf('~~删~~'), [{ kind: 'strike', spans: [{ kind: 'text', text: '删' }] }])
758
+ assert.deepEqual(inlineOf('`码`'), [{ kind: 'code', text: '码' }])
759
+ assert.deepEqual(inlineOf('`*x*`'), [{ kind: 'code', text: '*x*' }])
760
+ assert.deepEqual(inlineOf('[标签](https://e.x)'), [{ kind: 'link', spans: [{ kind: 'text', text: '标签' }], href: 'https://e.x' }])
761
+ assert.deepEqual(inlineOf('[](https://e.x)'), [{ kind: 'link', spans: [{ kind: 'text', text: 'https://e.x' }], href: 'https://e.x' }], 'empty link text shows the URL itself')
762
+ assert.deepEqual(inlineOf('**a *i* c**'), [{
763
+ kind: 'bold',
764
+ spans: [{ kind: 'text', text: 'a ' }, { kind: 'italic', spans: [{ kind: 'text', text: 'i' }] }, { kind: 'text', text: ' c' }],
765
+ }])
766
+ assert.deepEqual(inlineOf('[**粗链**](u)'), [{ kind: 'link', spans: [{ kind: 'bold', spans: [{ kind: 'text', text: '粗链' }] }], href: 'u' }])
767
+
768
+ // Escapes: backslash + ASCII punctuation yields the literal char; a
769
+ // backslash before anything else stays a backslash.
770
+ assert.deepEqual(inlineOf('a \\*not em\\* b'), [{ kind: 'text', text: 'a *not em* b' }])
771
+ assert.deepEqual(inlineOf('\\`码\\`'), [{ kind: 'text', text: '`码`' }])
772
+ assert.deepEqual(inlineOf('\\[not a link\\](x)'), [{ kind: 'text', text: '[not a link](x)' }])
773
+ assert.deepEqual(inlineOf('a\\\\b'), [{ kind: 'text', text: 'a\\b' }])
774
+
775
+ // THE fallback rule: unclosed or implausible markers are literal text.
776
+ assert.deepEqual(inlineOf('**没闭'), [{ kind: 'text', text: '**没闭' }])
777
+ assert.deepEqual(inlineOf('*没闭'), [{ kind: 'text', text: '*没闭' }])
778
+ assert.deepEqual(inlineOf('`没闭'), [{ kind: 'text', text: '`没闭' }])
779
+ assert.deepEqual(inlineOf('~~没闭'), [{ kind: 'text', text: '~~没闭' }])
780
+ assert.deepEqual(inlineOf('[没闭](u'), [{ kind: 'text', text: '[没闭](u' }])
781
+ assert.deepEqual(inlineOf('[没括号] 后面'), [{ kind: 'text', text: '[没括号] 后面' }])
782
+ assert.deepEqual(inlineOf('a ** ** b'), [{ kind: 'text', text: 'a ** ** b' }], 'whitespace-only emphasis falls back')
783
+ // Flanking: an opener must touch non-space on both ends, so arithmetic and
784
+ // spaced asterisks never italicize.
785
+ assert.deepEqual(inlineOf('5 * 3 = 15'), [{ kind: 'text', text: '5 * 3 = 15' }])
786
+ assert.deepEqual(inlineOf('a * b * c'), [{ kind: 'text', text: 'a * b * c' }])
787
+
788
+ // Exact-run closers (review 09, P2): a longer run must NOT close a shorter
789
+ // marker. `*a **b** c*` parses as nested emphasis (the inner bold pair is
790
+ // skipped whole while hunting the outer single-star closer); the adjacent
791
+ // closer families that cannot close cleanly round-trip as literals —
792
+ // leaking mid-content stars into the share image is the one failure this
793
+ // parser must never produce.
794
+ assert.deepEqual(inlineOf('*a **b** c*'), [{
795
+ kind: 'italic',
796
+ spans: [{ kind: 'text', text: 'a ' }, { kind: 'bold', spans: [{ kind: 'text', text: 'b' }] }, { kind: 'text', text: ' c' }],
797
+ }])
798
+ assert.deepEqual(inlineOf('**a *b***'), [{ kind: 'text', text: '**a *b***' }])
799
+ assert.deepEqual(inlineOf('*****x*****'), [{ kind: 'text', text: '*****x*****' }])
800
+ assert.deepEqual(inlineOf('~~a ~~~ b~~'), [{ kind: 'strike', spans: [{ kind: 'text', text: 'a ~~~ b' }] }], 'a ~~~ triple is not a ~~ closer')
801
+
802
+ // Totality guards (review 09, P2/P3): nesting carries a depth ceiling and a
803
+ // pathological single paragraph past the inline-length ceiling parses as
804
+ // plain text — both return in milliseconds instead of throwing / scanning
805
+ // quadratic.
806
+ {
807
+ const deep = parseMarkdown('> '.repeat(10_000) + 'x')
808
+ assert.ok(Array.isArray(deep) && deep.length >= 1, 'deep quote chain must not throw')
809
+ const pathological = parseMarkdown(('*a ').repeat(20_000))
810
+ assert.deepEqual(pathological, [{ kind: 'para', spans: [{ kind: 'text', text: ('*a ').repeat(20_000) }] }], 'over-length paragraph downgrades to plain text')
811
+ }
812
+ }
813
+
814
+ // ---- ticket 09: parser output shape snapshot -----------------------------------
815
+ // One comprehensive document pinned in full: the renderer switches over this
816
+ // tree, so any parser change that reshapes it must be conscious (snapshot and
817
+ // renderer move together). This is the drift tripwire the ticket asked for.
818
+ assert.deepEqual(parseMarkdown([
819
+ '# 标题一',
820
+ '',
821
+ '段落 **粗** *斜* `码` ~~删~~ [链](https://e.x/a) 转义 \\*星\\*。',
822
+ '',
823
+ '## 标题二',
824
+ '',
825
+ '- 甲',
826
+ ' - 甲一',
827
+ '- 乙',
828
+ '',
829
+ '1. 步一',
830
+ '2. 步二',
831
+ '',
832
+ '> 引用行',
833
+ '> 第二行',
834
+ '',
835
+ '| A | B |',
836
+ '| :- | --: |',
837
+ '| a | b |',
838
+ '',
839
+ '```ts',
840
+ 'const x: number = 1',
841
+ '```',
842
+ '',
843
+ '---',
844
+ '',
845
+ '![图](https://e.x/i.png)',
846
+ ].join('\n')), [
847
+ { kind: 'heading', level: 1, spans: [{ kind: 'text', text: '标题一' }] },
848
+ {
849
+ kind: 'para',
850
+ spans: [
851
+ { kind: 'text', text: '段落 ' },
852
+ { kind: 'bold', spans: [{ kind: 'text', text: '粗' }] },
853
+ { kind: 'text', text: ' ' },
854
+ { kind: 'italic', spans: [{ kind: 'text', text: '斜' }] },
855
+ { kind: 'text', text: ' ' },
856
+ { kind: 'code', text: '码' },
857
+ { kind: 'text', text: ' ' },
858
+ { kind: 'strike', spans: [{ kind: 'text', text: '删' }] },
859
+ { kind: 'text', text: ' ' },
860
+ { kind: 'link', spans: [{ kind: 'text', text: '链' }], href: 'https://e.x/a' },
861
+ { kind: 'text', text: ' 转义 *星*。' },
862
+ ],
863
+ },
864
+ { kind: 'heading', level: 2, spans: [{ kind: 'text', text: '标题二' }] },
865
+ {
866
+ kind: 'list',
867
+ ordered: false,
868
+ items: [
869
+ { spans: [{ kind: 'text', text: '甲' }], number: undefined, children: [{ spans: [{ kind: 'text', text: '甲一' }], number: undefined, children: [] }] },
870
+ { spans: [{ kind: 'text', text: '乙' }], number: undefined, children: [] },
871
+ ],
872
+ },
873
+ {
874
+ kind: 'list',
875
+ ordered: true,
876
+ items: [
877
+ { spans: [{ kind: 'text', text: '步一' }], number: 1, children: [] },
878
+ { spans: [{ kind: 'text', text: '步二' }], number: 2, children: [] },
879
+ ],
880
+ },
881
+ { kind: 'quote', children: [{ kind: 'para', spans: [{ kind: 'text', text: '引用行\n第二行' }] }] },
882
+ {
883
+ kind: 'table',
884
+ align: ['left', 'right'],
885
+ header: [[{ kind: 'text', text: 'A' }], [{ kind: 'text', text: 'B' }]],
886
+ rows: [[[{ kind: 'text', text: 'a' }], [{ kind: 'text', text: 'b' }]]],
887
+ },
888
+ { kind: 'code', lang: 'ts', lines: ['const x: number = 1'] },
889
+ { kind: 'hr' },
890
+ { kind: 'image', alt: '图', src: 'https://e.x/i.png' },
891
+ ])
892
+
893
+ // ---- structural contracts the math depends on ---------------------------------
894
+ // rasterize.ts may only import the PURE constants module of its directory:
895
+ // this script loads it via Node type stripping, which EXECUTES every value
896
+ // import (JSX or DOM code would break the check), and the client bundler
897
+ // inlines it next to share-preview without new externals. share-constants.ts
898
+ // is the single source both the template and the rasterizer consume, so the
899
+ // width formulas can never drift from the rendered bubble again.
900
+ const rasterizeSrc = readFileSync(new URL('../src/client/share/rasterize.ts', import.meta.url), 'utf8')
901
+ const rasterizeImportSpecifiers = [...rasterizeSrc.matchAll(/^\s*import[^;\n]*from\s*['"]([^'"]+)['"]/gm)].map((m) => m[1])
902
+ assert.deepEqual(
903
+ rasterizeImportSpecifiers, ['./share-constants.ts'],
904
+ 'rasterize.ts may only import this directory\'s pure constants module (type stripping + externals budget)',
905
+ )
906
+ assert.match(rasterizeSrc, /export (async )?function (rasterizeShareCard|ensureProbe|planShareSlices)/)
907
+ assert.match(rasterizeSrc, /drawImage\(img, 0, 0\)/, 'paint must be a 1:1 copy — the SVG intrinsic size already IS the physical target')
908
+ assert.match(rasterizeSrc, /已省略前/, 'the default truncation note must count the dropped head turns')
909
+
910
+ // The constants module itself must stay a pure leaf: an import here would
911
+ // execute in this check, and JSX/DOM module scope would break type stripping.
912
+ const constantsSrc = readFileSync(new URL('../src/client/share/share-constants.ts', import.meta.url), 'utf8')
913
+ assert.doesNotMatch(constantsSrc, /^\s*import/m, 'share-constants.ts must stay import-free (pure leaf both sides consume)')
914
+
915
+ // png-stitch.ts is held to the same pure-leaf contract — this script imports
916
+ // it BY VALUE through type stripping (module scope executes), so it may carry
917
+ // no imports at all, and its browser globals (CompressionStream /
918
+ // createImageBitmap / document) must stay inside function/class bodies. The
919
+ // writer tests above prove the Node-loadable half runs for real.
920
+ const stitchSrc = readFileSync(new URL('../src/client/share/png-stitch.ts', import.meta.url), 'utf8')
921
+ assert.doesNotMatch(stitchSrc, /^\s*import/m, 'png-stitch.ts must stay import-free (pure leaf loadable by type stripping anywhere)')
922
+ assert.match(stitchSrc, /typeof CompressionStream === 'function'/, 'the stitch feature-detect gates on CompressionStream (old browsers fall back to multi-file)')
923
+
924
+ // The ticket-05 modules ride the same type-stripping constraint: a VALUE
925
+ // import (of share-card.tsx's JSX, or of anything touching the DOM at module
926
+ // scope) would fail this script's own import above. `import type` is erased
927
+ // by both tsc and Node's stripper, so only those are allowed. Constructing
928
+ // the error classes above additionally proves the constructor-parameter-
929
+ // property syntax (which the stripper rejects) is absent.
930
+ for (const mod of ['fetch-share.ts', 'share-flow.ts']) {
931
+ const src = readFileSync(new URL(`../src/client/share/${mod}`, import.meta.url), 'utf8')
932
+ assert.doesNotMatch(src, /^\s*import\s(?!type )/m, `${mod} must only import types (type stripping keeps it loadable here)`)
933
+ }
934
+
935
+ // share-card.tsx must CONSUME the shared constants module — that import is
936
+ // what makes the constants above the single source of truth for the bubble
937
+ // geometry (the retired regexes used to pin '82%' / '9px 14px' literals; the
938
+ // card root's padding/gap need no pin at all, the rasterizer measures them
939
+ // off the rendered clone).
940
+ const cardSrc = readFileSync(new URL('../src/client/share/share-card.tsx', import.meta.url), 'utf8')
941
+ assert.match(cardSrc, /from '\.\/share-constants\.ts'/, 'share-card must import the shared template constants (rasterize inverts them)')
942
+ assert.match(cardSrc, /data-share-role=\{turn\.role\}/, 'turn rows must carry data-share-role for role-aware width lifting')
943
+ assert.match(cardSrc, /whiteSpace: 'pre'/, 'code-block lines must keep natural width (the width probe depends on it)')
944
+ assert.match(cardSrc, /whiteSpace: 'nowrap'/, 'table cells must keep natural width (the width probe depends on it)')
945
+ // Ticket 09: assistant text renders through the markdown parser, user text
946
+ // keeps the ticket-03 plain-text fold (Chat-view parity), and the card stays
947
+ // className-free — the SVG-image render loads no stylesheets.
948
+ assert.match(cardSrc, /from '\.\/markdown\.ts'/, 'assistant text must render through the markdown parser')
949
+ assert.match(cardSrc, /from '\.\/text-layout\.ts'/, 'user text keeps the ticket-03 plain-text fold')
950
+ assert.ok(!/className[=:]/.test(cardSrc), 'share-card must stay className-free (foreignObject rasterization red line)')
951
+ // Inheritable-text drift guards: the shadow-root measurement inherits the
952
+ // host page's values while the SVG-image render starts from engine defaults,
953
+ // so every unpinned inheritable text property is a width-measurement drift
954
+ // channel (all pinned values are CSS initial values).
955
+ assert.match(cardSrc, /letterSpacing: 'normal'/, 'card root must pin letterSpacing so page and SVG renders agree')
956
+ assert.match(cardSrc, /wordBreak: 'normal'/, 'card root must pin wordBreak')
957
+ assert.match(cardSrc, /overflowWrap: 'normal'/, 'card root must pin overflowWrap')
958
+ assert.match(cardSrc, /hyphens: 'manual'/, 'card root must pin hyphens (initial value)')
959
+ assert.match(cardSrc, /textAlign: 'start'/, 'card root must pin textAlign')
960
+ assert.match(cardSrc, /textTransform: 'none'/, 'card root must pin textTransform')
961
+ assert.match(cardSrc, /tabSize: 8/, 'card root must pin tabSize (engine default 8)')
962
+ assert.match(cardSrc, /fontVariantLigatures: 'normal'/, 'card root must pin fontVariantLigatures')
963
+
964
+ // markdown.ts (ticket 09) rides the same pure-leaf contract as the constants
965
+ // module: import-free so this script's own type-stripped import above can load
966
+ // it, and the client bundler inlines it with no new externals.
967
+ const mdSrc = readFileSync(new URL('../src/client/share/markdown.ts', import.meta.url), 'utf8')
968
+ assert.doesNotMatch(mdSrc, /^\s*import/m, 'markdown.ts must stay import-free (pure leaf loadable by type stripping)')
969
+
970
+ console.log('SHARE IMAGE CHECK OK')