@jant/core 0.5.3 → 0.6.0

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 (95) hide show
  1. package/bin/commands/telegram/register-webhooks.js +93 -0
  2. package/dist/{app-C481ssbr.js → app-BIkkbVQk.js} +2252 -383
  3. package/dist/app-Bcr5_wZI.js +6 -0
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/client-Bo7sKkAQ.js +274 -0
  6. package/dist/client/_assets/client-QHRvzZwk.css +2 -0
  7. package/dist/client/_assets/{client-auth-CfBiCAB7.js → client-auth-D1jDQgbH.js} +49 -49
  8. package/dist/{env-CgaH9Mut.js → env-C7e2Nlnt.js} +30 -1
  9. package/dist/{export-CR9Megtb.js → export-Bbn86HmS.js} +1 -1
  10. package/dist/{github-sync-DYZq9rQp.js → github-sync-CBQPRZ8H.js} +1 -1
  11. package/dist/{github-sync-8Vv06aCr.js → github-sync-dXsiZa_e.js} +2 -2
  12. package/dist/index.js +4 -4
  13. package/dist/node.js +61 -5
  14. package/package.json +2 -1
  15. package/src/__tests__/helpers/app.ts +15 -2
  16. package/src/app.tsx +3 -0
  17. package/src/client/components/jant-compose-editor.ts +72 -0
  18. package/src/client/thread-context.ts +146 -2
  19. package/src/client/tiptap/__tests__/link-toolbar.test.ts +1 -1
  20. package/src/client/tiptap/bubble-menu.ts +1 -16
  21. package/src/client/tiptap/extensions.ts +2 -6
  22. package/src/client/tiptap/link-toolbar.ts +0 -21
  23. package/src/client/tiptap/paste-media.ts +49 -33
  24. package/src/client/tiptap/toolbar-mode.ts +0 -43
  25. package/src/client/video-processor.ts +9 -0
  26. package/src/db/migrations/0022_old_gressill.sql +24 -0
  27. package/src/db/migrations/0023_broad_terror.sql +20 -0
  28. package/src/db/migrations/0024_red_the_twelve.sql +3 -0
  29. package/src/db/migrations/0025_exotic_wendell_rand.sql +1 -0
  30. package/src/db/migrations/meta/0022_snapshot.json +2267 -0
  31. package/src/db/migrations/meta/0023_snapshot.json +2396 -0
  32. package/src/db/migrations/meta/0024_snapshot.json +2417 -0
  33. package/src/db/migrations/meta/0025_snapshot.json +2424 -0
  34. package/src/db/migrations/meta/_journal.json +28 -0
  35. package/src/db/migrations/pg/0020_bizarre_smasher.sql +24 -0
  36. package/src/db/migrations/pg/0021_sharp_puppet_master.sql +20 -0
  37. package/src/db/migrations/pg/0022_blushing_blue_shield.sql +3 -0
  38. package/src/db/migrations/pg/0023_organic_zemo.sql +1 -0
  39. package/src/db/migrations/pg/meta/0020_snapshot.json +2904 -0
  40. package/src/db/migrations/pg/meta/0021_snapshot.json +3060 -0
  41. package/src/db/migrations/pg/meta/0022_snapshot.json +3078 -0
  42. package/src/db/migrations/pg/meta/0023_snapshot.json +3084 -0
  43. package/src/db/migrations/pg/meta/_journal.json +28 -0
  44. package/src/db/pg/schema.ts +82 -0
  45. package/src/db/schema.ts +90 -0
  46. package/src/i18n/coverage.generated.ts +2 -2
  47. package/src/i18n/locales/public/en.po +8 -0
  48. package/src/i18n/locales/public/zh-Hans.po +8 -0
  49. package/src/i18n/locales/public/zh-Hant.po +8 -0
  50. package/src/i18n/locales/settings/en.po +135 -0
  51. package/src/i18n/locales/settings/en.ts +1 -1
  52. package/src/i18n/locales/settings/zh-Hans.po +136 -1
  53. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  54. package/src/i18n/locales/settings/zh-Hant.po +136 -1
  55. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  56. package/src/lib/__tests__/image-dimensions.test.ts +314 -0
  57. package/src/lib/__tests__/mp4-track-flags.test.ts +117 -0
  58. package/src/lib/__tests__/telegram-entities.test.ts +180 -0
  59. package/src/lib/__tests__/telegram-pool-webhooks.test.ts +127 -0
  60. package/src/lib/env.ts +45 -0
  61. package/src/lib/ids.ts +3 -0
  62. package/src/lib/image-dimensions.ts +258 -0
  63. package/src/lib/mp4-track-flags.ts +71 -0
  64. package/src/lib/telegram-entities.ts +240 -0
  65. package/src/lib/telegram-pool-webhooks.ts +86 -0
  66. package/src/lib/telegram-settings-status.tsx +109 -0
  67. package/src/lib/telegram.ts +363 -0
  68. package/src/node/runtime.ts +6 -0
  69. package/src/routes/api/__tests__/telegram.test.ts +612 -0
  70. package/src/routes/api/telegram.ts +782 -0
  71. package/src/routes/api/upload-multipart.ts +34 -12
  72. package/src/routes/api/upload.ts +23 -2
  73. package/src/routes/dash/settings.tsx +131 -1
  74. package/src/routes/pages/__tests__/post-page-title.test.ts +70 -0
  75. package/src/routes/pages/page.tsx +3 -2
  76. package/src/runtime/cloudflare.ts +20 -9
  77. package/src/runtime/node.ts +20 -9
  78. package/src/runtime/site.ts +2 -1
  79. package/src/services/__tests__/telegram.test.ts +148 -0
  80. package/src/services/index.ts +9 -0
  81. package/src/services/telegram.ts +613 -0
  82. package/src/services/upload-session.ts +39 -12
  83. package/src/styles/tokens.css +1 -0
  84. package/src/styles/ui.css +134 -38
  85. package/src/types/app-context.ts +6 -0
  86. package/src/types/bindings.ts +3 -0
  87. package/src/types/config.ts +40 -0
  88. package/src/ui/dash/settings/SettingsRootContent.tsx +48 -17
  89. package/src/ui/dash/settings/TelegramContent.tsx +549 -0
  90. package/src/ui/feed/ThreadPreview.tsx +91 -38
  91. package/src/ui/feed/__tests__/thread-preview.test.ts +67 -5
  92. package/src/ui/pages/PostPage.tsx +78 -15
  93. package/dist/app-BgMwEN-M.js +0 -6
  94. package/dist/client/_assets/client-CJQYvkEx.js +0 -274
  95. package/dist/client/_assets/client-CQvi1Buw.css +0 -2
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Image dimensions parser tests.
3
+ *
4
+ * Each test constructs the minimal valid header bytes for a format and
5
+ * verifies that {@link parseImageDimensions} extracts width/height.
6
+ */
7
+
8
+ import { describe, expect, it } from "vitest";
9
+ import { parseImageDimensions } from "../image-dimensions.js";
10
+
11
+ describe("parseImageDimensions", () => {
12
+ describe("PNG", () => {
13
+ it("parses width and height from the IHDR chunk", () => {
14
+ const bytes = buildPng(1280, 720);
15
+ expect(parseImageDimensions("image/png", bytes)).toEqual({
16
+ width: 1280,
17
+ height: 720,
18
+ });
19
+ });
20
+
21
+ it("returns null when the PNG signature is wrong", () => {
22
+ const bytes = buildPng(10, 10);
23
+ bytes[0] = 0;
24
+ expect(parseImageDimensions("image/png", bytes)).toBeNull();
25
+ });
26
+
27
+ it("returns null when the buffer is too short", () => {
28
+ expect(parseImageDimensions("image/png", new Uint8Array(10))).toBeNull();
29
+ });
30
+ });
31
+
32
+ describe("JPEG", () => {
33
+ it("parses dimensions from the SOF0 marker", () => {
34
+ const bytes = buildJpeg(800, 600);
35
+ expect(parseImageDimensions("image/jpeg", bytes)).toEqual({
36
+ width: 800,
37
+ height: 600,
38
+ });
39
+ });
40
+
41
+ it("skips APP segments before reaching SOF", () => {
42
+ const bytes = buildJpegWithApp(1920, 1080);
43
+ expect(parseImageDimensions("image/jpeg", bytes)).toEqual({
44
+ width: 1920,
45
+ height: 1080,
46
+ });
47
+ });
48
+
49
+ it("returns null when SOI marker is missing", () => {
50
+ const bytes = new Uint8Array([0x00, 0x00, 0xff, 0xc0]);
51
+ expect(parseImageDimensions("image/jpeg", bytes)).toBeNull();
52
+ });
53
+ });
54
+
55
+ describe("GIF", () => {
56
+ it("parses width and height from the logical screen descriptor", () => {
57
+ const bytes = buildGif(640, 480);
58
+ expect(parseImageDimensions("image/gif", bytes)).toEqual({
59
+ width: 640,
60
+ height: 480,
61
+ });
62
+ });
63
+ });
64
+
65
+ describe("WebP", () => {
66
+ it("parses VP8 (lossy) dimensions", () => {
67
+ const bytes = buildWebpVp8(1024, 768);
68
+ expect(parseImageDimensions("image/webp", bytes)).toEqual({
69
+ width: 1024,
70
+ height: 768,
71
+ });
72
+ });
73
+
74
+ it("parses VP8L (lossless) dimensions", () => {
75
+ const bytes = buildWebpVp8L(300, 200);
76
+ expect(parseImageDimensions("image/webp", bytes)).toEqual({
77
+ width: 300,
78
+ height: 200,
79
+ });
80
+ });
81
+
82
+ it("parses VP8X (extended) dimensions", () => {
83
+ const bytes = buildWebpVp8X(4096, 4096);
84
+ expect(parseImageDimensions("image/webp", bytes)).toEqual({
85
+ width: 4096,
86
+ height: 4096,
87
+ });
88
+ });
89
+
90
+ it("returns null when RIFF header is missing", () => {
91
+ const bytes = buildWebpVp8(10, 10);
92
+ bytes[0] = 0;
93
+ expect(parseImageDimensions("image/webp", bytes)).toBeNull();
94
+ });
95
+ });
96
+
97
+ describe("AVIF", () => {
98
+ it("parses dimensions from the ispe box", () => {
99
+ const bytes = buildAvif(2000, 1500);
100
+ expect(parseImageDimensions("image/avif", bytes)).toEqual({
101
+ width: 2000,
102
+ height: 1500,
103
+ });
104
+ });
105
+
106
+ it("accepts AVIF declared via compatible_brands", () => {
107
+ const bytes = buildAvif(500, 500, { majorBrand: "mif1" });
108
+ expect(parseImageDimensions("image/avif", bytes)).toEqual({
109
+ width: 500,
110
+ height: 500,
111
+ });
112
+ });
113
+ });
114
+
115
+ describe("unsupported types", () => {
116
+ it("returns null for SVG", () => {
117
+ const bytes = new TextEncoder().encode(
118
+ '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>',
119
+ );
120
+ expect(parseImageDimensions("image/svg+xml", bytes)).toBeNull();
121
+ });
122
+
123
+ it("returns null for unknown mime types", () => {
124
+ expect(parseImageDimensions("image/bmp", new Uint8Array(64))).toBeNull();
125
+ expect(parseImageDimensions("text/plain", new Uint8Array(64))).toBeNull();
126
+ });
127
+ });
128
+ });
129
+
130
+ // ── Fixture builders ──────────────────────────────────────────────────
131
+
132
+ function buildPng(width: number, height: number): Uint8Array {
133
+ // Signature + IHDR chunk header. Only the first 24 bytes are inspected.
134
+ const bytes = new Uint8Array(24);
135
+ bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
136
+ const view = new DataView(bytes.buffer);
137
+ view.setUint32(8, 13, false); // IHDR chunk length
138
+ bytes.set([0x49, 0x48, 0x44, 0x52], 12); // "IHDR"
139
+ view.setUint32(16, width, false);
140
+ view.setUint32(20, height, false);
141
+ return bytes;
142
+ }
143
+
144
+ function buildJpeg(width: number, height: number): Uint8Array {
145
+ // SOI + SOF0(payload: 8 bytes precision/height/width/components)
146
+ const bytes = new Uint8Array(2 + 2 + 8);
147
+ bytes[0] = 0xff;
148
+ bytes[1] = 0xd8;
149
+ bytes[2] = 0xff;
150
+ bytes[3] = 0xc0; // SOF0
151
+ bytes[4] = 0x00;
152
+ bytes[5] = 0x08; // segment length
153
+ bytes[6] = 0x08; // precision (8-bit)
154
+ bytes[7] = (height >> 8) & 0xff;
155
+ bytes[8] = height & 0xff;
156
+ bytes[9] = (width >> 8) & 0xff;
157
+ bytes[10] = width & 0xff;
158
+ bytes[11] = 0x03; // components
159
+ return bytes;
160
+ }
161
+
162
+ function buildJpegWithApp(width: number, height: number): Uint8Array {
163
+ // SOI + APP0 (length 16) + SOF0
164
+ const appPayload = 14; // 16-byte segment - 2 length bytes
165
+ const total = 2 + 2 + 2 + appPayload + 2 + 8;
166
+ const bytes = new Uint8Array(total);
167
+ let i = 0;
168
+ bytes[i++] = 0xff;
169
+ bytes[i++] = 0xd8;
170
+ bytes[i++] = 0xff;
171
+ bytes[i++] = 0xe0; // APP0
172
+ bytes[i++] = 0x00;
173
+ bytes[i++] = 0x10; // length 16
174
+ i += appPayload;
175
+ bytes[i++] = 0xff;
176
+ bytes[i++] = 0xc2; // SOF2 (progressive — also recognized)
177
+ bytes[i++] = 0x00;
178
+ bytes[i++] = 0x08;
179
+ bytes[i++] = 0x08;
180
+ bytes[i++] = (height >> 8) & 0xff;
181
+ bytes[i++] = height & 0xff;
182
+ bytes[i++] = (width >> 8) & 0xff;
183
+ bytes[i++] = width & 0xff;
184
+ bytes[i] = 0x03;
185
+ return bytes;
186
+ }
187
+
188
+ function buildGif(width: number, height: number): Uint8Array {
189
+ const bytes = new Uint8Array(10);
190
+ bytes.set([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]); // GIF89a
191
+ bytes[6] = width & 0xff;
192
+ bytes[7] = (width >> 8) & 0xff;
193
+ bytes[8] = height & 0xff;
194
+ bytes[9] = (height >> 8) & 0xff;
195
+ return bytes;
196
+ }
197
+
198
+ function buildWebpHeader(chunk: string, payloadLen: number): Uint8Array {
199
+ const totalChunk = 8 + payloadLen;
200
+ const bytes = new Uint8Array(12 + totalChunk);
201
+ bytes.set([0x52, 0x49, 0x46, 0x46]); // RIFF
202
+ const view = new DataView(bytes.buffer);
203
+ view.setUint32(4, 4 + totalChunk, true); // file size minus 8
204
+ bytes.set([0x57, 0x45, 0x42, 0x50], 8); // WEBP
205
+ for (let i = 0; i < 4; i += 1) {
206
+ bytes[12 + i] = chunk.charCodeAt(i);
207
+ }
208
+ view.setUint32(16, payloadLen, true);
209
+ return bytes;
210
+ }
211
+
212
+ function buildWebpVp8(width: number, height: number): Uint8Array {
213
+ // VP8 chunk: 10-byte frame header + signature 0x9d 0x01 0x2a + width/height
214
+ const payloadLen = 16;
215
+ const bytes = buildWebpHeader("VP8 ", payloadLen);
216
+ bytes[23] = 0x9d;
217
+ bytes[24] = 0x01;
218
+ bytes[25] = 0x2a;
219
+ bytes[26] = width & 0xff;
220
+ bytes[27] = (width >> 8) & 0x3f;
221
+ bytes[28] = height & 0xff;
222
+ bytes[29] = (height >> 8) & 0x3f;
223
+ return bytes;
224
+ }
225
+
226
+ function buildWebpVp8L(width: number, height: number): Uint8Array {
227
+ const payloadLen = 5;
228
+ const bytes = buildWebpHeader("VP8L", payloadLen);
229
+ bytes[20] = 0x2f; // signature
230
+ const w = width - 1;
231
+ const h = height - 1;
232
+ bytes[21] = w & 0xff;
233
+ bytes[22] = ((w >> 8) & 0x3f) | ((h & 0x03) << 6);
234
+ bytes[23] = (h >> 2) & 0xff;
235
+ bytes[24] = (h >> 10) & 0x0f;
236
+ return bytes;
237
+ }
238
+
239
+ function buildWebpVp8X(width: number, height: number): Uint8Array {
240
+ const payloadLen = 10;
241
+ const bytes = buildWebpHeader("VP8X", payloadLen);
242
+ // bytes[20..23] flags + reserved
243
+ const w = width - 1;
244
+ const h = height - 1;
245
+ bytes[24] = w & 0xff;
246
+ bytes[25] = (w >> 8) & 0xff;
247
+ bytes[26] = (w >> 16) & 0xff;
248
+ bytes[27] = h & 0xff;
249
+ bytes[28] = (h >> 8) & 0xff;
250
+ bytes[29] = (h >> 16) & 0xff;
251
+ return bytes;
252
+ }
253
+
254
+ interface AvifFixtureOptions {
255
+ majorBrand?: string;
256
+ }
257
+
258
+ function buildAvif(
259
+ width: number,
260
+ height: number,
261
+ opts: AvifFixtureOptions = {},
262
+ ): Uint8Array {
263
+ const majorBrand = opts.majorBrand ?? "avif";
264
+ const ispe = makeBox(
265
+ "ispe",
266
+ concat([new Uint8Array(4), u32BE(width), u32BE(height)]),
267
+ );
268
+ const ipco = makeBox("ipco", ispe);
269
+ const iprp = makeBox("iprp", ipco);
270
+ const meta = makeBox(
271
+ "meta",
272
+ concat([new Uint8Array(4), iprp]), // FullBox version/flags + iprp
273
+ );
274
+ // ftyp: major_brand + minor_version + compatible_brands ("avif")
275
+ const ftypPayload = concat([
276
+ asciiBytes(majorBrand),
277
+ u32BE(0),
278
+ asciiBytes("avif"),
279
+ ]);
280
+ const ftyp = makeBox("ftyp", ftypPayload);
281
+ return concat([ftyp, meta]);
282
+ }
283
+
284
+ function makeBox(type: string, payload: Uint8Array): Uint8Array {
285
+ const size = 8 + payload.length;
286
+ const bytes = new Uint8Array(size);
287
+ new DataView(bytes.buffer).setUint32(0, size, false);
288
+ bytes.set(asciiBytes(type), 4);
289
+ bytes.set(payload, 8);
290
+ return bytes;
291
+ }
292
+
293
+ function asciiBytes(s: string): Uint8Array {
294
+ const bytes = new Uint8Array(s.length);
295
+ for (let i = 0; i < s.length; i += 1) bytes[i] = s.charCodeAt(i);
296
+ return bytes;
297
+ }
298
+
299
+ function u32BE(value: number): Uint8Array {
300
+ const bytes = new Uint8Array(4);
301
+ new DataView(bytes.buffer).setUint32(0, value, false);
302
+ return bytes;
303
+ }
304
+
305
+ function concat(chunks: Uint8Array[]): Uint8Array {
306
+ const total = chunks.reduce((sum, c) => sum + c.length, 0);
307
+ const out = new Uint8Array(total);
308
+ let offset = 0;
309
+ for (const chunk of chunks) {
310
+ out.set(chunk, offset);
311
+ offset += chunk.length;
312
+ }
313
+ return out;
314
+ }
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { zeroTrackAlternateGroups } from "../mp4-track-flags.js";
3
+
4
+ const str4 = (s: string): number[] => [...s].map((c) => c.charCodeAt(0));
5
+ const u32 = (n: number): number[] => [
6
+ (n >>> 24) & 0xff,
7
+ (n >>> 16) & 0xff,
8
+ (n >>> 8) & 0xff,
9
+ n & 0xff,
10
+ ];
11
+ const u16 = (n: number): number[] => [(n >>> 8) & 0xff, n & 0xff];
12
+ const box = (type: string, ...payload: number[][]): number[] => {
13
+ const body = payload.flat();
14
+ return [...u32(body.length + 8), ...str4(type), ...body];
15
+ };
16
+
17
+ /** A `tkhd` box with a given version and alternate_group value. */
18
+ function tkhd(version: 0 | 1, alternateGroup: number): number[] {
19
+ // creation, modification, trackID, reserved: 4 fields, 4 or 8 bytes each.
20
+ const idAndTimes =
21
+ version === 1 ? new Array(24).fill(0) : new Array(16).fill(0);
22
+ const duration = version === 1 ? new Array(8).fill(0) : new Array(4).fill(0);
23
+ return box(
24
+ "tkhd",
25
+ [version, 0, 0, 0], // version + flags
26
+ idAndTimes,
27
+ duration,
28
+ new Array(8).fill(0), // reserved[2]
29
+ u16(0), // layer
30
+ u16(alternateGroup),
31
+ new Array(48).fill(0), // volume, reserved, matrix, width, height
32
+ );
33
+ }
34
+
35
+ describe("zeroTrackAlternateGroups", () => {
36
+ it("zeroes a non-zero alternate_group in every tkhd (v0)", () => {
37
+ const bytes = new Uint8Array(
38
+ box(
39
+ "moov",
40
+ box("trak", tkhd(0, 1), box("mdia", [])),
41
+ box("trak", tkhd(0, 2), box("mdia", [])),
42
+ ),
43
+ );
44
+ zeroTrackAlternateGroups(bytes.buffer);
45
+
46
+ const view = new DataView(bytes.buffer);
47
+ // Locate both tkhd boxes and confirm alternate_group is now 0.
48
+ const groups: number[] = [];
49
+ for (let i = 0; i + 8 <= bytes.length; i++) {
50
+ if (
51
+ String.fromCharCode(
52
+ bytes[i + 4],
53
+ bytes[i + 5],
54
+ bytes[i + 6],
55
+ bytes[i + 7],
56
+ ) === "tkhd"
57
+ ) {
58
+ groups.push(view.getUint16(i + 8 + 4 + 20 + 8 + 2));
59
+ }
60
+ }
61
+ expect(groups).toEqual([0, 0]);
62
+ });
63
+
64
+ it("handles version 1 tkhd boxes", () => {
65
+ const bytes = new Uint8Array(box("moov", box("trak", tkhd(1, 7))));
66
+ zeroTrackAlternateGroups(bytes.buffer);
67
+
68
+ const view = new DataView(bytes.buffer);
69
+ let offset = -1;
70
+ for (let i = 0; i + 8 <= bytes.length; i++) {
71
+ if (
72
+ String.fromCharCode(
73
+ bytes[i + 4],
74
+ bytes[i + 5],
75
+ bytes[i + 6],
76
+ bytes[i + 7],
77
+ ) === "tkhd"
78
+ ) {
79
+ offset = i + 8 + 4 + 32 + 8 + 2;
80
+ }
81
+ }
82
+ expect(view.getUint16(offset)).toBe(0);
83
+ });
84
+
85
+ it("touches nothing but the alternate_group field", () => {
86
+ const original = new Uint8Array(
87
+ box(
88
+ "moov",
89
+ box("trak", tkhd(0, 0x0102), box("mdia", box("mdhd", u32(0)))),
90
+ ),
91
+ );
92
+ const copy = new Uint8Array(original);
93
+ zeroTrackAlternateGroups(copy.buffer);
94
+
95
+ const diffs: number[] = [];
96
+ for (let i = 0; i < original.length; i++) {
97
+ if (original[i] !== copy[i]) diffs.push(i);
98
+ }
99
+ // Exactly the two bytes of one alternate_group field changed.
100
+ expect(diffs).toHaveLength(2);
101
+ expect(diffs[1]).toBe(diffs[0] + 1);
102
+ });
103
+
104
+ it("leaves an already-zero alternate_group untouched", () => {
105
+ const original = new Uint8Array(box("moov", box("trak", tkhd(0, 0))));
106
+ const copy = new Uint8Array(original);
107
+ zeroTrackAlternateGroups(copy.buffer);
108
+ expect([...copy]).toEqual([...original]);
109
+ });
110
+
111
+ it("ignores files with no tkhd box", () => {
112
+ const original = new Uint8Array(box("ftyp", str4("isom")));
113
+ const copy = new Uint8Array(original);
114
+ zeroTrackAlternateGroups(copy.buffer);
115
+ expect([...copy]).toEqual([...original]);
116
+ });
117
+ });
@@ -0,0 +1,180 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { entitiesToMarkdown } from "../telegram-entities.js";
3
+ import type { TelegramMessageEntity } from "../telegram.js";
4
+
5
+ function ents(
6
+ ...entries: Array<
7
+ Partial<TelegramMessageEntity> & {
8
+ type: string;
9
+ offset: number;
10
+ length: number;
11
+ }
12
+ >
13
+ ): TelegramMessageEntity[] {
14
+ return entries as TelegramMessageEntity[];
15
+ }
16
+
17
+ describe("entitiesToMarkdown", () => {
18
+ it("returns text unchanged when entities is empty or undefined", () => {
19
+ expect(entitiesToMarkdown("hello", [])).toBe("hello");
20
+ expect(entitiesToMarkdown("hello", undefined)).toBe("hello");
21
+ });
22
+
23
+ it("preserves user-typed markdown that sits outside any entity", () => {
24
+ // No entities → byte-for-byte passthrough. This is the case where the
25
+ // user typed literal markdown into Telegram instead of using its
26
+ // formatting menu.
27
+ expect(entitiesToMarkdown("**already bold** _italic_", [])).toBe(
28
+ "**already bold** _italic_",
29
+ );
30
+ });
31
+
32
+ it("wraps bold, italic, and strikethrough spans", () => {
33
+ expect(
34
+ entitiesToMarkdown(
35
+ "bold italic strike",
36
+ ents(
37
+ { type: "bold", offset: 0, length: 4 },
38
+ { type: "italic", offset: 5, length: 6 },
39
+ { type: "strikethrough", offset: 12, length: 6 },
40
+ ),
41
+ ),
42
+ ).toBe("**bold** *italic* ~~strike~~");
43
+ });
44
+
45
+ it("wraps text_link entities with the url", () => {
46
+ expect(
47
+ entitiesToMarkdown(
48
+ "see Jant for details",
49
+ ents({
50
+ type: "text_link",
51
+ offset: 4,
52
+ length: 4,
53
+ url: "https://jant.example/",
54
+ }),
55
+ ),
56
+ ).toBe("see [Jant](https://jant.example/) for details");
57
+ });
58
+
59
+ it("escapes parentheses in link URLs", () => {
60
+ expect(
61
+ entitiesToMarkdown(
62
+ "ref",
63
+ ents({
64
+ type: "text_link",
65
+ offset: 0,
66
+ length: 3,
67
+ url: "https://en.wikipedia.org/wiki/Foo_(bar)",
68
+ }),
69
+ ),
70
+ ).toBe("[ref](https://en.wikipedia.org/wiki/Foo_\\(bar\\))");
71
+ });
72
+
73
+ it("uses the shortest non-colliding fence for inline code with backticks", () => {
74
+ // Content has a single backtick, so a double-backtick fence is required.
75
+ expect(
76
+ entitiesToMarkdown(
77
+ "use `code` here",
78
+ ents({ type: "code", offset: 4, length: 6 }),
79
+ ),
80
+ ).toBe("use `` `code` `` here");
81
+ });
82
+
83
+ it("emits fenced code blocks with the entity language", () => {
84
+ expect(
85
+ entitiesToMarkdown(
86
+ "print(1)",
87
+ ents({ type: "pre", offset: 0, length: 8, language: "python" }),
88
+ ),
89
+ ).toBe("```python\nprint(1)\n```");
90
+ });
91
+
92
+ it("escapes markdown delimiters that appear inside a styled span", () => {
93
+ // Without escaping, the inner `_underscore_` would render as italic
94
+ // when the post body is parsed as markdown, even though the user only
95
+ // asked for bold.
96
+ expect(
97
+ entitiesToMarkdown(
98
+ "a _b_ c",
99
+ ents({ type: "bold", offset: 0, length: 7 }),
100
+ ),
101
+ ).toBe("**a \\_b\\_ c**");
102
+ });
103
+
104
+ it("handles nested entities (italic inside bold)", () => {
105
+ // "bold and italic" — bold covers the whole span, italic covers "italic"
106
+ expect(
107
+ entitiesToMarkdown(
108
+ "bold and italic",
109
+ ents(
110
+ { type: "bold", offset: 0, length: 15 },
111
+ { type: "italic", offset: 9, length: 6 },
112
+ ),
113
+ ),
114
+ ).toBe("**bold and *italic***");
115
+ });
116
+
117
+ it("renders a bold link as a bold-wrapped markdown link", () => {
118
+ expect(
119
+ entitiesToMarkdown(
120
+ "click here",
121
+ ents(
122
+ { type: "bold", offset: 0, length: 10 },
123
+ {
124
+ type: "text_link",
125
+ offset: 6,
126
+ length: 4,
127
+ url: "https://example.com/",
128
+ },
129
+ ),
130
+ ),
131
+ ).toBe("**click [here](https://example.com/)**");
132
+ });
133
+
134
+ it("leaves auto-detected url/mention/hashtag entities as plain text", () => {
135
+ // Telegram tags these automatically; markdown will auto-link the URL.
136
+ expect(
137
+ entitiesToMarkdown(
138
+ "visit https://jant.example/ @alice #news",
139
+ ents(
140
+ { type: "url", offset: 6, length: 21 },
141
+ { type: "mention", offset: 28, length: 6 },
142
+ { type: "hashtag", offset: 35, length: 5 },
143
+ ),
144
+ ),
145
+ ).toBe("visit https://jant.example/ @alice #news");
146
+ });
147
+
148
+ it("renders blockquote entities with `> ` per line", () => {
149
+ expect(
150
+ entitiesToMarkdown(
151
+ "line one\nline two",
152
+ ents({ type: "blockquote", offset: 0, length: 17 }),
153
+ ),
154
+ ).toBe("> line one\n> line two");
155
+ });
156
+
157
+ it("uses UTF-16 offsets so surrogate-pair emoji align correctly", () => {
158
+ // The emoji is one Telegram "character" but two JS code units. Telegram
159
+ // sends offset 3 / length 4 for "bold" — that lands on indices 3..7 in
160
+ // the JS string, which is exactly what slice expects.
161
+ const text = "👋 hi bold tail";
162
+ // wave emoji is two UTF-16 units, so the string is 15 long, not 14.
163
+ expect(text.length).toBe(15);
164
+ expect(text.slice(6, 10)).toBe("bold");
165
+ expect(
166
+ entitiesToMarkdown(text, ents({ type: "bold", offset: 6, length: 4 })),
167
+ ).toBe("👋 hi **bold** tail");
168
+ });
169
+
170
+ it("does not escape markdown chars between top-level entities", () => {
171
+ // Bold span at the end; the **literal asterisks** the user typed before
172
+ // it must pass through unchanged.
173
+ expect(
174
+ entitiesToMarkdown(
175
+ "**typed** styled",
176
+ ents({ type: "bold", offset: 10, length: 6 }),
177
+ ),
178
+ ).toBe("**typed** **styled**");
179
+ });
180
+ });