@symbiosis-lab/moss-plugin-matters 1.4.2

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +18 -0
  3. package/assets/icon.svg +1 -0
  4. package/assets/manifest.json +36 -0
  5. package/codegen.ts +26 -0
  6. package/e2e/moss-cli.test.ts +338 -0
  7. package/features/api/fetch-articles.feature +39 -0
  8. package/features/auth/wallet-auth.feature +27 -0
  9. package/features/download/retry-logic.feature +36 -0
  10. package/features/download/self-correcting.feature +83 -0
  11. package/features/download/worker-pool.feature +29 -0
  12. package/features/social/fetch-social-data.feature +40 -0
  13. package/features/steps/api.steps.ts +180 -0
  14. package/features/steps/download.steps.ts +423 -0
  15. package/features/steps/incremental-sync.steps.ts +105 -0
  16. package/features/steps/self-correcting.steps.ts +575 -0
  17. package/features/steps/social.steps.ts +257 -0
  18. package/features/steps/syndication.steps.ts +264 -0
  19. package/features/steps/wallet-auth.steps.ts +185 -0
  20. package/features/sync/article-sync.feature +49 -0
  21. package/features/sync/homepage-grid.feature +43 -0
  22. package/features/sync/incremental-sync.feature +28 -0
  23. package/features/syndication/create-draft.feature +35 -0
  24. package/package.json +58 -0
  25. package/src/__generated__/schema.graphql +4289 -0
  26. package/src/__generated__/types.ts +5355 -0
  27. package/src/__tests__/api.test.ts +678 -0
  28. package/src/__tests__/auth-route.test.ts +38 -0
  29. package/src/__tests__/auth-routing.test.ts +462 -0
  30. package/src/__tests__/auto-detect.test.ts +412 -0
  31. package/src/__tests__/binding-guard.test.ts +256 -0
  32. package/src/__tests__/config.test.ts +212 -0
  33. package/src/__tests__/converter.test.ts +289 -0
  34. package/src/__tests__/credential.test.ts +332 -0
  35. package/src/__tests__/domain.test.ts +341 -0
  36. package/src/__tests__/downloader.test.ts +679 -0
  37. package/src/__tests__/folder-detection.test.ts +289 -0
  38. package/src/__tests__/force-fresh-login.test.ts +236 -0
  39. package/src/__tests__/main.test.ts +2437 -0
  40. package/src/__tests__/progress.test.ts +93 -0
  41. package/src/__tests__/session.test.ts +375 -0
  42. package/src/__tests__/social-integration.test.ts +386 -0
  43. package/src/__tests__/social-sync-logic.test.ts +107 -0
  44. package/src/__tests__/social.test.ts +788 -0
  45. package/src/__tests__/sync.test.ts +1273 -0
  46. package/src/__tests__/syndication-toast-law.test.ts +649 -0
  47. package/src/__tests__/syndication.test.ts +125 -0
  48. package/src/__tests__/test-profile-escape.test.ts +209 -0
  49. package/src/__tests__/url-detect.test.ts +79 -0
  50. package/src/__tests__/utils.test.ts +226 -0
  51. package/src/api.ts +1366 -0
  52. package/src/auth-route.ts +38 -0
  53. package/src/config.ts +80 -0
  54. package/src/converter.ts +305 -0
  55. package/src/credential.ts +329 -0
  56. package/src/domain.ts +183 -0
  57. package/src/downloader.ts +761 -0
  58. package/src/main.ts +2092 -0
  59. package/src/progress.ts +89 -0
  60. package/src/queries/user.graphql +85 -0
  61. package/src/queries/viewer.graphql +104 -0
  62. package/src/social.ts +413 -0
  63. package/src/sync.ts +818 -0
  64. package/src/types.ts +477 -0
  65. package/src/url-detect.ts +49 -0
  66. package/src/utils.ts +305 -0
  67. package/test-fixtures/syndication-test-site/input/index.md +8 -0
  68. package/test-fixtures/syndication-test-site/input/posts/rich-test-article.md +90 -0
  69. package/test-helpers/TEST_ACCOUNT.md +151 -0
  70. package/test-helpers/api-client.ts +252 -0
  71. package/test-helpers/fixtures/articles.ts +147 -0
  72. package/test-helpers/wallet-auth.ts +305 -0
  73. package/test-setup/e2e.ts +93 -0
  74. package/tsconfig.json +23 -0
  75. package/vitest.config.ts +39 -0
package/src/sync.ts ADDED
@@ -0,0 +1,818 @@
1
+ /**
2
+ * Sync logic for articles, drafts, and collections
3
+ *
4
+ * FILE STRUCTURE DESIGN:
5
+ * ----------------------
6
+ * All content is organized under a single content folder:
7
+ * - English: article/
8
+ * - Chinese: 文章/
9
+ *
10
+ * The folder name is determined by the user's Matters.town language preference
11
+ * (viewer.settings.language). Chinese is used for zh_hans or zh_hant.
12
+ *
13
+ * COLLECTION MODES:
14
+ * -----------------
15
+ * The plugin automatically detects the appropriate collection mode:
16
+ *
17
+ * 1. FOLDER MODE (default): Used when all articles belong to 0-1 collections
18
+ * - Collections are folders: article/{collection}/index.md
19
+ * - Articles in collections: article/{collection}/{article}.md
20
+ * - Standalone articles: article/{article}.md
21
+ *
22
+ * 2. FILE MODE: Used when any article belongs to 2+ collections
23
+ * - Collections are files: article/{collection}.md (with order: field)
24
+ * - All articles at: article/{article}.md (with collections: field)
25
+ *
26
+ * This automatic detection ensures no article duplication while maintaining
27
+ * the simplest possible structure for the user's content.
28
+ *
29
+ * TWO-PHASE SYNC:
30
+ * ---------------
31
+ * Media download is NOT done during markdown sync. This function writes
32
+ * markdown files with remote URLs intact. Call downloadMediaAndUpdate()
33
+ * afterward to download and localize media assets.
34
+ */
35
+
36
+ import type {
37
+ MattersArticle,
38
+ MattersDraft,
39
+ MattersCollection,
40
+ MattersUserProfile,
41
+ SyncResult,
42
+ SyncResultWithMap,
43
+ } from "./types";
44
+ import type { MattersPluginConfig } from "./config";
45
+ import { slugify, reportError } from "./utils";
46
+ import { overallProgress, type ProgressReporter } from "./progress";
47
+ import { generateFrontmatter, parseFrontmatter } from "./converter";
48
+ import { htmlToMarkdown, readFile, writeFile, listFiles, listProjectTree } from "@symbiosis-lab/moss-api";
49
+ import { isMattersUrl, articleUrl, extractShortHash } from "./domain";
50
+
51
+ // Canonical home for extractShortHash is ./domain (it owns Matters URL knowledge).
52
+ // Re-exported here so existing `import { extractShortHash } from "../sync"` callers
53
+ // and tests keep resolving it.
54
+ export { extractShortHash } from "./domain";
55
+
56
+ // ============================================================================
57
+ // Exported Functions for Folder Detection
58
+ // ============================================================================
59
+
60
+ /**
61
+ * Get default folder names. Only the drafts folder is fixed (`_drafts`); the
62
+ * article folder is language-aware via `folderNameForLanguage` (see
63
+ * `getArticleFolderName`).
64
+ */
65
+ export function getDefaultFolderNames(): {
66
+ article: string;
67
+ drafts: string;
68
+ } {
69
+ return { article: "articles", drafts: "_drafts" };
70
+ }
71
+
72
+ /**
73
+ * Map a Matters language preference to the article folder name.
74
+ * Chinese (`zh_hans` / `zh_hant`) → `文章`; everything else → `articles`.
75
+ * Restores the language-aware naming that had regressed to a hardcoded
76
+ * `articles` (it was disabled because it read the viewer-only
77
+ * `settings.language`; we now also accept a public per-article language). (G)
78
+ */
79
+ export function folderNameForLanguage(language?: string | null): string {
80
+ return language === "zh_hans" || language === "zh_hant" ? "文章" : "articles";
81
+ }
82
+
83
+ /**
84
+ * Resolve the site's language for folder naming. Prefers an explicit/authed
85
+ * value, then the public per-article majority (the only language signal
86
+ * available in unauthenticated/public-fetch mode, since `settings.language`
87
+ * is viewer-only). Returns undefined if no signal — caller defaults to English.
88
+ */
89
+ export function resolveContentLanguage(
90
+ explicit: string | null | undefined,
91
+ articleLanguages: Array<string | null | undefined>,
92
+ ): string | undefined {
93
+ if (explicit) return explicit;
94
+ const counts = new Map<string, number>();
95
+ for (const l of articleLanguages) {
96
+ if (l) counts.set(l, (counts.get(l) ?? 0) + 1);
97
+ }
98
+ let best: string | undefined;
99
+ let bestN = 0;
100
+ for (const [l, n] of counts) {
101
+ if (n > bestN) {
102
+ best = l;
103
+ bestN = n;
104
+ }
105
+ }
106
+ return best;
107
+ }
108
+
109
+ /**
110
+ * Check if drafts should be synced based on config.
111
+ * Default is FALSE - user must explicitly enable draft sync.
112
+ */
113
+ export function shouldSyncDrafts(config: MattersPluginConfig): boolean {
114
+ return config.sync_drafts ?? false;
115
+ }
116
+
117
+ /**
118
+ * Scan project files for Matters syndication content.
119
+ * Returns the top-level folder name and the Matters username if found.
120
+ *
121
+ * Shared helper used by both detectArticleFolder() and detectBoundUser().
122
+ */
123
+ async function scanForMattersContent(): Promise<{ folder: string | null; userName: string | null }> {
124
+ try {
125
+ const allFiles = await listFiles();
126
+ const mdFiles = allFiles.filter((f) => f.endsWith(".md"));
127
+
128
+ for (const filePath of mdFiles) {
129
+ const segments = filePath.split("/");
130
+ if (segments.length < 2) continue; // Skip root-level files
131
+
132
+ const topFolder = segments[0];
133
+
134
+ // Skip hidden and underscore folders
135
+ if (topFolder.startsWith(".") || topFolder.startsWith("_")) continue;
136
+
137
+ try {
138
+ const content = await readFile(filePath);
139
+ const parsed = parseFrontmatter(content);
140
+
141
+ if (
142
+ parsed?.frontmatter?.syndicated &&
143
+ Array.isArray(parsed.frontmatter.syndicated)
144
+ ) {
145
+ const mattersUrl = parsed.frontmatter.syndicated.find(
146
+ (url: string) => isMattersUrl(url)
147
+ );
148
+ if (mattersUrl) {
149
+ // Extract username from URL: https://matters.town/@username/slug-hash
150
+ const match = mattersUrl.match(/\/@([^/]+)\//);
151
+ const userName = match ? match[1] : null;
152
+ return { folder: topFolder, userName };
153
+ }
154
+ }
155
+ } catch {
156
+ continue;
157
+ }
158
+ }
159
+
160
+ return { folder: null, userName: null };
161
+ } catch {
162
+ return { folder: null, userName: null };
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Detect the article folder by scanning for files with Matters syndication URLs.
168
+ * Returns the folder name if found, or null if no existing articles.
169
+ */
170
+ export async function detectArticleFolder(): Promise<string | null> {
171
+ const { folder } = await scanForMattersContent();
172
+ return folder;
173
+ }
174
+
175
+ /**
176
+ * Detect the Matters username bound to this project by scanning syndication URLs.
177
+ * Returns the username if found, or null if no Matters content exists.
178
+ */
179
+ export async function detectBoundUser(): Promise<string | null> {
180
+ const { userName } = await scanForMattersContent();
181
+ return userName;
182
+ }
183
+
184
+ /**
185
+ * Get the article folder name to use for syncing.
186
+ *
187
+ * Priority:
188
+ * 1. Explicit config (articleFolder) - user override
189
+ * 2. Auto-detected from existing content - finds folder with Matters-synced files
190
+ * 3. Default "articles" - for new projects
191
+ */
192
+ export async function getArticleFolderName(
193
+ config: MattersPluginConfig,
194
+ language?: string,
195
+ ): Promise<string> {
196
+ // 1. Check if explicitly configured
197
+ if (config.articleFolder) {
198
+ return config.articleFolder;
199
+ }
200
+
201
+ // 2. Auto-detect from existing content
202
+ const detected = await detectArticleFolder();
203
+ if (detected) {
204
+ return detected;
205
+ }
206
+
207
+ // 3. Fall back to a language-derived default (Chinese → 文章, else articles)
208
+ return folderNameForLanguage(language ?? config.language);
209
+ }
210
+
211
+ // ============================================================================
212
+ // Helper Functions
213
+ // ============================================================================
214
+
215
+ /**
216
+ * Check if any article belongs to multiple collections
217
+ * Returns true if file-mode (collections as .md files) should be used
218
+ */
219
+ function hasMultiCollectionArticles(collections: MattersCollection[]): boolean {
220
+ const articleCollectionCount = new Map<string, number>();
221
+
222
+ for (const collection of collections) {
223
+ for (const article of collection.articles) {
224
+ const count = articleCollectionCount.get(article.shortHash) || 0;
225
+ articleCollectionCount.set(article.shortHash, count + 1);
226
+ }
227
+ }
228
+
229
+ for (const count of articleCollectionCount.values()) {
230
+ if (count > 1) return true;
231
+ }
232
+ return false;
233
+ }
234
+
235
+ /**
236
+ * Check if remote article is newer than local
237
+ */
238
+ export function isRemoteNewer(
239
+ localUpdated: string | undefined,
240
+ remoteUpdated: string | undefined
241
+ ): boolean {
242
+ if (!localUpdated) return true;
243
+ if (!remoteUpdated) return false;
244
+
245
+ const localDate = new Date(localUpdated);
246
+ const remoteDate = new Date(remoteUpdated);
247
+
248
+ return remoteDate > localDate;
249
+ }
250
+
251
+
252
+ /**
253
+ * Scan local markdown files to find all synced Matters articles
254
+ * Returns array of { shortHash, path, title, uid } for all articles with Matters syndicated URLs
255
+ *
256
+ * The uid comes from frontmatter and is used as the key for local social data storage.
257
+ * When uid is null (file hasn't been built yet), callers should fall back to path.
258
+ */
259
+ export async function scanLocalArticles(): Promise<Array<{ shortHash: string; path: string; title: string; uid: string | null }>> {
260
+ const articles: Array<{ shortHash: string; path: string; title: string; uid: string | null }> = [];
261
+
262
+ try {
263
+ // List all files in the project
264
+ const allFiles = await listFiles();
265
+
266
+ // Filter to markdown files only
267
+ const files = allFiles.filter((f) => f.endsWith(".md"));
268
+
269
+ for (const file of files) {
270
+ // Skip node_modules, .moss, and other non-content directories
271
+ if (
272
+ file.startsWith("node_modules/") ||
273
+ file.startsWith(".moss/") ||
274
+ file.startsWith("_drafts/") ||
275
+ file.startsWith(".") ||
276
+ file === "index.md" ||
277
+ file === "README.md"
278
+ ) {
279
+ continue;
280
+ }
281
+
282
+ try {
283
+ const content = await readFile(file);
284
+ const parsed = parseFrontmatter(content);
285
+
286
+ if (
287
+ parsed?.frontmatter?.syndicated &&
288
+ Array.isArray(parsed.frontmatter.syndicated)
289
+ ) {
290
+ // Find Matters URL in syndicated array
291
+ const mattersUrl = parsed.frontmatter.syndicated.find(
292
+ (url: string) => typeof url === "string" && isMattersUrl(url)
293
+ );
294
+
295
+ if (mattersUrl) {
296
+ const shortHash = extractShortHash(mattersUrl);
297
+ if (shortHash) {
298
+ const uid = typeof parsed.frontmatter.uid === "string"
299
+ ? parsed.frontmatter.uid
300
+ : null;
301
+ articles.push({
302
+ shortHash,
303
+ path: file,
304
+ title: (parsed.frontmatter.title as string) || file,
305
+ uid,
306
+ });
307
+ } else {
308
+ // Visible signal rather than a silent drop: an article with a
309
+ // valid Matters syndicated URL whose shortHash can't be parsed
310
+ // will get no comments/social data, and the user would otherwise
311
+ // have no way to know why.
312
+ console.warn(
313
+ `[matters] could not extract shortHash from syndicated URL "${mattersUrl}" (${file}) — skipping social fetch`
314
+ );
315
+ }
316
+ }
317
+ }
318
+ } catch {
319
+ // Skip files that can't be read or parsed
320
+ }
321
+ }
322
+ } catch (error) {
323
+ console.warn(`Failed to scan local articles: ${error}`);
324
+ }
325
+
326
+ return articles;
327
+ }
328
+
329
+ /**
330
+ * Find an available filename by adding sequence numbers if needed
331
+ */
332
+ async function findAvailableFilename(
333
+ basePath: string,
334
+ slug: string
335
+ ): Promise<string> {
336
+ let filename = `${basePath}/${slug}.md`;
337
+ let counter = 1;
338
+
339
+ while (true) {
340
+ try {
341
+ await readFile(filename);
342
+ counter++;
343
+ filename = `${basePath}/${slug}-${counter}.md`;
344
+ } catch {
345
+ return filename;
346
+ }
347
+ }
348
+ }
349
+
350
+ // ============================================================================
351
+ // Main Sync Function
352
+ // ============================================================================
353
+
354
+ /**
355
+ * Sync articles, drafts, and collections to local markdown files
356
+ * Media is NOT downloaded here - use downloadMediaAndUpdate() after this
357
+ *
358
+ * Returns both the sync result and an articlePathMap for link rewriting.
359
+ * The articlePathMap maps Matters URLs and shortHashes to local file paths,
360
+ * enabling internal link rewriting in the post-sync phase.
361
+ */
362
+ export async function syncToLocalFiles(
363
+ articles: MattersArticle[],
364
+ drafts: MattersDraft[],
365
+ collections: MattersCollection[],
366
+ userName: string,
367
+ config: Record<string, unknown>,
368
+ profile: MattersUserProfile,
369
+ // moss tells us which file it detected as the homepage (e.g., "刘果.md", "index.md").
370
+ // When set, we skip homepage generation — the user already has a home file and the
371
+ // Matters plugin should not create a competing index.md. This uses the same detection
372
+ // logic as moss-core's home::detect_home_file_in_folder(), which considers index stems,
373
+ // self-named folder notes, and alphabetical fallback.
374
+ homepageFile?: string | null,
375
+ // The root folder's basename (e.g. "刘果"), from moss's project_info.folder_name.
376
+ // When we DO generate a home, we name it self-named (`<folder>.md`) with a
377
+ // `home: true` marker to match moss's folder-home convention. Falls back to
378
+ // `index.md` when absent (older hosts that don't supply it).
379
+ folderName?: string | null,
380
+ // Reports per-item sync progress to the unified import task so the hairline
381
+ // advances within the "syncing" band instead of jumping start→end. Optional:
382
+ // direct callers (tests) omit it and the per-item reports no-op.
383
+ onProgress?: ProgressReporter,
384
+ ): Promise<SyncResultWithMap> {
385
+ const result: SyncResult = {
386
+ created: 0,
387
+ updated: 0,
388
+ skipped: 0,
389
+ errors: [],
390
+ };
391
+
392
+ // Map for internal link rewriting: Matters URL/shortHash → local file path
393
+ const articlePathMap = new Map<string, string>();
394
+
395
+ // Get folder names - auto-detect existing folder, else language-derived
396
+ // (Chinese → 文章). Language: authed profile.language → stale config.language
397
+ // → public per-article majority (the only signal in unauthenticated mode). (G)
398
+ const contentLanguage = resolveContentLanguage(
399
+ profile?.language ?? (config as MattersPluginConfig).language,
400
+ articles.map((a) => a.language),
401
+ );
402
+ const articleFolder = await getArticleFolderName(
403
+ config as MattersPluginConfig,
404
+ contentLanguage,
405
+ );
406
+ const folders = {
407
+ article: articleFolder,
408
+ drafts: getDefaultFolderNames().drafts,
409
+ };
410
+
411
+ // Build dedup index: shortHash → local file path
412
+ // Catches renamed files that still have a Matters syndicated URL in frontmatter
413
+ const localArticles = await scanLocalArticles();
414
+ const knownShortHashes = new Map<string, string>();
415
+ for (const local of localArticles) {
416
+ knownShortHashes.set(local.shortHash, local.path);
417
+ }
418
+
419
+ const totalItems = articles.length + drafts.length + collections.length + 1; // +1 for homepage
420
+ let processedItems = 0;
421
+
422
+ // Detect collection mode: folder-based or file-based
423
+ const useFileMode = hasMultiCollectionArticles(collections);
424
+ console.log(
425
+ `📁 Syncing ${articles.length} articles, ${drafts.length} drafts, and ${collections.length} collections...`
426
+ );
427
+ console.log(` Collection mode: ${useFileMode ? "file-based (multi-collection articles detected)" : "folder-based"}`);
428
+ console.log(` Content folder: ${folders.article}/`);
429
+ console.log(` Drafts folder: ${folders.drafts}/`);
430
+
431
+ // Build article ID → collection memberships mapping
432
+ const articleCollections = new Map<string, Record<string, number>>();
433
+ const articleFirstCollection = new Map<string, string>();
434
+
435
+ for (const collection of collections) {
436
+ const collectionSlug = slugify(collection.title);
437
+ for (let i = 0; i < collection.articles.length; i++) {
438
+ const article = collection.articles[i];
439
+ const articleKey = article.shortHash;
440
+
441
+ if (!articleCollections.has(articleKey)) {
442
+ articleCollections.set(articleKey, {});
443
+ }
444
+ articleCollections.get(articleKey)![collectionSlug] = i;
445
+
446
+ if (!articleFirstCollection.has(articleKey)) {
447
+ articleFirstCollection.set(articleKey, collectionSlug);
448
+ }
449
+ }
450
+ }
451
+
452
+ // Build article shortHash → slug mapping for collection order field
453
+ const articleSlugMap = new Map<string, string>();
454
+ for (const article of articles) {
455
+ const slug = article.slug || slugify(article.title);
456
+ articleSlugMap.set(article.shortHash, slug);
457
+ }
458
+
459
+ // ============================================================================
460
+ // Generate Homepage (index.md)
461
+ // ============================================================================
462
+ // Skip if moss already detected a home file (e.g., "刘果.md", "index.md", "readme.md").
463
+ // The homepageFile comes from moss's home file detection (moss-core home.rs), which
464
+ // considers index stems, self-named folder notes, and alphabetical fallback. When a
465
+ // home file exists, the Matters plugin should not create a competing index.md.
466
+ processedItems++;
467
+ onProgress?.("syncing_homepage", overallProgress("syncing_homepage", processedItems, totalItems), 100, "Creating homepage...");
468
+
469
+ if (homepageFile) {
470
+ console.log(` ⏭️ Skipping homepage (moss detected home file: ${homepageFile})`);
471
+ result.skipped++;
472
+ } else {
473
+ try {
474
+ // Self-named home (`<folder>.md`) + `home: true` marker, matching moss's
475
+ // folder-home convention. Falls back to `index.md` when the host didn't
476
+ // tell us the folder name.
477
+ const homeFilename = folderName ? `${folderName}.md` : "index.md";
478
+ const homepageFrontmatter = generateFrontmatter({
479
+ title: folderName ?? profile.displayName,
480
+ home: true,
481
+ });
482
+
483
+ let homepageBody = profile.description || "";
484
+
485
+ if (profile.pinnedWorks && profile.pinnedWorks.length > 0) {
486
+ const gridItems = profile.pinnedWorks.map((work) => {
487
+ if (work.type === "collection") {
488
+ const slug = slugify(work.title);
489
+ // In file mode, collections are .md files; in folder mode, they are directories
490
+ const collectionPath = useFileMode
491
+ ? `/${folders.article}/${slug}`
492
+ : `/${folders.article}/${slug}/`;
493
+ return `[${work.title}](${collectionPath})`;
494
+ } else {
495
+ // Article — find its path (standalone or in collection)
496
+ const slug = work.slug || slugify(work.title);
497
+ const shortHash = work.shortHash ?? "";
498
+ const collectionSlug = articleFirstCollection.get(shortHash);
499
+ const path = collectionSlug
500
+ ? `/${folders.article}/${collectionSlug}/${slug}/`
501
+ : `/${folders.article}/${slug}/`;
502
+ return `[${work.title}](${path})`;
503
+ }
504
+ });
505
+
506
+ // Cells are separated by moss's canonical `+++` divider; a lone `:::`
507
+ // is the grid CLOSER, so using it between cells prematurely closes the
508
+ // grid and corrupts the homepage (B1). The single trailing `:::` closes.
509
+ homepageBody += "\n\n:::grid 3\n" + gridItems.join("\n+++\n") + "\n:::\n";
510
+ }
511
+
512
+ const homepageContent = homepageFrontmatter + "\n\n" + homepageBody;
513
+
514
+ // Don't overwrite an existing home. moss's homepageFile check (above) already
515
+ // covers homes it detected; this is the on-disk backstop for the self-named
516
+ // target and a legacy index.md.
517
+ let existingHomepage: string | null = null;
518
+ let existingPath = "";
519
+ for (const candidate of [homeFilename, "index.md"]) {
520
+ try {
521
+ existingHomepage = await readFile(candidate);
522
+ } catch {
523
+ existingHomepage = null;
524
+ }
525
+ if (existingHomepage !== null) {
526
+ existingPath = candidate;
527
+ break;
528
+ }
529
+ }
530
+
531
+ if (existingHomepage !== null) {
532
+ console.log(` ⏭️ Skipping homepage (already exists): ${existingPath}`);
533
+ result.skipped++;
534
+ } else {
535
+ await writeFile(homeFilename, homepageContent);
536
+ console.log(` ✅ Created homepage: ${homeFilename}`);
537
+ result.created++;
538
+ }
539
+ } catch (error) {
540
+ const errorMsg = `Failed to create homepage: ${error}`;
541
+ await reportError(errorMsg, "syncing_homepage", false);
542
+ console.error(` ❌ ${errorMsg}`);
543
+ result.errors.push(errorMsg);
544
+ }
545
+ }
546
+
547
+ // Fetch project tree once for home-file detection in collection folders
548
+ const projectTree = await listProjectTree();
549
+
550
+ // Process collections
551
+ for (const collection of collections) {
552
+ processedItems++;
553
+ onProgress?.(
554
+ "syncing_collections",
555
+ overallProgress("syncing_collections", processedItems, totalItems),
556
+ 100,
557
+ `Syncing collection: ${collection.title}`
558
+ );
559
+
560
+ try {
561
+ const collectionSlug = slugify(collection.title);
562
+
563
+ // Determine path based on mode
564
+ // All collections live under the article/ folder
565
+ const collectionPath = useFileMode
566
+ ? `${folders.article}/${collectionSlug}.md` // File mode: collection as .md file
567
+ : `${folders.article}/${collectionSlug}/${collectionSlug}.md`; // Folder mode: self-named folder home
568
+
569
+ // In folder mode, skip if the folder already has a home file (self-named note, etc.)
570
+ if (!useFileMode) {
571
+ const folderPrefix = `${folders.article}/${collectionSlug}/`;
572
+ const homeInFolder = projectTree.find(
573
+ (f) => f.path.startsWith(folderPrefix) && f.is_home
574
+ );
575
+ if (homeInFolder) {
576
+ console.log(` ⏭️ Skipping collection index (folder has home file: ${homeInFolder.path})`);
577
+ result.skipped++;
578
+ continue;
579
+ }
580
+ }
581
+
582
+ let existingContent: string | null = null;
583
+ try {
584
+ existingContent = await readFile(collectionPath);
585
+ } catch {
586
+ // File doesn't exist
587
+ }
588
+
589
+ // Build order field for collections (list of article slugs/paths)
590
+ let orderField: string[] | undefined;
591
+ if (collection.articles.length > 0) {
592
+ if (useFileMode) {
593
+ // File mode: full paths relative to project root
594
+ orderField = collection.articles
595
+ .map((a) => {
596
+ const slug = articleSlugMap.get(a.shortHash);
597
+ return slug ? `${folders.article}/${slug}` : null;
598
+ })
599
+ .filter((s): s is string => s !== null);
600
+ } else {
601
+ // Folder mode: bare slugs (articles are inside the collection folder)
602
+ orderField = collection.articles
603
+ .map((a) => articleSlugMap.get(a.shortHash) ?? null)
604
+ .filter((s): s is string => s !== null);
605
+ }
606
+ }
607
+
608
+ const frontmatter = generateFrontmatter({
609
+ title: collection.title,
610
+ // A folder-mode collection's landing page IS that folder's home.
611
+ // (File-mode collections are plain `.md` pages, not folder homes.)
612
+ home: !useFileMode,
613
+ description: collection.description,
614
+ cover: collection.cover, // Keep remote URL, will be downloaded in phase 2
615
+ order: orderField,
616
+ });
617
+
618
+ const fullContent = `${frontmatter}\n\n${collection.description || ""}`;
619
+
620
+ if (existingContent !== null) {
621
+ console.log(` ⏭️ Skipping collection (already exists): ${collectionPath}`);
622
+ result.skipped++;
623
+ continue;
624
+ }
625
+
626
+ await writeFile(collectionPath, fullContent);
627
+ console.log(` ✅ Created collection: ${collectionPath}`);
628
+ result.created++;
629
+ } catch (error) {
630
+ const errorMsg = `Failed to sync collection "${collection.title}": ${error}`;
631
+ await reportError(errorMsg, "syncing_collections", false);
632
+ console.error(` ❌ ${errorMsg}`);
633
+ result.errors.push(errorMsg);
634
+ }
635
+ }
636
+
637
+ // Process published articles
638
+ for (const article of articles) {
639
+ processedItems++;
640
+ onProgress?.(
641
+ "syncing_articles",
642
+ overallProgress("syncing_articles", processedItems, totalItems),
643
+ 100,
644
+ `Syncing article: ${article.title}`
645
+ );
646
+
647
+ try {
648
+ const articleSlug = article.slug || slugify(article.title);
649
+ const mattersUrl = articleUrl(userName, article.slug, article.shortHash);
650
+
651
+ // Determine file location based on mode and collection membership
652
+ // All articles live under the article/ folder
653
+ let filename: string;
654
+ if (useFileMode) {
655
+ // File mode: all articles directly under article/, collections via frontmatter
656
+ filename = `${folders.article}/${articleSlug}.md`;
657
+ } else {
658
+ // Folder mode: articles in their first collection's folder
659
+ const firstCollectionSlug = articleFirstCollection.get(article.shortHash);
660
+ if (firstCollectionSlug) {
661
+ filename = `${folders.article}/${firstCollectionSlug}/${articleSlug}.md`;
662
+ } else {
663
+ // Standalone articles (not in any collection) go directly under article/
664
+ filename = `${folders.article}/${articleSlug}.md`;
665
+ }
666
+ }
667
+
668
+ // Check if article already exists locally (even under a different filename)
669
+ const existingLocalPath = knownShortHashes.get(article.shortHash);
670
+ if (existingLocalPath) {
671
+ // Article exists locally — use actual path for link rewriting
672
+ articlePathMap.set(mattersUrl, existingLocalPath);
673
+ articlePathMap.set(article.shortHash, existingLocalPath);
674
+ console.log(` ⏭️ Skipping (already synced): ${existingLocalPath}`);
675
+ result.skipped++;
676
+ continue;
677
+ }
678
+
679
+ // New article — map the computed filename for link rewriting
680
+ articlePathMap.set(mattersUrl, filename);
681
+ articlePathMap.set(article.shortHash, filename);
682
+
683
+ // Build collections field for frontmatter
684
+ const allCollections = articleCollections.get(article.shortHash) || {};
685
+ let collectionsField: Record<string, number> | string[] | undefined;
686
+
687
+ if (useFileMode) {
688
+ // File mode: list all collections
689
+ if (Object.keys(allCollections).length > 0) {
690
+ collectionsField = allCollections;
691
+ }
692
+ } else {
693
+ // Folder mode: only additional collections (not the first one where article lives)
694
+ const firstCollectionSlug = articleFirstCollection.get(article.shortHash);
695
+ const additionalCollections: Record<string, number> = {};
696
+ for (const [slug, order] of Object.entries(allCollections)) {
697
+ if (slug !== firstCollectionSlug) {
698
+ additionalCollections[slug] = order;
699
+ }
700
+ }
701
+ if (Object.keys(additionalCollections).length > 0) {
702
+ collectionsField = additionalCollections;
703
+ }
704
+ }
705
+
706
+ // Check if file already exists - never overwrite existing files
707
+ // This implements "download new content only" model
708
+ let fileExists = false;
709
+ try {
710
+ await readFile(filename);
711
+ fileExists = true;
712
+ } catch {
713
+ // File doesn't exist
714
+ }
715
+
716
+ if (fileExists) {
717
+ // Never overwrite existing files - protects local edits
718
+ console.log(` ⏭️ Skipping (file exists): ${filename}`);
719
+ result.skipped++;
720
+ continue;
721
+ }
722
+
723
+ // Convert HTML to Markdown via moss's shared htmd converter (keep remote
724
+ // URLs; downloaded + rewritten to wikilinks in phase 2)
725
+ const markdownContent = await htmlToMarkdown(article.content);
726
+
727
+ const frontmatter = generateFrontmatter({
728
+ title: article.title,
729
+ description: article.summary,
730
+ date: article.createdAt,
731
+ updated: article.revisedAt,
732
+ // Matters tag strings can carry leading/trailing whitespace (e.g.
733
+ // `"React "`). Trim each and drop any that collapse to empty so the
734
+ // frontmatter `tags:` list is clean (B10).
735
+ tags: article.tags
736
+ .map((t) => t.content.trim())
737
+ .filter((t) => t.length > 0),
738
+ cover: article.cover, // Keep remote URL, will be downloaded in phase 2
739
+ syndicated: [mattersUrl],
740
+ collections: collectionsField,
741
+ });
742
+
743
+ const fullContent = `${frontmatter}\n\n${markdownContent}`;
744
+
745
+ await writeFile(filename, fullContent);
746
+ console.log(` ✅ Created: ${filename}`);
747
+ result.created++;
748
+ } catch (error) {
749
+ const errorMsg = `Failed to sync article "${article.title}": ${error}`;
750
+ await reportError(errorMsg, "syncing_articles", false);
751
+ console.error(` ❌ ${errorMsg}`);
752
+ result.errors.push(errorMsg);
753
+ }
754
+ }
755
+
756
+ // Process drafts (disabled by default - must be explicitly enabled)
757
+ if (shouldSyncDrafts(config)) {
758
+ for (const draft of drafts) {
759
+ processedItems++;
760
+ const draftTitle = draft.title || "Untitled";
761
+ onProgress?.(
762
+ "syncing_drafts",
763
+ overallProgress("syncing_drafts", processedItems, totalItems),
764
+ 100,
765
+ `Syncing draft: ${draftTitle}`
766
+ );
767
+
768
+ try {
769
+ const slug = slugify(draft.title || "untitled");
770
+ const filename = await findAvailableFilename(folders.drafts, slug);
771
+
772
+ // Check if file already exists - never overwrite existing files
773
+ let fileExists = false;
774
+ try {
775
+ await readFile(filename);
776
+ fileExists = true;
777
+ } catch {
778
+ // File doesn't exist
779
+ }
780
+
781
+ if (fileExists) {
782
+ // Never overwrite existing files - protects local edits
783
+ console.log(` ⏭️ Skipping draft (file exists): ${filename}`);
784
+ result.skipped++;
785
+ continue;
786
+ }
787
+
788
+ // Convert HTML to Markdown (keep remote URLs, will be downloaded in phase 2)
789
+ const markdownContent = await htmlToMarkdown(draft.content);
790
+
791
+ const frontmatter = generateFrontmatter({
792
+ title: draft.title || "Untitled Draft",
793
+ date: draft.createdAt,
794
+ updated: draft.updatedAt,
795
+ // Trim + drop empties, same as the published-article path (B10).
796
+ tags: (draft.tags || [])
797
+ .map((t) => t.trim())
798
+ .filter((t) => t.length > 0),
799
+ cover: draft.cover, // Keep remote URL, will be downloaded in phase 2
800
+ syndicated: [],
801
+ });
802
+
803
+ const fullContent = `${frontmatter}\n\n${markdownContent}`;
804
+
805
+ await writeFile(filename, fullContent);
806
+ console.log(` ✅ Created draft: ${filename}`);
807
+ result.created++;
808
+ } catch (error) {
809
+ const errorMsg = `Failed to sync draft "${draftTitle}": ${error}`;
810
+ await reportError(errorMsg, "syncing_drafts", false);
811
+ console.error(` ❌ ${errorMsg}`);
812
+ result.errors.push(errorMsg);
813
+ }
814
+ }
815
+ }
816
+
817
+ return { result, articlePathMap };
818
+ }