tangled
alpha
login
or
join now
apoena.dev
/
remanso-cli
0
fork
atom
this repo has no description
0
fork
atom
overview
issues
pulls
pipelines
chore: resolved action items from issue #3
stevedylan.dev
1 month ago
b8356ed0
4023a040
+156
-12
6 changed files
expand all
collapse all
unified
split
packages
cli
src
commands
publish.ts
sync.ts
lib
atproto.ts
config.ts
markdown.ts
types.ts
+7
-1
packages/cli/src/commands/publish.ts
···
87
87
// Scan for posts
88
88
const s = spinner();
89
89
s.start("Scanning for posts...");
90
90
-
const posts = await scanContentDirectory(contentDir, config.frontmatter, config.ignore);
90
90
+
const posts = await scanContentDirectory(contentDir, {
91
91
+
frontmatterMapping: config.frontmatter,
92
92
+
ignorePatterns: config.ignore,
93
93
+
slugSource: config.slugSource,
94
94
+
slugField: config.slugField,
95
95
+
removeIndexFromSlug: config.removeIndexFromSlug,
96
96
+
});
91
97
s.stop(`Found ${posts.length} posts`);
92
98
93
99
// Determine which posts need publishing
+10
-3
packages/cli/src/commands/sync.ts
···
90
90
91
91
// Scan local posts
92
92
s.start("Scanning local content...");
93
93
-
const localPosts = await scanContentDirectory(contentDir, config.frontmatter);
93
93
+
const localPosts = await scanContentDirectory(contentDir, {
94
94
+
frontmatterMapping: config.frontmatter,
95
95
+
ignorePatterns: config.ignore,
96
96
+
slugSource: config.slugSource,
97
97
+
slugField: config.slugField,
98
98
+
removeIndexFromSlug: config.removeIndexFromSlug,
99
99
+
});
94
100
s.stop(`Found ${localPosts.length} local posts`);
95
101
96
102
// Build a map of path -> local post for matching
97
97
-
// Document path is like /posts/my-post-slug
103
103
+
// Document path is like /posts/my-post-slug (or custom pathPrefix)
104
104
+
const pathPrefix = config.pathPrefix || "/posts";
98
105
const postsByPath = new Map<string, typeof localPosts[0]>();
99
106
for (const post of localPosts) {
100
100
-
const postPath = `/posts/${post.slug}`;
107
107
+
const postPath = `${pathPrefix}/${post.slug}`;
101
108
postsByPath.set(postPath, post);
102
109
}
103
110
+25
-2
packages/cli/src/lib/atproto.ts
···
171
171
): Promise<string> {
172
172
const pathPrefix = config.pathPrefix || "/posts";
173
173
const postPath = `${pathPrefix}/${post.slug}`;
174
174
-
const textContent = stripMarkdownForText(post.content);
175
174
const publishDate = new Date(post.frontmatter.publishDate);
176
175
176
176
+
// Determine textContent: use configured field from frontmatter, or fallback to markdown body
177
177
+
let textContent: string;
178
178
+
if (config.textContentField && post.rawFrontmatter?.[config.textContentField]) {
179
179
+
textContent = String(post.rawFrontmatter[config.textContentField]);
180
180
+
} else {
181
181
+
textContent = stripMarkdownForText(post.content);
182
182
+
}
183
183
+
177
184
const record: Record<string, unknown> = {
178
185
$type: "site.standard.document",
179
186
title: post.frontmatter.title,
···
183
190
publishedAt: publishDate.toISOString(),
184
191
canonicalUrl: `${config.siteUrl}${postPath}`,
185
192
};
193
193
+
194
194
+
if (post.frontmatter.description) {
195
195
+
record.description = post.frontmatter.description;
196
196
+
}
186
197
187
198
if (coverImage) {
188
199
record.coverImage = coverImage;
···
219
230
220
231
const pathPrefix = config.pathPrefix || "/posts";
221
232
const postPath = `${pathPrefix}/${post.slug}`;
222
222
-
const textContent = stripMarkdownForText(post.content);
223
233
const publishDate = new Date(post.frontmatter.publishDate);
224
234
235
235
+
// Determine textContent: use configured field from frontmatter, or fallback to markdown body
236
236
+
let textContent: string;
237
237
+
if (config.textContentField && post.rawFrontmatter?.[config.textContentField]) {
238
238
+
textContent = String(post.rawFrontmatter[config.textContentField]);
239
239
+
} else {
240
240
+
textContent = stripMarkdownForText(post.content);
241
241
+
}
242
242
+
225
243
const record: Record<string, unknown> = {
226
244
$type: "site.standard.document",
227
245
title: post.frontmatter.title,
···
231
249
publishedAt: publishDate.toISOString(),
232
250
canonicalUrl: `${config.siteUrl}${postPath}`,
233
251
};
252
252
+
253
253
+
if (post.frontmatter.description) {
254
254
+
record.description = post.frontmatter.description;
255
255
+
}
234
256
235
257
if (coverImage) {
236
258
record.coverImage = coverImage;
···
266
288
textContent: string;
267
289
publishedAt: string;
268
290
canonicalUrl?: string;
291
291
+
description?: string;
269
292
coverImage?: BlobObject;
270
293
tags?: string[];
271
294
location?: string;
+20
packages/cli/src/lib/config.ts
···
76
76
pdsUrl?: string;
77
77
frontmatter?: FrontmatterMapping;
78
78
ignore?: string[];
79
79
+
slugSource?: "filename" | "path" | "frontmatter";
80
80
+
slugField?: string;
81
81
+
removeIndexFromSlug?: boolean;
82
82
+
textContentField?: string;
79
83
}): string {
80
84
const config: Record<string, unknown> = {
81
85
siteUrl: options.siteUrl,
···
110
114
111
115
if (options.ignore && options.ignore.length > 0) {
112
116
config.ignore = options.ignore;
117
117
+
}
118
118
+
119
119
+
if (options.slugSource && options.slugSource !== "filename") {
120
120
+
config.slugSource = options.slugSource;
121
121
+
}
122
122
+
123
123
+
if (options.slugField && options.slugField !== "slug") {
124
124
+
config.slugField = options.slugField;
125
125
+
}
126
126
+
127
127
+
if (options.removeIndexFromSlug) {
128
128
+
config.removeIndexFromSlug = options.removeIndexFromSlug;
129
129
+
}
130
130
+
131
131
+
if (options.textContentField) {
132
132
+
config.textContentField = options.textContentField;
113
133
}
114
134
115
135
return JSON.stringify(config, null, 2);
+89
-6
packages/cli/src/lib/markdown.ts
···
7
7
export function parseFrontmatter(content: string, mapping?: FrontmatterMapping): {
8
8
frontmatter: PostFrontmatter;
9
9
body: string;
10
10
+
rawFrontmatter: Record<string, unknown>;
10
11
} {
11
12
// Support multiple frontmatter delimiters:
12
13
// --- (YAML) - Jekyll, Astro, most SSGs
···
102
103
// Always preserve atUri (internal field)
103
104
frontmatter.atUri = raw.atUri;
104
105
105
105
-
return { frontmatter: frontmatter as unknown as PostFrontmatter, body };
106
106
+
return { frontmatter: frontmatter as unknown as PostFrontmatter, body, rawFrontmatter: raw };
106
107
}
107
108
108
109
export function getSlugFromFilename(filename: string): string {
···
112
113
.replace(/\s+/g, "-");
113
114
}
114
115
116
116
+
export interface SlugOptions {
117
117
+
slugSource?: "filename" | "path" | "frontmatter";
118
118
+
slugField?: string;
119
119
+
removeIndexFromSlug?: boolean;
120
120
+
}
121
121
+
122
122
+
export function getSlugFromOptions(
123
123
+
relativePath: string,
124
124
+
rawFrontmatter: Record<string, unknown>,
125
125
+
options: SlugOptions = {}
126
126
+
): string {
127
127
+
const { slugSource = "filename", slugField = "slug", removeIndexFromSlug = false } = options;
128
128
+
129
129
+
let slug: string;
130
130
+
131
131
+
switch (slugSource) {
132
132
+
case "path":
133
133
+
// Use full relative path without extension
134
134
+
slug = relativePath
135
135
+
.replace(/\.mdx?$/, "")
136
136
+
.toLowerCase()
137
137
+
.replace(/\s+/g, "-");
138
138
+
break;
139
139
+
140
140
+
case "frontmatter":
141
141
+
// Use frontmatter field (slug or url)
142
142
+
const frontmatterValue = rawFrontmatter[slugField] || rawFrontmatter.slug || rawFrontmatter.url;
143
143
+
if (frontmatterValue && typeof frontmatterValue === "string") {
144
144
+
// Remove leading slash if present
145
145
+
slug = frontmatterValue.replace(/^\//, "").toLowerCase().replace(/\s+/g, "-");
146
146
+
} else {
147
147
+
// Fallback to filename if frontmatter field not found
148
148
+
slug = getSlugFromFilename(path.basename(relativePath));
149
149
+
}
150
150
+
break;
151
151
+
152
152
+
case "filename":
153
153
+
default:
154
154
+
slug = getSlugFromFilename(path.basename(relativePath));
155
155
+
break;
156
156
+
}
157
157
+
158
158
+
// Remove /index or /_index suffix if configured
159
159
+
if (removeIndexFromSlug) {
160
160
+
slug = slug.replace(/\/_?index$/, "");
161
161
+
}
162
162
+
163
163
+
return slug;
164
164
+
}
165
165
+
115
166
export async function getContentHash(content: string): Promise<string> {
116
167
const encoder = new TextEncoder();
117
168
const data = encoder.encode(content);
···
129
180
return false;
130
181
}
131
182
183
183
+
export interface ScanOptions {
184
184
+
frontmatterMapping?: FrontmatterMapping;
185
185
+
ignorePatterns?: string[];
186
186
+
slugSource?: "filename" | "path" | "frontmatter";
187
187
+
slugField?: string;
188
188
+
removeIndexFromSlug?: boolean;
189
189
+
}
190
190
+
132
191
export async function scanContentDirectory(
133
192
contentDir: string,
134
134
-
frontmatterMapping?: FrontmatterMapping,
193
193
+
frontmatterMappingOrOptions?: FrontmatterMapping | ScanOptions,
135
194
ignorePatterns: string[] = []
136
195
): Promise<BlogPost[]> {
196
196
+
// Handle both old signature (frontmatterMapping, ignorePatterns) and new signature (options)
197
197
+
let options: ScanOptions;
198
198
+
if (frontmatterMappingOrOptions && ('slugSource' in frontmatterMappingOrOptions || 'frontmatterMapping' in frontmatterMappingOrOptions || 'ignorePatterns' in frontmatterMappingOrOptions)) {
199
199
+
options = frontmatterMappingOrOptions as ScanOptions;
200
200
+
} else {
201
201
+
// Old signature: (contentDir, frontmatterMapping?, ignorePatterns?)
202
202
+
options = {
203
203
+
frontmatterMapping: frontmatterMappingOrOptions as FrontmatterMapping | undefined,
204
204
+
ignorePatterns,
205
205
+
};
206
206
+
}
207
207
+
208
208
+
const {
209
209
+
frontmatterMapping,
210
210
+
ignorePatterns: ignore = [],
211
211
+
slugSource,
212
212
+
slugField,
213
213
+
removeIndexFromSlug,
214
214
+
} = options;
215
215
+
137
216
const patterns = ["**/*.md", "**/*.mdx"];
138
217
const posts: BlogPost[] = [];
139
218
···
145
224
146
225
for (const relativePath of files) {
147
226
// Skip files matching ignore patterns
148
148
-
if (shouldIgnore(relativePath, ignorePatterns)) {
227
227
+
if (shouldIgnore(relativePath, ignore)) {
149
228
continue;
150
229
}
151
230
···
153
232
const rawContent = await fs.readFile(filePath, "utf-8");
154
233
155
234
try {
156
156
-
const { frontmatter, body } = parseFrontmatter(rawContent, frontmatterMapping);
157
157
-
const filename = path.basename(relativePath);
158
158
-
const slug = getSlugFromFilename(filename);
235
235
+
const { frontmatter, body, rawFrontmatter } = parseFrontmatter(rawContent, frontmatterMapping);
236
236
+
const slug = getSlugFromOptions(relativePath, rawFrontmatter, {
237
237
+
slugSource,
238
238
+
slugField,
239
239
+
removeIndexFromSlug,
240
240
+
});
159
241
160
242
posts.push({
161
243
filePath,
···
163
245
frontmatter,
164
246
content: body,
165
247
rawContent,
248
248
+
rawFrontmatter,
166
249
});
167
250
} catch (error) {
168
251
console.error(`Error parsing ${relativePath}:`, error);
+5
packages/cli/src/lib/types.ts
···
18
18
identity?: string; // Which stored identity to use (matches identifier)
19
19
frontmatter?: FrontmatterMapping; // Custom frontmatter field mappings
20
20
ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"])
21
21
+
slugSource?: "filename" | "path" | "frontmatter"; // How to generate slugs (default: "filename")
22
22
+
slugField?: string; // Frontmatter field to use when slugSource is "frontmatter" (default: "slug")
23
23
+
removeIndexFromSlug?: boolean; // Remove "/index" or "/_index" suffix from paths (default: false)
24
24
+
textContentField?: string; // Frontmatter field to use for textContent instead of markdown body
21
25
}
22
26
23
27
export interface Credentials {
···
41
45
frontmatter: PostFrontmatter;
42
46
content: string;
43
47
rawContent: string;
48
48
+
rawFrontmatter: Record<string, unknown>; // For accessing custom fields like textContentField
44
49
}
45
50
46
51
export interface BlobRef {