@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.
- package/README.md +539 -0
- package/dist/chunk-5PM6EQE5.js +151 -0
- package/dist/chunk-5PM6EQE5.js.map +1 -0
- package/dist/chunk-7XU5X6CW.js +1331 -0
- package/dist/chunk-7XU5X6CW.js.map +1 -0
- package/dist/chunk-AAOQHQPU.js +574 -0
- package/dist/chunk-AAOQHQPU.js.map +1 -0
- package/dist/chunk-CF2XXJFF.js +1410 -0
- package/dist/chunk-CF2XXJFF.js.map +1 -0
- package/dist/chunk-CRPZUUDU.js +52 -0
- package/dist/chunk-CRPZUUDU.js.map +1 -0
- package/dist/chunk-CYLDJ3HZ.js +310 -0
- package/dist/chunk-CYLDJ3HZ.js.map +1 -0
- package/dist/chunk-KIKIPIFA.js +1 -0
- package/dist/chunk-KIKIPIFA.js.map +1 -0
- package/dist/chunk-XNTQTTJU.js +145 -0
- package/dist/chunk-XNTQTTJU.js.map +1 -0
- package/dist/client/index.css +2 -0
- package/dist/client/index.css.map +1 -0
- package/dist/client/index.js +375 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/styles.css +584 -0
- package/dist/client/variables.css +304 -0
- package/dist/config/index.d.ts +54 -0
- package/dist/config/index.js +38 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config-BmEdBDo_.d.ts +220 -0
- package/dist/content-BWR52vD-.d.ts +64 -0
- package/dist/discovery/index.d.ts +310 -0
- package/dist/discovery/index.js +38 -0
- package/dist/discovery/index.js.map +1 -0
- package/dist/errors-C0iYiDTv.d.ts +107 -0
- package/dist/filesystem/index.d.ts +1292 -0
- package/dist/filesystem/index.js +203 -0
- package/dist/filesystem/index.js.map +1 -0
- package/dist/image-FP7w5ZIs.d.ts +47 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.js +151 -0
- package/dist/index.js.map +1 -0
- package/dist/loader-55LWCXHA.js +12 -0
- package/dist/loader-55LWCXHA.js.map +1 -0
- package/dist/loader-CrdnaAWR.d.ts +327 -0
- package/dist/server/index.d.ts +357 -0
- package/dist/server/index.js +37 -0
- package/dist/server/index.js.map +1 -0
- package/package.json +94 -0
- package/src/client/App.tsx +900 -0
- package/src/client/components/ConfigPanel/ConfigPanel.css +553 -0
- package/src/client/components/ConfigPanel/ConfigPanel.tsx +396 -0
- package/src/client/components/ConfigPanel/index.ts +6 -0
- package/src/client/components/CreateContentModal/CreateContentModal.css +327 -0
- package/src/client/components/CreateContentModal/CreateContentModal.tsx +216 -0
- package/src/client/components/CreateContentModal/index.ts +7 -0
- package/src/client/components/Editor/Editor.css +885 -0
- package/src/client/components/Editor/Editor.tsx +484 -0
- package/src/client/components/Editor/ImageDialog.css +344 -0
- package/src/client/components/Editor/ImageDialog.tsx +367 -0
- package/src/client/components/Editor/LinkDialog.css +326 -0
- package/src/client/components/Editor/LinkDialog.tsx +332 -0
- package/src/client/components/Editor/index.ts +6 -0
- package/src/client/components/FrontmatterForm/FrontmatterForm.css +468 -0
- package/src/client/components/FrontmatterForm/FrontmatterForm.tsx +914 -0
- package/src/client/components/FrontmatterForm/index.ts +7 -0
- package/src/client/components/Header/Header.css +300 -0
- package/src/client/components/Header/Header.tsx +300 -0
- package/src/client/components/Header/index.ts +7 -0
- package/src/client/components/KeyboardShortcuts/KeyboardShortcuts.css +239 -0
- package/src/client/components/KeyboardShortcuts/KeyboardShortcuts.tsx +151 -0
- package/src/client/components/KeyboardShortcuts/index.ts +6 -0
- package/src/client/components/LazyEditor.tsx +75 -0
- package/src/client/components/LiveRegion/LiveRegion.css +19 -0
- package/src/client/components/LiveRegion/LiveRegion.tsx +60 -0
- package/src/client/components/LiveRegion/index.ts +7 -0
- package/src/client/components/SearchReplace/SearchReplacePanel.css +300 -0
- package/src/client/components/SearchReplace/SearchReplacePanel.tsx +332 -0
- package/src/client/components/SearchReplace/index.ts +7 -0
- package/src/client/components/SelectCollectionModal/SelectCollectionModal.css +308 -0
- package/src/client/components/SelectCollectionModal/SelectCollectionModal.tsx +223 -0
- package/src/client/components/SelectCollectionModal/index.ts +7 -0
- package/src/client/components/Sidebar/Sidebar.css +570 -0
- package/src/client/components/Sidebar/Sidebar.tsx +617 -0
- package/src/client/components/Sidebar/index.ts +7 -0
- package/src/client/components/SkipLink/SkipLink.css +51 -0
- package/src/client/components/SkipLink/SkipLink.tsx +67 -0
- package/src/client/components/SkipLink/index.ts +7 -0
- package/src/client/components/UnsavedChangesModal/UnsavedChangesModal.css +233 -0
- package/src/client/components/UnsavedChangesModal/UnsavedChangesModal.tsx +160 -0
- package/src/client/components/UnsavedChangesModal/index.ts +1 -0
- package/src/client/components/VersionHistory/DiffViewer.css +430 -0
- package/src/client/components/VersionHistory/DiffViewer.tsx +383 -0
- package/src/client/components/VersionHistory/VersionActions.css +318 -0
- package/src/client/components/VersionHistory/VersionActions.tsx +277 -0
- package/src/client/components/VersionHistory/VersionHistoryPanel.css +369 -0
- package/src/client/components/VersionHistory/VersionHistoryPanel.tsx +469 -0
- package/src/client/components/VersionHistory/index.ts +9 -0
- package/src/client/context/ApiContext.tsx +154 -0
- package/src/client/context/ThemeContext.tsx +172 -0
- package/src/client/hooks/useAnnounce.ts +201 -0
- package/src/client/hooks/useApi.ts +374 -0
- package/src/client/hooks/useArrowNavigation.ts +286 -0
- package/src/client/hooks/useAutosave.ts +241 -0
- package/src/client/hooks/useFocusTrap.ts +178 -0
- package/src/client/hooks/useKeyboardShortcuts.ts +203 -0
- package/src/client/hooks/useSearch.ts +206 -0
- package/src/client/hooks/useVersionHistory.ts +451 -0
- package/src/client/index.tsx +70 -0
- package/src/client/styles.css +584 -0
- package/src/client/utils/focus.ts +57 -0
- package/src/client/utils/openInEditor.ts +130 -0
- package/src/client/variables.css +304 -0
- package/src/config/defaults.ts +109 -0
- package/src/config/index.ts +32 -0
- package/src/config/loader.ts +174 -0
- package/src/config/schema.ts +161 -0
- package/src/core/constants.ts +39 -0
- package/src/core/errors.ts +739 -0
- package/src/core/index.ts +11 -0
- package/src/discovery/collections.ts +216 -0
- package/src/discovery/index.ts +33 -0
- package/src/discovery/patterns.ts +702 -0
- package/src/discovery/schema.ts +453 -0
- package/src/filesystem/images.ts +798 -0
- package/src/filesystem/index.ts +107 -0
- package/src/filesystem/reader.ts +452 -0
- package/src/filesystem/version-config.ts +390 -0
- package/src/filesystem/versions.ts +1339 -0
- package/src/filesystem/watcher.ts +226 -0
- package/src/filesystem/writer.ts +540 -0
- package/src/index.ts +61 -0
- package/src/integration.ts +228 -0
- package/src/server/assets.ts +254 -0
- package/src/server/cache.ts +355 -0
- package/src/server/index.ts +33 -0
- package/src/server/middleware.ts +209 -0
- package/src/server/routes.ts +1428 -0
- package/src/types/api.ts +61 -0
- package/src/types/config.ts +134 -0
- package/src/types/content.ts +64 -0
- package/src/types/image.ts +48 -0
- package/src/types/index.ts +58 -0
- 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
|
+
}
|