noslop 0.1.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.
@@ -0,0 +1,746 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import { parseContent, findItem, createDraft, formatDate, sortBySchedule, isNoslopProject, getContentDirs, isValidUrl, updateStatus, updateSchedule, moveToPosts, moveToDrafts, addPublishedUrl, deleteDraft, } from './content.js';
6
+ describe('content.ts', () => {
7
+ let testDir;
8
+ beforeEach(() => {
9
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'noslop-test-'));
10
+ });
11
+ afterEach(() => {
12
+ fs.rmSync(testDir, { recursive: true, force: true });
13
+ });
14
+ describe('isNoslopProject', () => {
15
+ it('returns false for empty directory', () => {
16
+ expect(isNoslopProject(testDir)).toBe(false);
17
+ });
18
+ it('returns true if drafts directory exists', () => {
19
+ fs.mkdirSync(path.join(testDir, 'drafts'));
20
+ expect(isNoslopProject(testDir)).toBe(true);
21
+ });
22
+ it('returns true if posts directory exists', () => {
23
+ fs.mkdirSync(path.join(testDir, 'posts'));
24
+ expect(isNoslopProject(testDir)).toBe(true);
25
+ });
26
+ });
27
+ describe('getContentDirs', () => {
28
+ it('returns correct paths', () => {
29
+ const dirs = getContentDirs(testDir);
30
+ expect(dirs.postsDir).toBe(path.join(testDir, 'posts'));
31
+ expect(dirs.draftsDir).toBe(path.join(testDir, 'drafts'));
32
+ });
33
+ });
34
+ describe('parseContent', () => {
35
+ it('returns empty array for non-existent directory', () => {
36
+ const result = parseContent(path.join(testDir, 'nonexistent'), 'D');
37
+ expect(result).toEqual([]);
38
+ });
39
+ it('parses x.md file with all fields', () => {
40
+ const draftsDir = path.join(testDir, 'drafts');
41
+ const postDir = path.join(draftsDir, 'test-post');
42
+ fs.mkdirSync(postDir, { recursive: true });
43
+ const xContent = `# Test Title
44
+
45
+ ## Post
46
+ \`\`\`
47
+ Hello world content
48
+ \`\`\`
49
+
50
+ ## Status
51
+ ready
52
+
53
+ ## Media
54
+ screenshot.png
55
+
56
+ ## Scheduled
57
+ 2026-01-27 09:00
58
+
59
+ ## Posted
60
+
61
+
62
+ ## Published
63
+ `;
64
+ fs.writeFileSync(path.join(postDir, 'x.md'), xContent);
65
+ const result = parseContent(draftsDir, 'D');
66
+ expect(result).toHaveLength(1);
67
+ expect(result[0].title).toBe('Test Title');
68
+ expect(result[0].post).toBe('Hello world content');
69
+ expect(result[0].status).toBe('ready');
70
+ expect(result[0].media).toBe('screenshot.png');
71
+ expect(result[0].scheduledAt).toBe('2026-01-27 09:00');
72
+ expect(result[0].id).toBe('D001');
73
+ });
74
+ it('handles missing optional fields gracefully', () => {
75
+ const draftsDir = path.join(testDir, 'drafts');
76
+ const postDir = path.join(draftsDir, 'minimal-post');
77
+ fs.mkdirSync(postDir, { recursive: true });
78
+ const xContent = `# Minimal Post
79
+
80
+ ## Post
81
+ \`\`\`
82
+ Just content
83
+ \`\`\`
84
+ `;
85
+ fs.writeFileSync(path.join(postDir, 'x.md'), xContent);
86
+ const result = parseContent(draftsDir, 'D');
87
+ expect(result).toHaveLength(1);
88
+ expect(result[0].title).toBe('Minimal Post');
89
+ expect(result[0].status).toBe('draft');
90
+ expect(result[0].scheduledAt).toBe('');
91
+ });
92
+ it('filters out hidden directories', () => {
93
+ const draftsDir = path.join(testDir, 'drafts');
94
+ fs.mkdirSync(path.join(draftsDir, '.hidden'), { recursive: true });
95
+ fs.mkdirSync(path.join(draftsDir, 'visible'), { recursive: true });
96
+ fs.writeFileSync(path.join(draftsDir, 'visible', 'x.md'), '# Visible\n\n## Post\n```\ntest\n```');
97
+ const result = parseContent(draftsDir, 'D');
98
+ expect(result).toHaveLength(1);
99
+ expect(result[0].folder).toBe('visible');
100
+ });
101
+ });
102
+ describe('createDraft', () => {
103
+ beforeEach(() => {
104
+ fs.mkdirSync(path.join(testDir, 'drafts'));
105
+ });
106
+ it('creates folder with slugified name', () => {
107
+ const item = createDraft('My First Post!', testDir);
108
+ expect(item.folder).toBe('my-first-post');
109
+ expect(fs.existsSync(item.path)).toBe(true);
110
+ expect(fs.existsSync(item.xFile)).toBe(true);
111
+ });
112
+ it('creates x.md with correct template', () => {
113
+ const item = createDraft('Test Post', testDir);
114
+ const content = fs.readFileSync(item.xFile, 'utf-8');
115
+ expect(content).toContain('# Test Post');
116
+ expect(content).toContain('## Post');
117
+ expect(content).toContain('## Status\ndraft');
118
+ });
119
+ it('handles special characters in title', () => {
120
+ const item = createDraft('Hello & Goodbye!!! @#$%', testDir);
121
+ expect(item.folder).toBe('hello-goodbye');
122
+ expect(fs.existsSync(item.path)).toBe(true);
123
+ });
124
+ it('creates drafts directory if it does not exist', () => {
125
+ const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'noslop-empty-'));
126
+ const item = createDraft('New Post', emptyDir);
127
+ expect(fs.existsSync(path.join(emptyDir, 'drafts'))).toBe(true);
128
+ expect(fs.existsSync(item.path)).toBe(true);
129
+ fs.rmSync(emptyDir, { recursive: true, force: true });
130
+ });
131
+ });
132
+ describe('findItem', () => {
133
+ beforeEach(() => {
134
+ const draftsDir = path.join(testDir, 'drafts');
135
+ fs.mkdirSync(path.join(draftsDir, 'monday-motivation'), { recursive: true });
136
+ fs.writeFileSync(path.join(draftsDir, 'monday-motivation', 'x.md'), '# Monday Motivation\n\n## Post\n```\ncontent\n```');
137
+ });
138
+ it('finds by exact folder name', () => {
139
+ const item = findItem('monday-motivation', testDir);
140
+ expect(item).not.toBeNull();
141
+ expect(item?.folder).toBe('monday-motivation');
142
+ });
143
+ it('finds by partial folder name', () => {
144
+ const item = findItem('monday', testDir);
145
+ expect(item).not.toBeNull();
146
+ expect(item?.folder).toBe('monday-motivation');
147
+ });
148
+ it('finds by ID', () => {
149
+ const item = findItem('D001', testDir);
150
+ expect(item).not.toBeNull();
151
+ });
152
+ it('finds by lowercase ID', () => {
153
+ const item = findItem('d001', testDir);
154
+ expect(item).not.toBeNull();
155
+ });
156
+ it('returns null for non-existent item', () => {
157
+ const item = findItem('nonexistent', testDir);
158
+ expect(item).toBeNull();
159
+ });
160
+ });
161
+ describe('formatDate', () => {
162
+ it('formats date correctly', () => {
163
+ const date = new Date(2026, 0, 27, 9, 5);
164
+ expect(formatDate(date)).toBe('2026-01-27 09:05');
165
+ });
166
+ it('pads single digits', () => {
167
+ const date = new Date(2026, 0, 5, 8, 3);
168
+ expect(formatDate(date)).toBe('2026-01-05 08:03');
169
+ });
170
+ it('handles midnight', () => {
171
+ const date = new Date(2026, 11, 31, 0, 0);
172
+ expect(formatDate(date)).toBe('2026-12-31 00:00');
173
+ });
174
+ });
175
+ describe('isValidUrl', () => {
176
+ it('returns true for valid http URL', () => {
177
+ expect(isValidUrl('http://example.com')).toBe(true);
178
+ });
179
+ it('returns true for valid https URL', () => {
180
+ expect(isValidUrl('https://example.com/path?query=value')).toBe(true);
181
+ });
182
+ it('returns true for URL with port', () => {
183
+ expect(isValidUrl('https://example.com:8080/path')).toBe(true);
184
+ });
185
+ it('returns false for invalid URL', () => {
186
+ expect(isValidUrl('not-a-url')).toBe(false);
187
+ });
188
+ it('returns false for empty string', () => {
189
+ expect(isValidUrl('')).toBe(false);
190
+ });
191
+ it('returns false for relative path', () => {
192
+ expect(isValidUrl('/path/to/something')).toBe(false);
193
+ });
194
+ });
195
+ describe('findItem - path traversal protection', () => {
196
+ beforeEach(() => {
197
+ const draftsDir = path.join(testDir, 'drafts');
198
+ fs.mkdirSync(path.join(draftsDir, 'test-post'), { recursive: true });
199
+ fs.writeFileSync(path.join(draftsDir, 'test-post', 'x.md'), '# Test\n\n## Post\n```\ncontent\n```');
200
+ });
201
+ it('throws error for query with ..', () => {
202
+ expect(() => findItem('../etc/passwd', testDir)).toThrow('path traversal not allowed');
203
+ });
204
+ it('throws error for query starting with /', () => {
205
+ expect(() => findItem('/etc/passwd', testDir)).toThrow('path traversal not allowed');
206
+ });
207
+ it('throws error for query with backslash', () => {
208
+ expect(() => findItem('..\\windows\\system32', testDir)).toThrow('path traversal not allowed');
209
+ });
210
+ it('allows normal queries', () => {
211
+ expect(() => findItem('test-post', testDir)).not.toThrow();
212
+ });
213
+ });
214
+ describe('sortBySchedule', () => {
215
+ it('sorts by scheduled date ascending', () => {
216
+ const items = [
217
+ { scheduledAt: '2026-01-30 09:00' },
218
+ { scheduledAt: '2026-01-25 09:00' },
219
+ { scheduledAt: '2026-01-28 09:00' },
220
+ ];
221
+ const sorted = sortBySchedule(items);
222
+ expect(sorted[0].scheduledAt).toBe('2026-01-25 09:00');
223
+ expect(sorted[1].scheduledAt).toBe('2026-01-28 09:00');
224
+ expect(sorted[2].scheduledAt).toBe('2026-01-30 09:00');
225
+ });
226
+ it('puts unscheduled items at the end', () => {
227
+ const items = [
228
+ { scheduledAt: '' },
229
+ { scheduledAt: '2026-01-25 09:00' },
230
+ ];
231
+ const sorted = sortBySchedule(items);
232
+ expect(sorted[0].scheduledAt).toBe('2026-01-25 09:00');
233
+ expect(sorted[1].scheduledAt).toBe('');
234
+ });
235
+ it('does not mutate original array', () => {
236
+ const items = [
237
+ { scheduledAt: '2026-01-30 09:00' },
238
+ { scheduledAt: '2026-01-25 09:00' },
239
+ ];
240
+ const sorted = sortBySchedule(items);
241
+ expect(items[0].scheduledAt).toBe('2026-01-30 09:00');
242
+ expect(sorted[0].scheduledAt).toBe('2026-01-25 09:00');
243
+ });
244
+ });
245
+ describe('updateStatus', () => {
246
+ it('updates status from draft to ready', () => {
247
+ const draftsDir = path.join(testDir, 'drafts');
248
+ const postDir = path.join(draftsDir, 'test-post');
249
+ fs.mkdirSync(postDir, { recursive: true });
250
+ const xFile = path.join(postDir, 'x.md');
251
+ fs.writeFileSync(xFile, '# Test\n\n## Post\n```\ncontent\n```\n\n## Status\ndraft\n\n## Media\nNone');
252
+ const item = {
253
+ id: 'D001',
254
+ folder: 'test-post',
255
+ title: 'Test',
256
+ post: 'content',
257
+ published: '',
258
+ media: 'None',
259
+ postedAt: '',
260
+ scheduledAt: '',
261
+ status: 'draft',
262
+ path: postDir,
263
+ xFile,
264
+ };
265
+ updateStatus(item, 'ready');
266
+ const content = fs.readFileSync(xFile, 'utf-8');
267
+ expect(content).toContain('## Status\nready');
268
+ });
269
+ it('updates status from ready to draft', () => {
270
+ const draftsDir = path.join(testDir, 'drafts');
271
+ const postDir = path.join(draftsDir, 'test-post');
272
+ fs.mkdirSync(postDir, { recursive: true });
273
+ const xFile = path.join(postDir, 'x.md');
274
+ fs.writeFileSync(xFile, '# Test\n\n## Post\n```\ncontent\n```\n\n## Status\nready\n\n## Media\nNone');
275
+ const item = {
276
+ id: 'D001',
277
+ folder: 'test-post',
278
+ title: 'Test',
279
+ post: 'content',
280
+ published: '',
281
+ media: 'None',
282
+ postedAt: '',
283
+ scheduledAt: '',
284
+ status: 'ready',
285
+ path: postDir,
286
+ xFile,
287
+ };
288
+ updateStatus(item, 'draft');
289
+ const content = fs.readFileSync(xFile, 'utf-8');
290
+ expect(content).toContain('## Status\ndraft');
291
+ });
292
+ });
293
+ describe('updateSchedule', () => {
294
+ it('updates scheduled date', () => {
295
+ const draftsDir = path.join(testDir, 'drafts');
296
+ const postDir = path.join(draftsDir, 'test-post');
297
+ fs.mkdirSync(postDir, { recursive: true });
298
+ const xFile = path.join(postDir, 'x.md');
299
+ fs.writeFileSync(xFile, '# Test\n\n## Post\n```\ncontent\n```\n\n## Scheduled\n\n## Posted\n');
300
+ const item = {
301
+ id: 'D001',
302
+ folder: 'test-post',
303
+ title: 'Test',
304
+ post: 'content',
305
+ published: '',
306
+ media: 'None',
307
+ postedAt: '',
308
+ scheduledAt: '',
309
+ status: 'draft',
310
+ path: postDir,
311
+ xFile,
312
+ };
313
+ updateSchedule(item, '2026-01-27 09:00');
314
+ const content = fs.readFileSync(xFile, 'utf-8');
315
+ expect(content).toContain('## Scheduled\n2026-01-27 09:00');
316
+ });
317
+ });
318
+ describe('moveToPosts', () => {
319
+ it('moves draft folder to posts', () => {
320
+ const draftsDir = path.join(testDir, 'drafts');
321
+ const postsDir = path.join(testDir, 'posts');
322
+ const postDir = path.join(draftsDir, 'test-post');
323
+ fs.mkdirSync(postDir, { recursive: true });
324
+ fs.mkdirSync(postsDir, { recursive: true });
325
+ const xFile = path.join(postDir, 'x.md');
326
+ fs.writeFileSync(xFile, '# Test\n\n## Post\n```\ncontent\n```\n\n## Status\nready\n\n## Media\nNone');
327
+ const item = {
328
+ id: 'D001',
329
+ folder: 'test-post',
330
+ title: 'Test',
331
+ post: 'content',
332
+ published: '',
333
+ media: 'None',
334
+ postedAt: '',
335
+ scheduledAt: '',
336
+ status: 'ready',
337
+ path: postDir,
338
+ xFile,
339
+ };
340
+ moveToPosts(item, testDir);
341
+ expect(fs.existsSync(postDir)).toBe(false);
342
+ expect(fs.existsSync(path.join(postsDir, 'test-post'))).toBe(true);
343
+ });
344
+ it('removes status field from file', () => {
345
+ const draftsDir = path.join(testDir, 'drafts');
346
+ const postsDir = path.join(testDir, 'posts');
347
+ const postDir = path.join(draftsDir, 'test-post');
348
+ fs.mkdirSync(postDir, { recursive: true });
349
+ fs.mkdirSync(postsDir, { recursive: true });
350
+ const xFile = path.join(postDir, 'x.md');
351
+ fs.writeFileSync(xFile, '# Test\n\n## Post\n```\ncontent\n```\n\n## Status\nready\n\n## Media\nNone');
352
+ const item = {
353
+ id: 'D001',
354
+ folder: 'test-post',
355
+ title: 'Test',
356
+ post: 'content',
357
+ published: '',
358
+ media: 'None',
359
+ postedAt: '',
360
+ scheduledAt: '',
361
+ status: 'ready',
362
+ path: postDir,
363
+ xFile,
364
+ };
365
+ moveToPosts(item, testDir);
366
+ const newXFile = path.join(postsDir, 'test-post', 'x.md');
367
+ const content = fs.readFileSync(newXFile, 'utf-8');
368
+ expect(content).not.toContain('## Status');
369
+ });
370
+ it('creates posts directory if missing', () => {
371
+ const draftsDir = path.join(testDir, 'drafts');
372
+ const postDir = path.join(draftsDir, 'test-post');
373
+ fs.mkdirSync(postDir, { recursive: true });
374
+ const xFile = path.join(postDir, 'x.md');
375
+ fs.writeFileSync(xFile, '# Test\n\n## Post\n```\ncontent\n```');
376
+ const item = {
377
+ id: 'D001',
378
+ folder: 'test-post',
379
+ title: 'Test',
380
+ post: 'content',
381
+ published: '',
382
+ media: 'None',
383
+ postedAt: '',
384
+ scheduledAt: '',
385
+ status: 'draft',
386
+ path: postDir,
387
+ xFile,
388
+ };
389
+ moveToPosts(item, testDir);
390
+ expect(fs.existsSync(path.join(testDir, 'posts', 'test-post'))).toBe(true);
391
+ });
392
+ });
393
+ describe('moveToDrafts', () => {
394
+ it('moves post folder to drafts', () => {
395
+ const postsDir = path.join(testDir, 'posts');
396
+ const draftsDir = path.join(testDir, 'drafts');
397
+ const postDir = path.join(postsDir, 'test-post');
398
+ fs.mkdirSync(postDir, { recursive: true });
399
+ fs.mkdirSync(draftsDir, { recursive: true });
400
+ const xFile = path.join(postDir, 'x.md');
401
+ fs.writeFileSync(xFile, '# Test\n\n## Post\n```\ncontent\n```\n\n## Media\nNone');
402
+ const item = {
403
+ id: 'P001',
404
+ folder: 'test-post',
405
+ title: 'Test',
406
+ post: 'content',
407
+ published: '',
408
+ media: 'None',
409
+ postedAt: '',
410
+ scheduledAt: '',
411
+ status: 'draft',
412
+ path: postDir,
413
+ xFile,
414
+ };
415
+ moveToDrafts(item, testDir);
416
+ expect(fs.existsSync(postDir)).toBe(false);
417
+ expect(fs.existsSync(path.join(draftsDir, 'test-post'))).toBe(true);
418
+ });
419
+ it('adds status field to file', () => {
420
+ const postsDir = path.join(testDir, 'posts');
421
+ const draftsDir = path.join(testDir, 'drafts');
422
+ const postDir = path.join(postsDir, 'test-post');
423
+ fs.mkdirSync(postDir, { recursive: true });
424
+ fs.mkdirSync(draftsDir, { recursive: true });
425
+ const xFile = path.join(postDir, 'x.md');
426
+ fs.writeFileSync(xFile, '# Test\n\n## Post\n```\ncontent\n```\n\n## Media\nNone');
427
+ const item = {
428
+ id: 'P001',
429
+ folder: 'test-post',
430
+ title: 'Test',
431
+ post: 'content',
432
+ published: '',
433
+ media: 'None',
434
+ postedAt: '',
435
+ scheduledAt: '',
436
+ status: 'draft',
437
+ path: postDir,
438
+ xFile,
439
+ };
440
+ moveToDrafts(item, testDir);
441
+ const newXFile = path.join(draftsDir, 'test-post', 'x.md');
442
+ const content = fs.readFileSync(newXFile, 'utf-8');
443
+ expect(content).toContain('## Status\nready');
444
+ });
445
+ });
446
+ describe('addPublishedUrl', () => {
447
+ it('adds published URL to post', () => {
448
+ const postsDir = path.join(testDir, 'posts');
449
+ const postDir = path.join(postsDir, 'test-post');
450
+ fs.mkdirSync(postDir, { recursive: true });
451
+ const xFile = path.join(postDir, 'x.md');
452
+ fs.writeFileSync(xFile, '# Test\n\n## Post\n```\ncontent\n```\n\n## Posted\n\n## Published\n');
453
+ const item = {
454
+ id: 'P001',
455
+ folder: 'test-post',
456
+ title: 'Test',
457
+ post: 'content',
458
+ published: '',
459
+ media: 'None',
460
+ postedAt: '',
461
+ scheduledAt: '',
462
+ status: 'draft',
463
+ path: postDir,
464
+ xFile,
465
+ };
466
+ addPublishedUrl(item, 'https://x.com/user/status/123');
467
+ const content = fs.readFileSync(xFile, 'utf-8');
468
+ expect(content).toContain('## Published\nhttps://x.com/user/status/123');
469
+ });
470
+ it('throws error for invalid URL', () => {
471
+ const postsDir = path.join(testDir, 'posts');
472
+ const postDir = path.join(postsDir, 'test-post');
473
+ fs.mkdirSync(postDir, { recursive: true });
474
+ const xFile = path.join(postDir, 'x.md');
475
+ fs.writeFileSync(xFile, '# Test\n\n## Post\n```\ncontent\n```');
476
+ const item = {
477
+ id: 'P001',
478
+ folder: 'test-post',
479
+ title: 'Test',
480
+ post: 'content',
481
+ published: '',
482
+ media: 'None',
483
+ postedAt: '',
484
+ scheduledAt: '',
485
+ status: 'draft',
486
+ path: postDir,
487
+ xFile,
488
+ };
489
+ expect(() => addPublishedUrl(item, 'not-a-url')).toThrow('Invalid URL');
490
+ });
491
+ it('uses scheduled date for posted date if available', () => {
492
+ const postsDir = path.join(testDir, 'posts');
493
+ const postDir = path.join(postsDir, 'test-post');
494
+ fs.mkdirSync(postDir, { recursive: true });
495
+ const xFile = path.join(postDir, 'x.md');
496
+ fs.writeFileSync(xFile, '# Test\n\n## Post\n```\ncontent\n```\n\n## Scheduled\n2026-01-27 09:00\n\n## Posted\n\n## Published\n');
497
+ const item = {
498
+ id: 'P001',
499
+ folder: 'test-post',
500
+ title: 'Test',
501
+ post: 'content',
502
+ published: '',
503
+ media: 'None',
504
+ postedAt: '',
505
+ scheduledAt: '2026-01-27 09:00',
506
+ status: 'draft',
507
+ path: postDir,
508
+ xFile,
509
+ };
510
+ addPublishedUrl(item, 'https://x.com/user/status/123');
511
+ const content = fs.readFileSync(xFile, 'utf-8');
512
+ expect(content).toContain('## Posted\n2026-01-27 09:00');
513
+ });
514
+ });
515
+ describe('deleteDraft', () => {
516
+ it('deletes draft folder', () => {
517
+ const draftsDir = path.join(testDir, 'drafts');
518
+ const postDir = path.join(draftsDir, 'test-post');
519
+ fs.mkdirSync(postDir, { recursive: true });
520
+ const xFile = path.join(postDir, 'x.md');
521
+ fs.writeFileSync(xFile, '# Test\n\n## Post\n```\ncontent\n```');
522
+ const item = {
523
+ id: 'D001',
524
+ folder: 'test-post',
525
+ title: 'Test',
526
+ post: 'content',
527
+ published: '',
528
+ media: 'None',
529
+ postedAt: '',
530
+ scheduledAt: '',
531
+ status: 'draft',
532
+ path: postDir,
533
+ xFile,
534
+ };
535
+ deleteDraft(item);
536
+ expect(fs.existsSync(postDir)).toBe(false);
537
+ });
538
+ it('deletes folder with nested files', () => {
539
+ const draftsDir = path.join(testDir, 'drafts');
540
+ const postDir = path.join(draftsDir, 'test-post');
541
+ const assetsDir = path.join(postDir, 'assets');
542
+ fs.mkdirSync(assetsDir, { recursive: true });
543
+ fs.writeFileSync(path.join(postDir, 'x.md'), '# Test');
544
+ fs.writeFileSync(path.join(assetsDir, 'image.png'), 'fake image data');
545
+ const item = {
546
+ id: 'D001',
547
+ folder: 'test-post',
548
+ title: 'Test',
549
+ post: 'content',
550
+ published: '',
551
+ media: 'None',
552
+ postedAt: '',
553
+ scheduledAt: '',
554
+ status: 'draft',
555
+ path: postDir,
556
+ xFile: path.join(postDir, 'x.md'),
557
+ };
558
+ deleteDraft(item);
559
+ expect(fs.existsSync(postDir)).toBe(false);
560
+ });
561
+ });
562
+ describe('Edge Cases', () => {
563
+ describe('Unicode and special characters', () => {
564
+ it('handles unicode in title', () => {
565
+ const item = createDraft('こんにちは世界 🌍', testDir);
566
+ expect(fs.existsSync(item.path)).toBe(true);
567
+ const content = fs.readFileSync(item.xFile, 'utf-8');
568
+ expect(content).toContain('# こんにちは世界 🌍');
569
+ });
570
+ it('handles emoji-only title', () => {
571
+ const item = createDraft('🚀🔥💯', testDir);
572
+ expect(fs.existsSync(item.path)).toBe(true);
573
+ });
574
+ it('parses post content with unicode', () => {
575
+ const draftsDir = path.join(testDir, 'drafts');
576
+ const postDir = path.join(draftsDir, 'unicode-post');
577
+ fs.mkdirSync(postDir, { recursive: true });
578
+ fs.writeFileSync(path.join(postDir, 'x.md'), '# Unicode Test\n\n## Post\n```\n日本語テスト 🎉\n```');
579
+ const result = parseContent(draftsDir, 'D');
580
+ expect(result[0].post).toBe('日本語テスト 🎉');
581
+ });
582
+ });
583
+ describe('Malformed x.md files', () => {
584
+ it('handles empty x.md file', () => {
585
+ const draftsDir = path.join(testDir, 'drafts');
586
+ const postDir = path.join(draftsDir, 'empty-post');
587
+ fs.mkdirSync(postDir, { recursive: true });
588
+ fs.writeFileSync(path.join(postDir, 'x.md'), '');
589
+ const result = parseContent(draftsDir, 'D');
590
+ expect(result).toHaveLength(1);
591
+ // Falls back to folder name when title is missing
592
+ expect(result[0].title).toBe('empty-post');
593
+ // Falls back to "(no content)" placeholder when post is empty
594
+ expect(result[0].post).toBe('(no content)');
595
+ });
596
+ it('handles x.md with only title', () => {
597
+ const draftsDir = path.join(testDir, 'drafts');
598
+ const postDir = path.join(draftsDir, 'title-only');
599
+ fs.mkdirSync(postDir, { recursive: true });
600
+ fs.writeFileSync(path.join(postDir, 'x.md'), '# Just a Title');
601
+ const result = parseContent(draftsDir, 'D');
602
+ expect(result).toHaveLength(1);
603
+ expect(result[0].title).toBe('Just a Title');
604
+ // Falls back to "(no content)" placeholder when post is empty
605
+ expect(result[0].post).toBe('(no content)');
606
+ });
607
+ it('handles x.md with unclosed code block', () => {
608
+ const draftsDir = path.join(testDir, 'drafts');
609
+ const postDir = path.join(draftsDir, 'unclosed');
610
+ fs.mkdirSync(postDir, { recursive: true });
611
+ fs.writeFileSync(path.join(postDir, 'x.md'), '# Title\n\n## Post\n```\nThis code block never closes');
612
+ const result = parseContent(draftsDir, 'D');
613
+ expect(result).toHaveLength(1);
614
+ // Should still parse something even with unclosed block
615
+ expect(result[0].title).toBe('Title');
616
+ });
617
+ it('handles x.md with multiple post sections (uses first)', () => {
618
+ const draftsDir = path.join(testDir, 'drafts');
619
+ const postDir = path.join(draftsDir, 'multi-post');
620
+ fs.mkdirSync(postDir, { recursive: true });
621
+ fs.writeFileSync(path.join(postDir, 'x.md'), '# Title\n\n## Post\n```\nFirst\n```\n\n## Post\n```\nSecond\n```');
622
+ const result = parseContent(draftsDir, 'D');
623
+ expect(result).toHaveLength(1);
624
+ expect(result[0].post).toBe('First');
625
+ });
626
+ it('handles x.md with status variations', () => {
627
+ const draftsDir = path.join(testDir, 'drafts');
628
+ const postDir = path.join(draftsDir, 'status-test');
629
+ fs.mkdirSync(postDir, { recursive: true });
630
+ fs.writeFileSync(path.join(postDir, 'x.md'), '# Title\n\n## Post\n```\ncontent\n```\n\n## Status\n ready ');
631
+ const result = parseContent(draftsDir, 'D');
632
+ expect(result[0].status).toBe('ready');
633
+ });
634
+ });
635
+ describe('Edge cases in findItem', () => {
636
+ beforeEach(() => {
637
+ const draftsDir = path.join(testDir, 'drafts');
638
+ fs.mkdirSync(path.join(draftsDir, 'test-post'), { recursive: true });
639
+ fs.mkdirSync(path.join(draftsDir, 'test-post-2'), { recursive: true });
640
+ fs.writeFileSync(path.join(draftsDir, 'test-post', 'x.md'), '# Test\n\n## Post\n```\ncontent\n```');
641
+ fs.writeFileSync(path.join(draftsDir, 'test-post-2', 'x.md'), '# Test 2\n\n## Post\n```\ncontent\n```');
642
+ });
643
+ it('returns exact match over partial', () => {
644
+ const item = findItem('test-post', testDir);
645
+ expect(item?.folder).toBe('test-post');
646
+ });
647
+ it('empty query matches first item (partial match behavior)', () => {
648
+ const item = findItem('', testDir);
649
+ // Empty string is a substring of any folder name
650
+ expect(item).not.toBeNull();
651
+ });
652
+ it('folder matching is case-sensitive', () => {
653
+ const item = findItem('TEST-POST', testDir);
654
+ // Case-sensitive: uppercase won't match lowercase folder
655
+ expect(item).toBeNull();
656
+ });
657
+ });
658
+ describe('Large content handling', () => {
659
+ it('handles large post content', () => {
660
+ const draftsDir = path.join(testDir, 'drafts');
661
+ const postDir = path.join(draftsDir, 'large-post');
662
+ fs.mkdirSync(postDir, { recursive: true });
663
+ const largeContent = 'A'.repeat(50000);
664
+ fs.writeFileSync(path.join(postDir, 'x.md'), `# Large Post\n\n## Post\n\`\`\`\n${largeContent}\n\`\`\``);
665
+ const result = parseContent(draftsDir, 'D');
666
+ expect(result).toHaveLength(1);
667
+ expect(result[0].post.length).toBe(50000);
668
+ });
669
+ it('handles many posts in directory', () => {
670
+ const draftsDir = path.join(testDir, 'drafts');
671
+ for (let i = 0; i < 100; i++) {
672
+ const postDir = path.join(draftsDir, `post-${i}`);
673
+ fs.mkdirSync(postDir, { recursive: true });
674
+ fs.writeFileSync(path.join(postDir, 'x.md'), `# Post ${i}\n\n## Post\n\`\`\`\ncontent ${i}\n\`\`\``);
675
+ }
676
+ const result = parseContent(draftsDir, 'D');
677
+ expect(result).toHaveLength(100);
678
+ });
679
+ });
680
+ describe('createDraft edge cases', () => {
681
+ it('throws error for very long title (filesystem limit)', () => {
682
+ const longTitle = 'A'.repeat(500);
683
+ // Most filesystems have 255 char limit for names
684
+ expect(() => createDraft(longTitle, testDir)).toThrow();
685
+ });
686
+ it('handles title with only special characters', () => {
687
+ const item = createDraft('!@#$%^&*()', testDir);
688
+ expect(fs.existsSync(item.path)).toBe(true);
689
+ });
690
+ it('preserves whitespace in title (uses original for header)', () => {
691
+ const item = createDraft(' Trimmed Title ', testDir);
692
+ expect(fs.existsSync(item.path)).toBe(true);
693
+ const content = fs.readFileSync(item.xFile, 'utf-8');
694
+ // Title is stored as-is in the template
695
+ expect(content).toContain('# Trimmed Title ');
696
+ });
697
+ });
698
+ describe('Date parsing edge cases', () => {
699
+ it('handles invalid scheduled date format', () => {
700
+ const draftsDir = path.join(testDir, 'drafts');
701
+ const postDir = path.join(draftsDir, 'bad-date');
702
+ fs.mkdirSync(postDir, { recursive: true });
703
+ fs.writeFileSync(path.join(postDir, 'x.md'), '# Test\n\n## Post\n```\ncontent\n```\n\n## Scheduled\nnot-a-date');
704
+ const result = parseContent(draftsDir, 'D');
705
+ expect(result[0].scheduledAt).toBe('not-a-date');
706
+ });
707
+ it('sorts invalid dates consistently', () => {
708
+ const items = [
709
+ { scheduledAt: 'invalid' },
710
+ { scheduledAt: '2026-01-25 09:00' },
711
+ { scheduledAt: '' },
712
+ ];
713
+ const sorted = sortBySchedule(items);
714
+ expect(sorted[0].scheduledAt).toBe('2026-01-25 09:00');
715
+ });
716
+ });
717
+ describe('Filesystem edge cases', () => {
718
+ it('includes directories without x.md with defaults', () => {
719
+ const draftsDir = path.join(testDir, 'drafts');
720
+ fs.mkdirSync(path.join(draftsDir, 'empty-folder'), { recursive: true });
721
+ fs.mkdirSync(path.join(draftsDir, 'with-x-md'), { recursive: true });
722
+ fs.writeFileSync(path.join(draftsDir, 'with-x-md', 'x.md'), '# Test\n\n## Post\n```\ncontent\n```');
723
+ const result = parseContent(draftsDir, 'D');
724
+ // Both folders are included, even empty ones (folder name as title)
725
+ expect(result).toHaveLength(2);
726
+ const folders = result.map(r => r.folder).sort();
727
+ expect(folders).toEqual(['empty-folder', 'with-x-md']);
728
+ });
729
+ it('handles symlinks gracefully', () => {
730
+ const draftsDir = path.join(testDir, 'drafts');
731
+ fs.mkdirSync(draftsDir, { recursive: true });
732
+ const realDir = path.join(testDir, 'real-post');
733
+ fs.mkdirSync(realDir, { recursive: true });
734
+ fs.writeFileSync(path.join(realDir, 'x.md'), '# Symlinked\n\n## Post\n```\ncontent\n```');
735
+ try {
736
+ fs.symlinkSync(realDir, path.join(draftsDir, 'linked-post'));
737
+ const result = parseContent(draftsDir, 'D');
738
+ expect(result).toHaveLength(1);
739
+ }
740
+ catch {
741
+ // Skip test if symlinks not supported
742
+ }
743
+ });
744
+ });
745
+ });
746
+ });