chore: resolved action items from issue #3 b8356ed0
Steve · 2026-01-31 07:23 6 file(s) · +156 −12
packages/cli/src/commands/publish.ts +7 −1
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
packages/cli/src/commands/sync.ts +10 −3
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
packages/cli/src/lib/atproto.ts +25 −2
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;
packages/cli/src/lib/config.ts +20 −0
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);
packages/cli/src/lib/markdown.ts +89 −6
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);
packages/cli/src/lib/types.ts +5 −0
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 {