@imjp/writenex-astro 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.
Files changed (141) hide show
  1. package/README.md +539 -0
  2. package/dist/chunk-5PM6EQE5.js +151 -0
  3. package/dist/chunk-5PM6EQE5.js.map +1 -0
  4. package/dist/chunk-7XU5X6CW.js +1331 -0
  5. package/dist/chunk-7XU5X6CW.js.map +1 -0
  6. package/dist/chunk-AAOQHQPU.js +574 -0
  7. package/dist/chunk-AAOQHQPU.js.map +1 -0
  8. package/dist/chunk-CF2XXJFF.js +1410 -0
  9. package/dist/chunk-CF2XXJFF.js.map +1 -0
  10. package/dist/chunk-CRPZUUDU.js +52 -0
  11. package/dist/chunk-CRPZUUDU.js.map +1 -0
  12. package/dist/chunk-CYLDJ3HZ.js +310 -0
  13. package/dist/chunk-CYLDJ3HZ.js.map +1 -0
  14. package/dist/chunk-KIKIPIFA.js +1 -0
  15. package/dist/chunk-KIKIPIFA.js.map +1 -0
  16. package/dist/chunk-XNTQTTJU.js +145 -0
  17. package/dist/chunk-XNTQTTJU.js.map +1 -0
  18. package/dist/client/index.css +2 -0
  19. package/dist/client/index.css.map +1 -0
  20. package/dist/client/index.js +375 -0
  21. package/dist/client/index.js.map +1 -0
  22. package/dist/client/styles.css +584 -0
  23. package/dist/client/variables.css +304 -0
  24. package/dist/config/index.d.ts +54 -0
  25. package/dist/config/index.js +38 -0
  26. package/dist/config/index.js.map +1 -0
  27. package/dist/config-BmEdBDo_.d.ts +220 -0
  28. package/dist/content-BWR52vD-.d.ts +64 -0
  29. package/dist/discovery/index.d.ts +310 -0
  30. package/dist/discovery/index.js +38 -0
  31. package/dist/discovery/index.js.map +1 -0
  32. package/dist/errors-C0iYiDTv.d.ts +107 -0
  33. package/dist/filesystem/index.d.ts +1292 -0
  34. package/dist/filesystem/index.js +203 -0
  35. package/dist/filesystem/index.js.map +1 -0
  36. package/dist/image-FP7w5ZIs.d.ts +47 -0
  37. package/dist/index.d.ts +64 -0
  38. package/dist/index.js +151 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/loader-55LWCXHA.js +12 -0
  41. package/dist/loader-55LWCXHA.js.map +1 -0
  42. package/dist/loader-CrdnaAWR.d.ts +327 -0
  43. package/dist/server/index.d.ts +357 -0
  44. package/dist/server/index.js +37 -0
  45. package/dist/server/index.js.map +1 -0
  46. package/package.json +94 -0
  47. package/src/client/App.tsx +900 -0
  48. package/src/client/components/ConfigPanel/ConfigPanel.css +553 -0
  49. package/src/client/components/ConfigPanel/ConfigPanel.tsx +396 -0
  50. package/src/client/components/ConfigPanel/index.ts +6 -0
  51. package/src/client/components/CreateContentModal/CreateContentModal.css +327 -0
  52. package/src/client/components/CreateContentModal/CreateContentModal.tsx +216 -0
  53. package/src/client/components/CreateContentModal/index.ts +7 -0
  54. package/src/client/components/Editor/Editor.css +885 -0
  55. package/src/client/components/Editor/Editor.tsx +484 -0
  56. package/src/client/components/Editor/ImageDialog.css +344 -0
  57. package/src/client/components/Editor/ImageDialog.tsx +367 -0
  58. package/src/client/components/Editor/LinkDialog.css +326 -0
  59. package/src/client/components/Editor/LinkDialog.tsx +332 -0
  60. package/src/client/components/Editor/index.ts +6 -0
  61. package/src/client/components/FrontmatterForm/FrontmatterForm.css +468 -0
  62. package/src/client/components/FrontmatterForm/FrontmatterForm.tsx +914 -0
  63. package/src/client/components/FrontmatterForm/index.ts +7 -0
  64. package/src/client/components/Header/Header.css +300 -0
  65. package/src/client/components/Header/Header.tsx +300 -0
  66. package/src/client/components/Header/index.ts +7 -0
  67. package/src/client/components/KeyboardShortcuts/KeyboardShortcuts.css +239 -0
  68. package/src/client/components/KeyboardShortcuts/KeyboardShortcuts.tsx +151 -0
  69. package/src/client/components/KeyboardShortcuts/index.ts +6 -0
  70. package/src/client/components/LazyEditor.tsx +75 -0
  71. package/src/client/components/LiveRegion/LiveRegion.css +19 -0
  72. package/src/client/components/LiveRegion/LiveRegion.tsx +60 -0
  73. package/src/client/components/LiveRegion/index.ts +7 -0
  74. package/src/client/components/SearchReplace/SearchReplacePanel.css +300 -0
  75. package/src/client/components/SearchReplace/SearchReplacePanel.tsx +332 -0
  76. package/src/client/components/SearchReplace/index.ts +7 -0
  77. package/src/client/components/SelectCollectionModal/SelectCollectionModal.css +308 -0
  78. package/src/client/components/SelectCollectionModal/SelectCollectionModal.tsx +223 -0
  79. package/src/client/components/SelectCollectionModal/index.ts +7 -0
  80. package/src/client/components/Sidebar/Sidebar.css +570 -0
  81. package/src/client/components/Sidebar/Sidebar.tsx +617 -0
  82. package/src/client/components/Sidebar/index.ts +7 -0
  83. package/src/client/components/SkipLink/SkipLink.css +51 -0
  84. package/src/client/components/SkipLink/SkipLink.tsx +67 -0
  85. package/src/client/components/SkipLink/index.ts +7 -0
  86. package/src/client/components/UnsavedChangesModal/UnsavedChangesModal.css +233 -0
  87. package/src/client/components/UnsavedChangesModal/UnsavedChangesModal.tsx +160 -0
  88. package/src/client/components/UnsavedChangesModal/index.ts +1 -0
  89. package/src/client/components/VersionHistory/DiffViewer.css +430 -0
  90. package/src/client/components/VersionHistory/DiffViewer.tsx +383 -0
  91. package/src/client/components/VersionHistory/VersionActions.css +318 -0
  92. package/src/client/components/VersionHistory/VersionActions.tsx +277 -0
  93. package/src/client/components/VersionHistory/VersionHistoryPanel.css +369 -0
  94. package/src/client/components/VersionHistory/VersionHistoryPanel.tsx +469 -0
  95. package/src/client/components/VersionHistory/index.ts +9 -0
  96. package/src/client/context/ApiContext.tsx +154 -0
  97. package/src/client/context/ThemeContext.tsx +172 -0
  98. package/src/client/hooks/useAnnounce.ts +201 -0
  99. package/src/client/hooks/useApi.ts +374 -0
  100. package/src/client/hooks/useArrowNavigation.ts +286 -0
  101. package/src/client/hooks/useAutosave.ts +241 -0
  102. package/src/client/hooks/useFocusTrap.ts +178 -0
  103. package/src/client/hooks/useKeyboardShortcuts.ts +203 -0
  104. package/src/client/hooks/useSearch.ts +206 -0
  105. package/src/client/hooks/useVersionHistory.ts +451 -0
  106. package/src/client/index.tsx +70 -0
  107. package/src/client/styles.css +584 -0
  108. package/src/client/utils/focus.ts +57 -0
  109. package/src/client/utils/openInEditor.ts +130 -0
  110. package/src/client/variables.css +304 -0
  111. package/src/config/defaults.ts +109 -0
  112. package/src/config/index.ts +32 -0
  113. package/src/config/loader.ts +174 -0
  114. package/src/config/schema.ts +161 -0
  115. package/src/core/constants.ts +39 -0
  116. package/src/core/errors.ts +739 -0
  117. package/src/core/index.ts +11 -0
  118. package/src/discovery/collections.ts +216 -0
  119. package/src/discovery/index.ts +33 -0
  120. package/src/discovery/patterns.ts +702 -0
  121. package/src/discovery/schema.ts +453 -0
  122. package/src/filesystem/images.ts +798 -0
  123. package/src/filesystem/index.ts +107 -0
  124. package/src/filesystem/reader.ts +452 -0
  125. package/src/filesystem/version-config.ts +390 -0
  126. package/src/filesystem/versions.ts +1339 -0
  127. package/src/filesystem/watcher.ts +226 -0
  128. package/src/filesystem/writer.ts +540 -0
  129. package/src/index.ts +61 -0
  130. package/src/integration.ts +228 -0
  131. package/src/server/assets.ts +254 -0
  132. package/src/server/cache.ts +355 -0
  133. package/src/server/index.ts +33 -0
  134. package/src/server/middleware.ts +209 -0
  135. package/src/server/routes.ts +1428 -0
  136. package/src/types/api.ts +61 -0
  137. package/src/types/config.ts +134 -0
  138. package/src/types/content.ts +64 -0
  139. package/src/types/image.ts +48 -0
  140. package/src/types/index.ts +58 -0
  141. package/src/types/version.ts +117 -0
@@ -0,0 +1,702 @@
1
+ /**
2
+ * @fileoverview File pattern detection for content collections
3
+ *
4
+ * This module provides functions to detect and work with file naming patterns
5
+ * in Astro content collections.
6
+ *
7
+ * ## Supported Patterns:
8
+ * - `{slug}.md` - Simple slug-based naming
9
+ * - `{date}-{slug}.md` - Date-prefixed naming (2024-01-15-my-post.md)
10
+ * - `{year}/{slug}.md` - Year folder structure
11
+ * - `{year}/{month}/{slug}.md` - Year/month folder structure
12
+ * - `{year}/{month}/{day}/{slug}.md` - Full date folder structure
13
+ * - `{slug}/index.md` - Folder-based with index file
14
+ * - `{category}/{slug}.md` - Category folder structure
15
+ * - `{category}/{slug}/index.md` - Category with folder-based content
16
+ * - `{lang}/{slug}.md` - Language-prefixed content (i18n)
17
+ * - `{lang}/{slug}/index.md` - Language with folder-based content
18
+ *
19
+ * ## Custom Patterns:
20
+ * Developers can configure custom patterns in their collection config.
21
+ * Custom tokens are resolved from frontmatter data or use default values.
22
+ *
23
+ * ## Detection Process:
24
+ * 1. Scan collection directory for all content files
25
+ * 2. Analyze file paths and names for common patterns
26
+ * 3. Score each pattern based on match frequency
27
+ * 4. Return the best matching pattern
28
+ *
29
+ * @module @writenex/astro/discovery/patterns
30
+ */
31
+
32
+ import { readdir } from "node:fs/promises";
33
+ import { existsSync } from "node:fs";
34
+ import { join, extname, relative } from "node:path";
35
+ import { isContentFile } from "@/filesystem/reader";
36
+
37
+ /**
38
+ * Pattern definition with regex and template
39
+ */
40
+ interface PatternDefinition {
41
+ /** Pattern name for identification */
42
+ name: string;
43
+ /** Template string with tokens */
44
+ template: string;
45
+ /** Regex to match against file paths */
46
+ regex: RegExp;
47
+ /** Function to extract tokens from a match */
48
+ extract: (match: RegExpMatchArray, ext: string) => Record<string, string>;
49
+ /** Priority when multiple patterns match (higher = preferred) */
50
+ priority: number;
51
+ }
52
+
53
+ /**
54
+ * Result of pattern detection
55
+ */
56
+ export interface PatternDetectionResult {
57
+ /** The detected pattern template */
58
+ pattern: string;
59
+ /** Confidence score (0-1) */
60
+ confidence: number;
61
+ /** Number of files that matched this pattern */
62
+ matchCount: number;
63
+ /** Total files analyzed */
64
+ totalFiles: number;
65
+ /** Sample matches for debugging */
66
+ samples: Array<{
67
+ filePath: string;
68
+ extracted: Record<string, string>;
69
+ }>;
70
+ }
71
+
72
+ /**
73
+ * All supported pattern definitions
74
+ *
75
+ * Order matters - more specific patterns should come first.
76
+ * Higher priority patterns are preferred when multiple patterns match.
77
+ */
78
+ const PATTERN_DEFINITIONS: PatternDefinition[] = [
79
+ // {year}/{month}/{day}/{slug}.md - Full date folder structure
80
+ {
81
+ name: "year-month-day-slug",
82
+ template: "{year}/{month}/{day}/{slug}.md",
83
+ regex: /^(\d{4})\/(\d{2})\/(\d{2})\/([^/]+)\.(md|mdx)$/,
84
+ extract: (match, ext) => ({
85
+ year: match[1] ?? "",
86
+ month: match[2] ?? "",
87
+ day: match[3] ?? "",
88
+ slug: match[4] ?? "",
89
+ extension: ext,
90
+ }),
91
+ priority: 95,
92
+ },
93
+
94
+ // {year}/{month}/{slug}.md - Year/month nested date structure
95
+ {
96
+ name: "year-month-slug",
97
+ template: "{year}/{month}/{slug}.md",
98
+ regex: /^(\d{4})\/(\d{2})\/([^/]+)\.(md|mdx)$/,
99
+ extract: (match, ext) => ({
100
+ year: match[1] ?? "",
101
+ month: match[2] ?? "",
102
+ slug: match[3] ?? "",
103
+ extension: ext,
104
+ }),
105
+ priority: 90,
106
+ },
107
+
108
+ // {year}/{slug}.md - Year folder structure
109
+ {
110
+ name: "year-slug",
111
+ template: "{year}/{slug}.md",
112
+ regex: /^(\d{4})\/([^/]+)\.(md|mdx)$/,
113
+ extract: (match, ext) => ({
114
+ year: match[1] ?? "",
115
+ slug: match[2] ?? "",
116
+ extension: ext,
117
+ }),
118
+ priority: 85,
119
+ },
120
+
121
+ // {lang}/{slug}/index.md - Language with folder-based content (i18n)
122
+ {
123
+ name: "lang-folder-index",
124
+ template: "{lang}/{slug}/index.md",
125
+ regex: /^([a-z]{2}(?:-[A-Z]{2})?)\/([^/]+)\/index\.(md|mdx)$/,
126
+ extract: (match, ext) => ({
127
+ lang: match[1] ?? "",
128
+ slug: match[2] ?? "",
129
+ extension: ext,
130
+ }),
131
+ priority: 82,
132
+ },
133
+
134
+ // {category}/{slug}/index.md - Category with folder-based content
135
+ {
136
+ name: "category-folder-index",
137
+ template: "{category}/{slug}/index.md",
138
+ regex: /^([^/]+)\/([^/]+)\/index\.(md|mdx)$/,
139
+ extract: (match, ext) => ({
140
+ category: match[1] ?? "",
141
+ slug: match[2] ?? "",
142
+ extension: ext,
143
+ }),
144
+ priority: 80,
145
+ },
146
+
147
+ // {slug}/index.md - Folder-based content
148
+ {
149
+ name: "folder-index",
150
+ template: "{slug}/index.md",
151
+ regex: /^([^/]+)\/index\.(md|mdx)$/,
152
+ extract: (match, ext) => ({
153
+ slug: match[1] ?? "",
154
+ extension: ext,
155
+ }),
156
+ priority: 75,
157
+ },
158
+
159
+ // {date}-{slug}.md - Date-prefixed (ISO format)
160
+ {
161
+ name: "date-slug",
162
+ template: "{date}-{slug}.md",
163
+ regex: /^(\d{4}-\d{2}-\d{2})-(.+)\.(md|mdx)$/,
164
+ extract: (match, ext) => ({
165
+ date: match[1] ?? "",
166
+ slug: match[2] ?? "",
167
+ extension: ext,
168
+ }),
169
+ priority: 70,
170
+ },
171
+
172
+ // {lang}/{slug}.md - Language-prefixed content (i18n)
173
+ // Matches: en/my-post.md, pt-BR/my-post.md
174
+ {
175
+ name: "lang-slug",
176
+ template: "{lang}/{slug}.md",
177
+ regex: /^([a-z]{2}(?:-[A-Z]{2})?)\/([^/]+)\.(md|mdx)$/,
178
+ extract: (match, ext) => ({
179
+ lang: match[1] ?? "",
180
+ slug: match[2] ?? "",
181
+ extension: ext,
182
+ }),
183
+ priority: 60,
184
+ },
185
+
186
+ // {category}/{slug}.md - Category folder (catch-all for non-date/non-lang folders)
187
+ {
188
+ name: "category-slug",
189
+ template: "{category}/{slug}.md",
190
+ regex: /^([^/]+)\/([^/]+)\.(md|mdx)$/,
191
+ extract: (match, ext) => ({
192
+ category: match[1] ?? "",
193
+ slug: match[2] ?? "",
194
+ extension: ext,
195
+ }),
196
+ priority: 50,
197
+ },
198
+
199
+ // {slug}.md - Simple flat structure (default fallback)
200
+ {
201
+ name: "simple-slug",
202
+ template: "{slug}.md",
203
+ regex: /^([^/]+)\.(md|mdx)$/,
204
+ extract: (match, ext) => ({
205
+ slug: match[1] ?? "",
206
+ extension: ext,
207
+ }),
208
+ priority: 10,
209
+ },
210
+ ];
211
+
212
+ /**
213
+ * List all content files in a directory recursively
214
+ *
215
+ * @param dirPath - Directory to scan
216
+ * @returns Array of relative file paths
217
+ */
218
+ async function listContentFiles(dirPath: string): Promise<string[]> {
219
+ const files: string[] = [];
220
+
221
+ if (!existsSync(dirPath)) {
222
+ return files;
223
+ }
224
+
225
+ async function scan(currentPath: string, relativeTo: string): Promise<void> {
226
+ const entries = await readdir(currentPath, { withFileTypes: true });
227
+
228
+ for (const entry of entries) {
229
+ const fullPath = join(currentPath, entry.name);
230
+ const relativePath = relative(relativeTo, fullPath);
231
+
232
+ if (entry.isDirectory()) {
233
+ // Skip hidden and special directories
234
+ if (!entry.name.startsWith(".") && !entry.name.startsWith("_")) {
235
+ await scan(fullPath, relativeTo);
236
+ }
237
+ } else if (entry.isFile() && isContentFile(entry.name)) {
238
+ files.push(relativePath);
239
+ }
240
+ }
241
+ }
242
+
243
+ await scan(dirPath, dirPath);
244
+ return files;
245
+ }
246
+
247
+ /**
248
+ * Try to match a file path against all pattern definitions
249
+ *
250
+ * @param relativePath - Relative path to the content file
251
+ * @returns Matched pattern and extracted tokens, or null
252
+ */
253
+ function matchPattern(
254
+ relativePath: string
255
+ ): { pattern: PatternDefinition; match: RegExpMatchArray } | null {
256
+ // Normalize path separators
257
+ const normalizedPath = relativePath.replace(/\\/g, "/");
258
+
259
+ for (const pattern of PATTERN_DEFINITIONS) {
260
+ const match = normalizedPath.match(pattern.regex);
261
+ if (match) {
262
+ return { pattern, match };
263
+ }
264
+ }
265
+
266
+ return null;
267
+ }
268
+
269
+ /**
270
+ * Detect the file naming pattern used in a collection
271
+ *
272
+ * Analyzes all content files in the collection directory and determines
273
+ * the most likely pattern based on file names and structure.
274
+ *
275
+ * @param collectionPath - Absolute path to the collection directory
276
+ * @returns Pattern detection result with confidence score
277
+ *
278
+ * @example
279
+ * ```typescript
280
+ * const result = await detectFilePattern('/project/src/content/blog');
281
+ * console.log(result.pattern); // "{date}-{slug}.md"
282
+ * console.log(result.confidence); // 0.95
283
+ * ```
284
+ */
285
+ export async function detectFilePattern(
286
+ collectionPath: string
287
+ ): Promise<PatternDetectionResult> {
288
+ const files = await listContentFiles(collectionPath);
289
+
290
+ if (files.length === 0) {
291
+ return {
292
+ pattern: "{slug}.md",
293
+ confidence: 0,
294
+ matchCount: 0,
295
+ totalFiles: 0,
296
+ samples: [],
297
+ };
298
+ }
299
+
300
+ // Count matches for each pattern
301
+ const patternCounts = new Map<
302
+ string,
303
+ {
304
+ pattern: PatternDefinition;
305
+ count: number;
306
+ samples: Array<{ filePath: string; extracted: Record<string, string> }>;
307
+ extension: string;
308
+ }
309
+ >();
310
+
311
+ for (const pattern of PATTERN_DEFINITIONS) {
312
+ patternCounts.set(pattern.name, {
313
+ pattern,
314
+ count: 0,
315
+ samples: [],
316
+ extension: ".md",
317
+ });
318
+ }
319
+
320
+ // Analyze each file
321
+ for (const filePath of files) {
322
+ const result = matchPattern(filePath);
323
+
324
+ if (result) {
325
+ const { pattern, match } = result;
326
+ const entry = patternCounts.get(pattern.name);
327
+
328
+ if (entry) {
329
+ const ext = extname(filePath);
330
+ const extracted = pattern.extract(match, ext);
331
+
332
+ entry.count++;
333
+ entry.extension = ext;
334
+
335
+ // Keep up to 3 samples
336
+ if (entry.samples.length < 3) {
337
+ entry.samples.push({ filePath, extracted });
338
+ }
339
+ }
340
+ }
341
+ }
342
+
343
+ // Find the best matching pattern
344
+ // Consider both match count and pattern priority
345
+ let bestPattern: PatternDetectionResult | null = null;
346
+ let bestScore = -1;
347
+
348
+ for (const [, entry] of patternCounts) {
349
+ if (entry.count === 0) continue;
350
+
351
+ // Score = (match ratio * 100) + priority
352
+ // This ensures high match ratio wins, but priority breaks ties
353
+ const matchRatio = entry.count / files.length;
354
+ const score = matchRatio * 100 + entry.pattern.priority;
355
+
356
+ if (score > bestScore) {
357
+ bestScore = score;
358
+
359
+ // Adjust template for actual extension used
360
+ let template = entry.pattern.template;
361
+ if (entry.extension === ".mdx") {
362
+ template = template.replace(".md", ".mdx");
363
+ }
364
+
365
+ bestPattern = {
366
+ pattern: template,
367
+ confidence: matchRatio,
368
+ matchCount: entry.count,
369
+ totalFiles: files.length,
370
+ samples: entry.samples,
371
+ };
372
+ }
373
+ }
374
+
375
+ // Return best pattern or default
376
+ return (
377
+ bestPattern ?? {
378
+ pattern: "{slug}.md",
379
+ confidence: 0,
380
+ matchCount: 0,
381
+ totalFiles: files.length,
382
+ samples: [],
383
+ }
384
+ );
385
+ }
386
+
387
+ /**
388
+ * Generate a file path from a pattern and tokens
389
+ *
390
+ * @param pattern - Pattern template (e.g., "{date}-{slug}.md")
391
+ * @param tokens - Token values to substitute
392
+ * @returns Generated file path
393
+ *
394
+ * @example
395
+ * ```typescript
396
+ * const path = generatePathFromPattern(
397
+ * "{date}-{slug}.md",
398
+ * { date: "2024-01-15", slug: "my-post" }
399
+ * );
400
+ * // Returns: "2024-01-15-my-post.md"
401
+ * ```
402
+ */
403
+ export function generatePathFromPattern(
404
+ pattern: string,
405
+ tokens: Record<string, string>
406
+ ): string {
407
+ let result = pattern;
408
+
409
+ for (const [key, value] of Object.entries(tokens)) {
410
+ result = result.replace(`{${key}}`, value);
411
+ }
412
+
413
+ return result;
414
+ }
415
+
416
+ /**
417
+ * Parse a pattern template to extract token names
418
+ *
419
+ * @param pattern - Pattern template
420
+ * @returns Array of token names
421
+ *
422
+ * @example
423
+ * ```typescript
424
+ * const tokens = parsePatternTokens("{year}/{month}/{slug}.md");
425
+ * // Returns: ["year", "month", "slug"]
426
+ * ```
427
+ */
428
+ export function parsePatternTokens(pattern: string): string[] {
429
+ const tokenRegex = /\{([^}]+)\}/g;
430
+ const tokens: string[] = [];
431
+ let match;
432
+
433
+ while ((match = tokenRegex.exec(pattern)) !== null) {
434
+ if (match[1]) {
435
+ tokens.push(match[1]);
436
+ }
437
+ }
438
+
439
+ return tokens;
440
+ }
441
+
442
+ /**
443
+ * Validate that a pattern has all required tokens
444
+ *
445
+ * @param pattern - Pattern template
446
+ * @param requiredTokens - Required token names
447
+ * @returns True if all required tokens are present
448
+ */
449
+ export function validatePattern(
450
+ pattern: string,
451
+ requiredTokens: string[] = ["slug"]
452
+ ): boolean {
453
+ const tokens = parsePatternTokens(pattern);
454
+ return requiredTokens.every((req) => tokens.includes(req));
455
+ }
456
+
457
+ /**
458
+ * Get the default extension for a pattern
459
+ *
460
+ * @param pattern - Pattern template
461
+ * @returns The file extension (.md or .mdx)
462
+ */
463
+ export function getPatternExtension(pattern: string): string {
464
+ if (pattern.endsWith(".mdx")) {
465
+ return ".mdx";
466
+ }
467
+ return ".md";
468
+ }
469
+
470
+ /**
471
+ * Known token types and their default value generators
472
+ */
473
+ type TokenResolver = (
474
+ frontmatter: Record<string, unknown>,
475
+ slug: string
476
+ ) => string;
477
+
478
+ const TOKEN_RESOLVERS: Record<string, TokenResolver> = {
479
+ // Core tokens
480
+ slug: (_fm, slug) => slug,
481
+
482
+ // Date tokens - from pubDate or current date
483
+ date: (fm) => {
484
+ const pubDate = resolveDateFromFrontmatter(fm);
485
+ return pubDate.toISOString().split("T")[0] ?? "";
486
+ },
487
+ year: (fm) => {
488
+ const pubDate = resolveDateFromFrontmatter(fm);
489
+ return pubDate.getFullYear().toString();
490
+ },
491
+ month: (fm) => {
492
+ const pubDate = resolveDateFromFrontmatter(fm);
493
+ return (pubDate.getMonth() + 1).toString().padStart(2, "0");
494
+ },
495
+ day: (fm) => {
496
+ const pubDate = resolveDateFromFrontmatter(fm);
497
+ return pubDate.getDate().toString().padStart(2, "0");
498
+ },
499
+
500
+ // i18n tokens
501
+ lang: (fm) => {
502
+ if (typeof fm.lang === "string") return fm.lang;
503
+ if (typeof fm.language === "string") return fm.language;
504
+ if (typeof fm.locale === "string") return fm.locale;
505
+ return "en"; // Default to English
506
+ },
507
+
508
+ // Organization tokens
509
+ category: (fm) => {
510
+ if (typeof fm.category === "string") return fm.category;
511
+ if (Array.isArray(fm.categories) && typeof fm.categories[0] === "string") {
512
+ return fm.categories[0];
513
+ }
514
+ return "uncategorized";
515
+ },
516
+ author: (fm) => {
517
+ if (typeof fm.author === "string") return slugifyValue(fm.author);
518
+ if (
519
+ typeof fm.author === "object" &&
520
+ fm.author !== null &&
521
+ "name" in fm.author
522
+ ) {
523
+ return slugifyValue(String(fm.author.name));
524
+ }
525
+ return "anonymous";
526
+ },
527
+ type: (fm) => {
528
+ if (typeof fm.type === "string") return fm.type;
529
+ if (typeof fm.contentType === "string") return fm.contentType;
530
+ return "post";
531
+ },
532
+ status: (fm) => {
533
+ if (typeof fm.status === "string") return fm.status;
534
+ if (fm.draft === true) return "draft";
535
+ return "published";
536
+ },
537
+ series: (fm) => {
538
+ if (typeof fm.series === "string") return slugifyValue(fm.series);
539
+ return "";
540
+ },
541
+ collection: (fm) => {
542
+ if (typeof fm.collection === "string") return fm.collection;
543
+ return "";
544
+ },
545
+ };
546
+
547
+ /**
548
+ * Resolve a date from frontmatter
549
+ *
550
+ * Checks common date field names: pubDate, date, publishDate, createdAt
551
+ *
552
+ * @param frontmatter - Frontmatter data
553
+ * @returns Resolved Date object
554
+ */
555
+ function resolveDateFromFrontmatter(
556
+ frontmatter: Record<string, unknown>
557
+ ): Date {
558
+ const dateFields = ["pubDate", "date", "publishDate", "createdAt", "created"];
559
+
560
+ for (const field of dateFields) {
561
+ const value = frontmatter[field];
562
+ if (value instanceof Date) return value;
563
+ if (typeof value === "string") {
564
+ const parsed = new Date(value);
565
+ if (!isNaN(parsed.getTime())) return parsed;
566
+ }
567
+ }
568
+
569
+ return new Date();
570
+ }
571
+
572
+ /**
573
+ * Convert a string to a URL-safe slug
574
+ *
575
+ * @param value - String to slugify
576
+ * @returns URL-safe slug
577
+ */
578
+ function slugifyValue(value: string): string {
579
+ return value
580
+ .toLowerCase()
581
+ .trim()
582
+ .replace(/[^\w\s-]/g, "")
583
+ .replace(/[\s_-]+/g, "-")
584
+ .replace(/^-+|-+$/g, "");
585
+ }
586
+
587
+ /**
588
+ * Options for resolving pattern tokens
589
+ */
590
+ export interface ResolveTokensOptions {
591
+ /** The content slug */
592
+ slug: string;
593
+ /** Frontmatter data for resolving dynamic tokens */
594
+ frontmatter?: Record<string, unknown>;
595
+ /** Custom token values (override automatic resolution) */
596
+ customTokens?: Record<string, string>;
597
+ }
598
+
599
+ /**
600
+ * Resolve all tokens in a pattern to their values
601
+ *
602
+ * Token resolution priority:
603
+ * 1. Custom tokens (explicitly provided)
604
+ * 2. Known token resolvers (date, year, month, etc.)
605
+ * 3. Frontmatter values (for custom tokens)
606
+ * 4. Empty string (fallback)
607
+ *
608
+ * @param pattern - Pattern template with tokens
609
+ * @param options - Resolution options
610
+ * @returns Record of token names to resolved values
611
+ *
612
+ * @example
613
+ * ```typescript
614
+ * const tokens = resolvePatternTokens("{year}/{month}/{slug}.md", {
615
+ * slug: "my-post",
616
+ * frontmatter: { pubDate: new Date("2024-06-15") }
617
+ * });
618
+ * // Returns: { year: "2024", month: "06", slug: "my-post" }
619
+ * ```
620
+ */
621
+ export function resolvePatternTokens(
622
+ pattern: string,
623
+ options: ResolveTokensOptions
624
+ ): Record<string, string> {
625
+ const { slug, frontmatter = {}, customTokens = {} } = options;
626
+ const tokenNames = parsePatternTokens(pattern);
627
+ const resolved: Record<string, string> = {};
628
+
629
+ for (const tokenName of tokenNames) {
630
+ // Priority 1: Custom tokens
631
+ if (tokenName in customTokens) {
632
+ resolved[tokenName] = customTokens[tokenName] ?? "";
633
+ continue;
634
+ }
635
+
636
+ // Priority 2: Known token resolvers
637
+ const resolver = TOKEN_RESOLVERS[tokenName];
638
+ if (resolver) {
639
+ resolved[tokenName] = resolver(frontmatter, slug);
640
+ continue;
641
+ }
642
+
643
+ // Priority 3: Direct frontmatter value
644
+ const fmValue = frontmatter[tokenName];
645
+ if (typeof fmValue === "string") {
646
+ resolved[tokenName] = slugifyValue(fmValue);
647
+ continue;
648
+ }
649
+ if (typeof fmValue === "number") {
650
+ resolved[tokenName] = fmValue.toString();
651
+ continue;
652
+ }
653
+
654
+ // Priority 4: Fallback to empty string
655
+ resolved[tokenName] = "";
656
+ }
657
+
658
+ return resolved;
659
+ }
660
+
661
+ /**
662
+ * Check if a pattern is valid for content creation
663
+ *
664
+ * A pattern is valid if:
665
+ * - It contains the {slug} token (required)
666
+ * - It ends with .md or .mdx
667
+ * - All tokens can be resolved
668
+ *
669
+ * @param pattern - Pattern template to validate
670
+ * @returns Validation result with error message if invalid
671
+ */
672
+ export function isValidPattern(pattern: string): {
673
+ valid: boolean;
674
+ error?: string;
675
+ } {
676
+ // Must contain slug token
677
+ if (!pattern.includes("{slug}")) {
678
+ return { valid: false, error: "Pattern must contain {slug} token" };
679
+ }
680
+
681
+ // Must end with .md or .mdx
682
+ if (!pattern.endsWith(".md") && !pattern.endsWith(".mdx")) {
683
+ return { valid: false, error: "Pattern must end with .md or .mdx" };
684
+ }
685
+
686
+ // Check for unclosed tokens
687
+ const unclosed = pattern.match(/\{[^}]*$/);
688
+ if (unclosed) {
689
+ return { valid: false, error: "Pattern contains unclosed token" };
690
+ }
691
+
692
+ return { valid: true };
693
+ }
694
+
695
+ /**
696
+ * Get list of all supported token names
697
+ *
698
+ * @returns Array of supported token names
699
+ */
700
+ export function getSupportedTokens(): string[] {
701
+ return Object.keys(TOKEN_RESOLVERS);
702
+ }