chore: resolved action items from issue #3
b8356ed0
6 file(s) · +156 −12
| 87 | 87 | // Scan for posts |
|
| 88 | 88 | const s = spinner(); |
|
| 89 | 89 | s.start("Scanning for posts..."); |
|
| 90 | - | const posts = await scanContentDirectory(contentDir, config.frontmatter, config.ignore); |
|
| 90 | + | const posts = await scanContentDirectory(contentDir, { |
|
| 91 | + | frontmatterMapping: config.frontmatter, |
|
| 92 | + | ignorePatterns: config.ignore, |
|
| 93 | + | slugSource: config.slugSource, |
|
| 94 | + | slugField: config.slugField, |
|
| 95 | + | removeIndexFromSlug: config.removeIndexFromSlug, |
|
| 96 | + | }); |
|
| 91 | 97 | s.stop(`Found ${posts.length} posts`); |
|
| 92 | 98 | ||
| 93 | 99 | // Determine which posts need publishing |
| 90 | 90 | ||
| 91 | 91 | // Scan local posts |
|
| 92 | 92 | s.start("Scanning local content..."); |
|
| 93 | - | const localPosts = await scanContentDirectory(contentDir, config.frontmatter); |
|
| 93 | + | const localPosts = await scanContentDirectory(contentDir, { |
|
| 94 | + | frontmatterMapping: config.frontmatter, |
|
| 95 | + | ignorePatterns: config.ignore, |
|
| 96 | + | slugSource: config.slugSource, |
|
| 97 | + | slugField: config.slugField, |
|
| 98 | + | removeIndexFromSlug: config.removeIndexFromSlug, |
|
| 99 | + | }); |
|
| 94 | 100 | s.stop(`Found ${localPosts.length} local posts`); |
|
| 95 | 101 | ||
| 96 | 102 | // Build a map of path -> local post for matching |
|
| 97 | - | // Document path is like /posts/my-post-slug |
|
| 103 | + | // Document path is like /posts/my-post-slug (or custom pathPrefix) |
|
| 104 | + | const pathPrefix = config.pathPrefix || "/posts"; |
|
| 98 | 105 | const postsByPath = new Map<string, typeof localPosts[0]>(); |
|
| 99 | 106 | for (const post of localPosts) { |
|
| 100 | - | const postPath = `/posts/${post.slug}`; |
|
| 107 | + | const postPath = `${pathPrefix}/${post.slug}`; |
|
| 101 | 108 | postsByPath.set(postPath, post); |
|
| 102 | 109 | } |
|
| 103 | 110 |
| 171 | 171 | ): Promise<string> { |
|
| 172 | 172 | const pathPrefix = config.pathPrefix || "/posts"; |
|
| 173 | 173 | const postPath = `${pathPrefix}/${post.slug}`; |
|
| 174 | - | const textContent = stripMarkdownForText(post.content); |
|
| 175 | 174 | const publishDate = new Date(post.frontmatter.publishDate); |
|
| 176 | 175 | ||
| 176 | + | // Determine textContent: use configured field from frontmatter, or fallback to markdown body |
|
| 177 | + | let textContent: string; |
|
| 178 | + | if (config.textContentField && post.rawFrontmatter?.[config.textContentField]) { |
|
| 179 | + | textContent = String(post.rawFrontmatter[config.textContentField]); |
|
| 180 | + | } else { |
|
| 181 | + | textContent = stripMarkdownForText(post.content); |
|
| 182 | + | } |
|
| 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 | + | ||
| 194 | + | if (post.frontmatter.description) { |
|
| 195 | + | record.description = post.frontmatter.description; |
|
| 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 | - | const textContent = stripMarkdownForText(post.content); |
|
| 223 | 233 | const publishDate = new Date(post.frontmatter.publishDate); |
|
| 224 | 234 | ||
| 235 | + | // Determine textContent: use configured field from frontmatter, or fallback to markdown body |
|
| 236 | + | let textContent: string; |
|
| 237 | + | if (config.textContentField && post.rawFrontmatter?.[config.textContentField]) { |
|
| 238 | + | textContent = String(post.rawFrontmatter[config.textContentField]); |
|
| 239 | + | } else { |
|
| 240 | + | textContent = stripMarkdownForText(post.content); |
|
| 241 | + | } |
|
| 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 | + | ||
| 253 | + | if (post.frontmatter.description) { |
|
| 254 | + | record.description = post.frontmatter.description; |
|
| 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 | + | description?: string; |
|
| 269 | 292 | coverImage?: BlobObject; |
|
| 270 | 293 | tags?: string[]; |
|
| 271 | 294 | location?: string; |
|
| 76 | 76 | pdsUrl?: string; |
|
| 77 | 77 | frontmatter?: FrontmatterMapping; |
|
| 78 | 78 | ignore?: string[]; |
|
| 79 | + | slugSource?: "filename" | "path" | "frontmatter"; |
|
| 80 | + | slugField?: string; |
|
| 81 | + | removeIndexFromSlug?: boolean; |
|
| 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 | + | } |
|
| 118 | + | ||
| 119 | + | if (options.slugSource && options.slugSource !== "filename") { |
|
| 120 | + | config.slugSource = options.slugSource; |
|
| 121 | + | } |
|
| 122 | + | ||
| 123 | + | if (options.slugField && options.slugField !== "slug") { |
|
| 124 | + | config.slugField = options.slugField; |
|
| 125 | + | } |
|
| 126 | + | ||
| 127 | + | if (options.removeIndexFromSlug) { |
|
| 128 | + | config.removeIndexFromSlug = options.removeIndexFromSlug; |
|
| 129 | + | } |
|
| 130 | + | ||
| 131 | + | if (options.textContentField) { |
|
| 132 | + | config.textContentField = options.textContentField; |
|
| 113 | 133 | } |
|
| 114 | 134 | ||
| 115 | 135 | return JSON.stringify(config, null, 2); |
|
| 7 | 7 | export function parseFrontmatter(content: string, mapping?: FrontmatterMapping): { |
|
| 8 | 8 | frontmatter: PostFrontmatter; |
|
| 9 | 9 | body: string; |
|
| 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 | - | return { frontmatter: frontmatter as unknown as PostFrontmatter, body }; |
|
| 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 | + | export interface SlugOptions { |
|
| 117 | + | slugSource?: "filename" | "path" | "frontmatter"; |
|
| 118 | + | slugField?: string; |
|
| 119 | + | removeIndexFromSlug?: boolean; |
|
| 120 | + | } |
|
| 121 | + | ||
| 122 | + | export function getSlugFromOptions( |
|
| 123 | + | relativePath: string, |
|
| 124 | + | rawFrontmatter: Record<string, unknown>, |
|
| 125 | + | options: SlugOptions = {} |
|
| 126 | + | ): string { |
|
| 127 | + | const { slugSource = "filename", slugField = "slug", removeIndexFromSlug = false } = options; |
|
| 128 | + | ||
| 129 | + | let slug: string; |
|
| 130 | + | ||
| 131 | + | switch (slugSource) { |
|
| 132 | + | case "path": |
|
| 133 | + | // Use full relative path without extension |
|
| 134 | + | slug = relativePath |
|
| 135 | + | .replace(/\.mdx?$/, "") |
|
| 136 | + | .toLowerCase() |
|
| 137 | + | .replace(/\s+/g, "-"); |
|
| 138 | + | break; |
|
| 139 | + | ||
| 140 | + | case "frontmatter": |
|
| 141 | + | // Use frontmatter field (slug or url) |
|
| 142 | + | const frontmatterValue = rawFrontmatter[slugField] || rawFrontmatter.slug || rawFrontmatter.url; |
|
| 143 | + | if (frontmatterValue && typeof frontmatterValue === "string") { |
|
| 144 | + | // Remove leading slash if present |
|
| 145 | + | slug = frontmatterValue.replace(/^\//, "").toLowerCase().replace(/\s+/g, "-"); |
|
| 146 | + | } else { |
|
| 147 | + | // Fallback to filename if frontmatter field not found |
|
| 148 | + | slug = getSlugFromFilename(path.basename(relativePath)); |
|
| 149 | + | } |
|
| 150 | + | break; |
|
| 151 | + | ||
| 152 | + | case "filename": |
|
| 153 | + | default: |
|
| 154 | + | slug = getSlugFromFilename(path.basename(relativePath)); |
|
| 155 | + | break; |
|
| 156 | + | } |
|
| 157 | + | ||
| 158 | + | // Remove /index or /_index suffix if configured |
|
| 159 | + | if (removeIndexFromSlug) { |
|
| 160 | + | slug = slug.replace(/\/_?index$/, ""); |
|
| 161 | + | } |
|
| 162 | + | ||
| 163 | + | return slug; |
|
| 164 | + | } |
|
| 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 | + | export interface ScanOptions { |
|
| 184 | + | frontmatterMapping?: FrontmatterMapping; |
|
| 185 | + | ignorePatterns?: string[]; |
|
| 186 | + | slugSource?: "filename" | "path" | "frontmatter"; |
|
| 187 | + | slugField?: string; |
|
| 188 | + | removeIndexFromSlug?: boolean; |
|
| 189 | + | } |
|
| 190 | + | ||
| 132 | 191 | export async function scanContentDirectory( |
|
| 133 | 192 | contentDir: string, |
|
| 134 | - | frontmatterMapping?: FrontmatterMapping, |
|
| 193 | + | frontmatterMappingOrOptions?: FrontmatterMapping | ScanOptions, |
|
| 135 | 194 | ignorePatterns: string[] = [] |
|
| 136 | 195 | ): Promise<BlogPost[]> { |
|
| 196 | + | // Handle both old signature (frontmatterMapping, ignorePatterns) and new signature (options) |
|
| 197 | + | let options: ScanOptions; |
|
| 198 | + | if (frontmatterMappingOrOptions && ('slugSource' in frontmatterMappingOrOptions || 'frontmatterMapping' in frontmatterMappingOrOptions || 'ignorePatterns' in frontmatterMappingOrOptions)) { |
|
| 199 | + | options = frontmatterMappingOrOptions as ScanOptions; |
|
| 200 | + | } else { |
|
| 201 | + | // Old signature: (contentDir, frontmatterMapping?, ignorePatterns?) |
|
| 202 | + | options = { |
|
| 203 | + | frontmatterMapping: frontmatterMappingOrOptions as FrontmatterMapping | undefined, |
|
| 204 | + | ignorePatterns, |
|
| 205 | + | }; |
|
| 206 | + | } |
|
| 207 | + | ||
| 208 | + | const { |
|
| 209 | + | frontmatterMapping, |
|
| 210 | + | ignorePatterns: ignore = [], |
|
| 211 | + | slugSource, |
|
| 212 | + | slugField, |
|
| 213 | + | removeIndexFromSlug, |
|
| 214 | + | } = options; |
|
| 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 | - | if (shouldIgnore(relativePath, ignorePatterns)) { |
|
| 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 | - | const { frontmatter, body } = parseFrontmatter(rawContent, frontmatterMapping); |
|
| 157 | - | const filename = path.basename(relativePath); |
|
| 158 | - | const slug = getSlugFromFilename(filename); |
|
| 235 | + | const { frontmatter, body, rawFrontmatter } = parseFrontmatter(rawContent, frontmatterMapping); |
|
| 236 | + | const slug = getSlugFromOptions(relativePath, rawFrontmatter, { |
|
| 237 | + | slugSource, |
|
| 238 | + | slugField, |
|
| 239 | + | removeIndexFromSlug, |
|
| 240 | + | }); |
|
| 159 | 241 | ||
| 160 | 242 | posts.push({ |
|
| 161 | 243 | filePath, |
|
| 163 | 245 | frontmatter, |
|
| 164 | 246 | content: body, |
|
| 165 | 247 | rawContent, |
|
| 248 | + | rawFrontmatter, |
|
| 166 | 249 | }); |
|
| 167 | 250 | } catch (error) { |
|
| 168 | 251 | console.error(`Error parsing ${relativePath}:`, error); |
|
| 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 | + | slugSource?: "filename" | "path" | "frontmatter"; // How to generate slugs (default: "filename") |
|
| 22 | + | slugField?: string; // Frontmatter field to use when slugSource is "frontmatter" (default: "slug") |
|
| 23 | + | removeIndexFromSlug?: boolean; // Remove "/index" or "/_index" suffix from paths (default: false) |
|
| 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 | + | rawFrontmatter: Record<string, unknown>; // For accessing custom fields like textContentField |
|
| 44 | 49 | } |
|
| 45 | 50 | ||
| 46 | 51 | export interface BlobRef { |
|