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,463 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ /**
4
+ * Validate that a string is a valid URL
5
+ * @param str - String to validate
6
+ * @returns True if valid URL, false otherwise
7
+ */
8
+ export function isValidUrl(str) {
9
+ try {
10
+ new URL(str);
11
+ return true;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ /**
18
+ * Validate a query string for path traversal attempts
19
+ * @param query - Query string to validate
20
+ * @throws Error if query contains path traversal patterns
21
+ */
22
+ function validateQuery(query) {
23
+ if (query.includes('..') || query.startsWith('/') || query.includes('\\')) {
24
+ throw new Error('Invalid query: path traversal not allowed');
25
+ }
26
+ }
27
+ /**
28
+ * Get the paths to posts and drafts directories
29
+ * @param cwd - Working directory (defaults to process.cwd())
30
+ * @returns Object with postsDir and draftsDir paths
31
+ */
32
+ export function getContentDirs(cwd = process.cwd()) {
33
+ return {
34
+ postsDir: path.join(cwd, 'posts'),
35
+ draftsDir: path.join(cwd, 'drafts'),
36
+ };
37
+ }
38
+ /**
39
+ * Check if the current directory is a noslop project
40
+ * @param cwd - Working directory to check
41
+ * @returns True if drafts/ or posts/ directory exists
42
+ */
43
+ export function isNoslopProject(cwd = process.cwd()) {
44
+ const { postsDir, draftsDir } = getContentDirs(cwd);
45
+ return fs.existsSync(postsDir) || fs.existsSync(draftsDir);
46
+ }
47
+ /**
48
+ * Parse content from a directory into ContentItem objects
49
+ * @param dir - Directory containing content folders
50
+ * @param prefix - ID prefix ('D' for drafts, 'P' for posts)
51
+ * @returns Array of parsed content items, sorted alphabetically by folder name
52
+ */
53
+ export function parseContent(dir, prefix) {
54
+ if (!fs.existsSync(dir)) {
55
+ return [];
56
+ }
57
+ let folders;
58
+ try {
59
+ folders = fs
60
+ .readdirSync(dir)
61
+ .filter(f => {
62
+ try {
63
+ return fs.statSync(path.join(dir, f)).isDirectory();
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ })
69
+ .filter(f => !f.startsWith('.'))
70
+ .sort();
71
+ }
72
+ catch (error) {
73
+ throw new Error(`Failed to read directory ${dir}: ${error.message}`);
74
+ }
75
+ return folders.map((folder, i) => {
76
+ const id = `${prefix}${String(i + 1).padStart(3, '0')}`;
77
+ const xFile = path.join(dir, folder, 'x.md');
78
+ let post = '', published = '', media = '', title = '', status = 'draft';
79
+ let postedAt = '', scheduledAt = '';
80
+ if (fs.existsSync(xFile)) {
81
+ try {
82
+ const content = fs.readFileSync(xFile, 'utf-8');
83
+ const titleMatch = content.match(/^# (.+)/m);
84
+ const postMatch = content.match(/## Post\n```\n([\s\S]*?)\n```/);
85
+ const pubMatch = content.match(/## Published\n(.+)/);
86
+ const mediaMatch = content.match(/## Media\n(.+)/);
87
+ const postedMatch = content.match(/## Posted\n(.+)/);
88
+ const scheduledMatch = content.match(/## Scheduled\n(.+)/);
89
+ const statusMatch = content.match(/## Status\n(.+)/);
90
+ title = titleMatch ? titleMatch[1].trim() : folder;
91
+ post = postMatch ? postMatch[1].trim() : '(no content)';
92
+ published = pubMatch ? pubMatch[1].trim() : '';
93
+ media = mediaMatch ? mediaMatch[1].trim() : 'None';
94
+ const rawStatus = statusMatch ? statusMatch[1].trim() : 'draft';
95
+ status = rawStatus === 'ready' ? 'ready' : 'draft';
96
+ postedAt = postedMatch ? postedMatch[1].trim() : '';
97
+ scheduledAt = scheduledMatch ? scheduledMatch[1].trim() : '';
98
+ }
99
+ catch {
100
+ // If we can't read the file, use defaults
101
+ title = folder;
102
+ post = '(error reading file)';
103
+ }
104
+ }
105
+ return {
106
+ id,
107
+ folder,
108
+ title,
109
+ post,
110
+ published,
111
+ media,
112
+ postedAt,
113
+ scheduledAt,
114
+ status,
115
+ path: path.join(dir, folder),
116
+ xFile,
117
+ };
118
+ });
119
+ }
120
+ /**
121
+ * Get all drafts from the drafts directory
122
+ * @param cwd - Working directory
123
+ * @returns Array of draft content items
124
+ */
125
+ export function getDrafts(cwd = process.cwd()) {
126
+ const { draftsDir } = getContentDirs(cwd);
127
+ return parseContent(draftsDir, 'D');
128
+ }
129
+ /**
130
+ * Get all posts from the posts directory
131
+ * @param cwd - Working directory
132
+ * @returns Array of post content items
133
+ */
134
+ export function getPosts(cwd = process.cwd()) {
135
+ const { postsDir } = getContentDirs(cwd);
136
+ return parseContent(postsDir, 'P');
137
+ }
138
+ /**
139
+ * Get all content (drafts + posts)
140
+ * @param cwd - Working directory
141
+ * @returns Object with drafts and posts arrays
142
+ */
143
+ export function getAllContent(cwd = process.cwd()) {
144
+ return {
145
+ drafts: getDrafts(cwd),
146
+ posts: getPosts(cwd),
147
+ };
148
+ }
149
+ /**
150
+ * Find a content item by folder name or ID
151
+ * @param query - Folder name (exact or partial) or ID (D001, P001)
152
+ * @param cwd - Working directory
153
+ * @returns Matching content item or null if not found
154
+ * @throws Error if query contains path traversal patterns
155
+ * @example
156
+ * findItem('monday-motivation') // exact folder match
157
+ * findItem('monday') // partial folder match
158
+ * findItem('D001') // ID match
159
+ */
160
+ export function findItem(query, cwd = process.cwd()) {
161
+ validateQuery(query);
162
+ const { drafts, posts } = getAllContent(cwd);
163
+ const all = [...drafts, ...posts];
164
+ // Exact folder match
165
+ let item = all.find(i => i.folder === query);
166
+ if (item) {
167
+ return item;
168
+ }
169
+ // Partial folder match
170
+ item = all.find(i => i.folder.includes(query));
171
+ if (item) {
172
+ return item;
173
+ }
174
+ // ID match (D001, P001)
175
+ item = all.find(i => i.id.toLowerCase() === query.toLowerCase());
176
+ return item || null;
177
+ }
178
+ /**
179
+ * Create a new draft with the given title
180
+ * @param title - Title for the draft (will be slugified for folder name)
181
+ * @param cwd - Working directory
182
+ * @returns The created ContentItem
183
+ * @throws Error if draft creation fails
184
+ */
185
+ export function createDraft(title, cwd = process.cwd()) {
186
+ const { draftsDir } = getContentDirs(cwd);
187
+ // Ensure drafts directory exists
188
+ try {
189
+ if (!fs.existsSync(draftsDir)) {
190
+ fs.mkdirSync(draftsDir, { recursive: true });
191
+ }
192
+ }
193
+ catch (error) {
194
+ throw new Error(`Failed to create drafts directory: ${error.message}`);
195
+ }
196
+ // Generate folder name from title
197
+ const folder = title
198
+ .toLowerCase()
199
+ .replace(/[^a-z0-9]+/g, '-')
200
+ .replace(/^-|-$/g, '');
201
+ const folderPath = path.join(draftsDir, folder);
202
+ const xFile = path.join(folderPath, 'x.md');
203
+ // Create folder and file
204
+ try {
205
+ fs.mkdirSync(folderPath, { recursive: true });
206
+ }
207
+ catch (error) {
208
+ throw new Error(`Failed to create draft folder ${folderPath}: ${error.message}`);
209
+ }
210
+ const content = `# ${title}
211
+
212
+ ## Post
213
+ \`\`\`
214
+
215
+ \`\`\`
216
+
217
+ ## Status
218
+ draft
219
+
220
+ ## Media
221
+ None
222
+
223
+ ## Scheduled
224
+
225
+ ## Posted
226
+
227
+ ## Published
228
+ `;
229
+ try {
230
+ fs.writeFileSync(xFile, content);
231
+ }
232
+ catch (error) {
233
+ throw new Error(`Failed to write draft file ${xFile}: ${error.message}`);
234
+ }
235
+ return {
236
+ id: 'D001',
237
+ folder,
238
+ title,
239
+ post: '',
240
+ published: '',
241
+ media: 'None',
242
+ postedAt: '',
243
+ scheduledAt: '',
244
+ status: 'draft',
245
+ path: folderPath,
246
+ xFile,
247
+ };
248
+ }
249
+ /**
250
+ * Update the status of a content item
251
+ * @param item - Content item to update
252
+ * @param newStatus - New status ('draft' or 'ready')
253
+ * @throws Error if file operations fail
254
+ */
255
+ export function updateStatus(item, newStatus) {
256
+ if (!fs.existsSync(item.xFile)) {
257
+ return;
258
+ }
259
+ let content;
260
+ try {
261
+ content = fs.readFileSync(item.xFile, 'utf-8');
262
+ }
263
+ catch (error) {
264
+ throw new Error(`Failed to read file ${item.xFile}: ${error.message}`);
265
+ }
266
+ if (content.includes('## Status\n')) {
267
+ content = content.replace(/## Status\n.+/, `## Status\n${newStatus}`);
268
+ }
269
+ else {
270
+ content = content.replace(/## Media\n/, `## Status\n${newStatus}\n\n## Media\n`);
271
+ }
272
+ try {
273
+ fs.writeFileSync(item.xFile, content);
274
+ }
275
+ catch (error) {
276
+ throw new Error(`Failed to write file ${item.xFile}: ${error.message}`);
277
+ }
278
+ }
279
+ /**
280
+ * Update the scheduled date of a content item
281
+ * @param item - Content item to update
282
+ * @param datetime - Scheduled datetime string (YYYY-MM-DD HH:MM)
283
+ * @throws Error if file operations fail
284
+ */
285
+ export function updateSchedule(item, datetime) {
286
+ if (!fs.existsSync(item.xFile)) {
287
+ return;
288
+ }
289
+ let content;
290
+ try {
291
+ content = fs.readFileSync(item.xFile, 'utf-8');
292
+ }
293
+ catch (error) {
294
+ throw new Error(`Failed to read file ${item.xFile}: ${error.message}`);
295
+ }
296
+ if (content.includes('## Scheduled\n')) {
297
+ content = content.replace(/## Scheduled\n.*/, `## Scheduled\n${datetime}`);
298
+ }
299
+ else {
300
+ content = content.replace(/## Posted\n/, `## Scheduled\n${datetime}\n\n## Posted\n`);
301
+ }
302
+ try {
303
+ fs.writeFileSync(item.xFile, content);
304
+ }
305
+ catch (error) {
306
+ throw new Error(`Failed to write file ${item.xFile}: ${error.message}`);
307
+ }
308
+ }
309
+ /**
310
+ * Move a draft to the posts folder
311
+ * @param item - Content item to move
312
+ * @param cwd - Working directory
313
+ * @throws Error if move operation fails
314
+ */
315
+ export function moveToPosts(item, cwd = process.cwd()) {
316
+ const { postsDir } = getContentDirs(cwd);
317
+ // Ensure posts directory exists
318
+ try {
319
+ if (!fs.existsSync(postsDir)) {
320
+ fs.mkdirSync(postsDir, { recursive: true });
321
+ }
322
+ }
323
+ catch (error) {
324
+ throw new Error(`Failed to create posts directory: ${error.message}`);
325
+ }
326
+ // Remove status field from file
327
+ if (fs.existsSync(item.xFile)) {
328
+ try {
329
+ let content = fs.readFileSync(item.xFile, 'utf-8');
330
+ content = content.replace(/## Status\n.+\n\n?/, '');
331
+ fs.writeFileSync(item.xFile, content);
332
+ }
333
+ catch (error) {
334
+ throw new Error(`Failed to update file ${item.xFile}: ${error.message}`);
335
+ }
336
+ }
337
+ // Move to posts
338
+ const newPath = path.join(postsDir, item.folder);
339
+ try {
340
+ fs.renameSync(item.path, newPath);
341
+ }
342
+ catch (error) {
343
+ throw new Error(`Failed to move ${item.path} to ${newPath}: ${error.message}`);
344
+ }
345
+ }
346
+ /**
347
+ * Move a post back to the drafts folder
348
+ * @param item - Content item to move
349
+ * @param cwd - Working directory
350
+ * @throws Error if move operation fails
351
+ */
352
+ export function moveToDrafts(item, cwd = process.cwd()) {
353
+ const { draftsDir } = getContentDirs(cwd);
354
+ // Ensure drafts directory exists
355
+ try {
356
+ if (!fs.existsSync(draftsDir)) {
357
+ fs.mkdirSync(draftsDir, { recursive: true });
358
+ }
359
+ }
360
+ catch (error) {
361
+ throw new Error(`Failed to create drafts directory: ${error.message}`);
362
+ }
363
+ // Add status field
364
+ if (fs.existsSync(item.xFile)) {
365
+ try {
366
+ let content = fs.readFileSync(item.xFile, 'utf-8');
367
+ if (!content.includes('## Status\n')) {
368
+ content = content.replace(/## Media\n/, `## Status\nready\n\n## Media\n`);
369
+ }
370
+ else {
371
+ content = content.replace(/## Status\n.+/, '## Status\nready');
372
+ }
373
+ fs.writeFileSync(item.xFile, content);
374
+ }
375
+ catch (error) {
376
+ throw new Error(`Failed to update file ${item.xFile}: ${error.message}`);
377
+ }
378
+ }
379
+ // Move to drafts
380
+ const newPath = path.join(draftsDir, item.folder);
381
+ try {
382
+ fs.renameSync(item.path, newPath);
383
+ }
384
+ catch (error) {
385
+ throw new Error(`Failed to move ${item.path} to ${newPath}: ${error.message}`);
386
+ }
387
+ }
388
+ /**
389
+ * Add a published URL to a post
390
+ * @param item - Content item to update
391
+ * @param url - Published URL
392
+ * @throws Error if URL is invalid or file operations fail
393
+ */
394
+ export function addPublishedUrl(item, url) {
395
+ if (!isValidUrl(url)) {
396
+ throw new Error(`Invalid URL: ${url}`);
397
+ }
398
+ if (!fs.existsSync(item.xFile)) {
399
+ return;
400
+ }
401
+ let content;
402
+ try {
403
+ content = fs.readFileSync(item.xFile, 'utf-8');
404
+ }
405
+ catch (error) {
406
+ throw new Error(`Failed to read file ${item.xFile}: ${error.message}`);
407
+ }
408
+ // Set Posted date = Scheduled date (or now if no scheduled date)
409
+ const scheduledMatch = content.match(/## Scheduled\n(.+)/);
410
+ const postedDate = scheduledMatch ? scheduledMatch[1].trim() : formatDate(new Date());
411
+ if (content.includes('## Posted\n')) {
412
+ content = content.replace(/## Posted\n.*/, `## Posted\n${postedDate}`);
413
+ }
414
+ else {
415
+ content += `\n## Posted\n${postedDate}`;
416
+ }
417
+ if (content.includes('## Published\n')) {
418
+ content = content.replace(/## Published\n.*/, `## Published\n${url}`);
419
+ }
420
+ else {
421
+ content += `\n## Published\n${url}`;
422
+ }
423
+ try {
424
+ fs.writeFileSync(item.xFile, content);
425
+ }
426
+ catch (error) {
427
+ throw new Error(`Failed to write file ${item.xFile}: ${error.message}`);
428
+ }
429
+ }
430
+ /**
431
+ * Delete a draft
432
+ * @param item - Content item to delete
433
+ * @throws Error if delete operation fails
434
+ */
435
+ export function deleteDraft(item) {
436
+ try {
437
+ fs.rmSync(item.path, { recursive: true });
438
+ }
439
+ catch (error) {
440
+ throw new Error(`Failed to delete ${item.path}: ${error.message}`);
441
+ }
442
+ }
443
+ /**
444
+ * Format a date as YYYY-MM-DD HH:MM
445
+ * @param date - Date to format
446
+ * @returns Formatted date string
447
+ */
448
+ export function formatDate(date) {
449
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
450
+ }
451
+ /**
452
+ * Sort content items by scheduled date (oldest first)
453
+ * Unscheduled items are placed at the end
454
+ * @param items - Array of content items to sort
455
+ * @returns New sorted array (does not mutate original)
456
+ */
457
+ export function sortBySchedule(items) {
458
+ return [...items].sort((a, b) => {
459
+ const dateA = a.scheduledAt || '9999-99-99';
460
+ const dateB = b.scheduledAt || '9999-99-99';
461
+ return dateA.localeCompare(dateB);
462
+ });
463
+ }
@@ -0,0 +1 @@
1
+ export {};