chore: refactored to use fallback approach if frontmatter.slugField is provided or not 4994ddfe
Steve · 2026-02-01 09:14 6 file(s) · +40 −52
docs/docs/pages/config.mdx +16 −0
14 14
| `pdsUrl` | `string` | No | `"https://bsky.social"` | PDS server URL, generated automatically |
15 15
| `identity` | `string` | No | - | Which stored identity to use |
16 16
| `frontmatter` | `object` | No | - | Custom frontmatter field mappings |
17 +
| `frontmatter.slugField` | `string` | No | - | Frontmatter field to use for slug (defaults to filepath) |
17 18
| `ignore` | `string[]` | No | - | Glob patterns for files to ignore |
19 +
| `removeIndexFromSlug` | `boolean` | No | `false` | Remove `/index` or `/_index` suffix from slugs |
18 20
| `bluesky` | `object` | No | - | Bluesky posting configuration |
19 21
| `bluesky.enabled` | `boolean` | No | `false` | Post to Bluesky when publishing documents |
20 22
| `bluesky.maxAgeDays` | `number` | No | `30` | Only post documents published within this many days |
79 81
  }
80 82
}
81 83
```
84 +
85 +
### Slug Configuration
86 +
87 +
By default, slugs are generated from the filepath (e.g., `posts/my-post.md` becomes `posts/my-post`). To use a frontmatter field instead:
88 +
89 +
```json
90 +
{
91 +
  "frontmatter": {
92 +
    "slugField": "url"
93 +
  }
94 +
}
95 +
```
96 +
97 +
If the frontmatter field is not found, it falls back to the filepath.
82 98
83 99
### Ignoring Files
84 100
packages/cli/src/commands/publish.ts +1 −2
108 108
		const posts = await scanContentDirectory(contentDir, {
109 109
			frontmatterMapping: config.frontmatter,
110 110
			ignorePatterns: config.ignore,
111 -
			slugSource: config.slugSource,
112 -
			slugField: config.slugField,
111 +
			slugField: config.frontmatter?.slugField,
113 112
			removeIndexFromSlug: config.removeIndexFromSlug,
114 113
		});
115 114
		s.stop(`Found ${posts.length} posts`);
packages/cli/src/commands/sync.ts +1 −2
103 103
		const localPosts = await scanContentDirectory(contentDir, {
104 104
			frontmatterMapping: config.frontmatter,
105 105
			ignorePatterns: config.ignore,
106 -
			slugSource: config.slugSource,
107 -
			slugField: config.slugField,
106 +
			slugField: config.frontmatter?.slugField,
108 107
			removeIndexFromSlug: config.removeIndexFromSlug,
109 108
		});
110 109
		s.stop(`Found ${localPosts.length} local posts`);
packages/cli/src/lib/config.ts +0 −10
81 81
	pdsUrl?: string;
82 82
	frontmatter?: FrontmatterMapping;
83 83
	ignore?: string[];
84 -
	slugSource?: "filename" | "path" | "frontmatter";
85 -
	slugField?: string;
86 84
	removeIndexFromSlug?: boolean;
87 85
	textContentField?: string;
88 86
	bluesky?: BlueskyConfig;
120 118
121 119
	if (options.ignore && options.ignore.length > 0) {
122 120
		config.ignore = options.ignore;
123 -
	}
124 -
125 -
	if (options.slugSource && options.slugSource !== "filename") {
126 -
		config.slugSource = options.slugSource;
127 -
	}
128 -
129 -
	if (options.slugField && options.slugField !== "slug") {
130 -
		config.slugField = options.slugField;
131 121
	}
132 122
133 123
	if (options.removeIndexFromSlug) {
packages/cli/src/lib/markdown.ts +21 −36
176 176
}
177 177
178 178
export interface SlugOptions {
179 -
	slugSource?: "filename" | "path" | "frontmatter";
180 179
	slugField?: string;
181 180
	removeIndexFromSlug?: boolean;
182 181
}
186 185
	rawFrontmatter: Record<string, unknown>,
187 186
	options: SlugOptions = {},
188 187
): string {
189 -
	const {
190 -
		slugSource = "filename",
191 -
		slugField = "slug",
192 -
		removeIndexFromSlug = false,
193 -
	} = options;
188 +
	const { slugField, removeIndexFromSlug = false } = options;
194 189
195 190
	let slug: string;
196 191
197 -
	switch (slugSource) {
198 -
		case "path":
199 -
			// Use full relative path without extension
192 +
	// If slugField is set, try to get the value from frontmatter
193 +
	if (slugField) {
194 +
		const frontmatterValue = rawFrontmatter[slugField];
195 +
		if (frontmatterValue && typeof frontmatterValue === "string") {
196 +
			// Remove leading slash if present
197 +
			slug = frontmatterValue
198 +
				.replace(/^\//, "")
199 +
				.toLowerCase()
200 +
				.replace(/\s+/g, "-");
201 +
		} else {
202 +
			// Fallback to filepath if frontmatter field not found
200 203
			slug = relativePath
201 204
				.replace(/\.mdx?$/, "")
202 205
				.toLowerCase()
203 206
				.replace(/\s+/g, "-");
204 -
			break;
205 -
206 -
		case "frontmatter": {
207 -
			// Use frontmatter field (slug or url)
208 -
			const frontmatterValue =
209 -
				rawFrontmatter[slugField] || rawFrontmatter.slug || rawFrontmatter.url;
210 -
			if (frontmatterValue && typeof frontmatterValue === "string") {
211 -
				// Remove leading slash if present
212 -
				slug = frontmatterValue
213 -
					.replace(/^\//, "")
214 -
					.toLowerCase()
215 -
					.replace(/\s+/g, "-");
216 -
			} else {
217 -
				// Fallback to filename if frontmatter field not found
218 -
				slug = getSlugFromFilename(path.basename(relativePath));
219 -
			}
220 -
			break;
221 207
		}
222 -
223 -
		default:
224 -
			slug = getSlugFromFilename(path.basename(relativePath));
225 -
			break;
208 +
	} else {
209 +
		// Default: use filepath
210 +
		slug = relativePath
211 +
			.replace(/\.mdx?$/, "")
212 +
			.toLowerCase()
213 +
			.replace(/\s+/g, "-");
226 214
	}
227 215
228 216
	// Remove /index or /_index suffix if configured
253 241
export interface ScanOptions {
254 242
	frontmatterMapping?: FrontmatterMapping;
255 243
	ignorePatterns?: string[];
256 -
	slugSource?: "filename" | "path" | "frontmatter";
257 244
	slugField?: string;
258 245
	removeIndexFromSlug?: boolean;
259 246
}
267 254
	let options: ScanOptions;
268 255
	if (
269 256
		frontmatterMappingOrOptions &&
270 -
		("slugSource" in frontmatterMappingOrOptions ||
271 -
			"frontmatterMapping" in frontmatterMappingOrOptions ||
272 -
			"ignorePatterns" in frontmatterMappingOrOptions)
257 +
		("frontmatterMapping" in frontmatterMappingOrOptions ||
258 +
			"ignorePatterns" in frontmatterMappingOrOptions ||
259 +
			"slugField" in frontmatterMappingOrOptions)
273 260
	) {
274 261
		options = frontmatterMappingOrOptions as ScanOptions;
275 262
	} else {
285 272
	const {
286 273
		frontmatterMapping,
287 274
		ignorePatterns: ignore = [],
288 -
		slugSource,
289 275
		slugField,
290 276
		removeIndexFromSlug,
291 277
	} = options;
314 300
					frontmatterMapping,
315 301
				);
316 302
				const slug = getSlugFromOptions(relativePath, rawFrontmatter, {
317 -
					slugSource,
318 303
					slugField,
319 304
					removeIndexFromSlug,
320 305
				});
packages/cli/src/lib/types.ts +1 −2
5 5
	coverImage?: string; // Field name for cover image (default: "ogImage")
6 6
	tags?: string; // Field name for tags (default: "tags")
7 7
	draft?: string; // Field name for draft status (default: "draft")
8 +
	slugField?: string; // Frontmatter field to use for slug (if set, uses frontmatter value; otherwise uses filepath)
8 9
}
9 10
10 11
// Strong reference for Bluesky post (com.atproto.repo.strongRef)
31 32
	identity?: string; // Which stored identity to use (matches identifier)
32 33
	frontmatter?: FrontmatterMapping; // Custom frontmatter field mappings
33 34
	ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"])
34 -
	slugSource?: "filename" | "path" | "frontmatter"; // How to generate slugs (default: "filename")
35 -
	slugField?: string; // Frontmatter field to use when slugSource is "frontmatter" (default: "slug")
36 35
	removeIndexFromSlug?: boolean; // Remove "/index" or "/_index" suffix from paths (default: false)
37 36
	textContentField?: string; // Frontmatter field to use for textContent instead of markdown body
38 37
	bluesky?: BlueskyConfig; // Optional Bluesky posting configuration