@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/main.ts ADDED
@@ -0,0 +1,2092 @@
1
+ /**
2
+ * Matters.town Syndicator Plugin
3
+ *
4
+ * Syndicates articles to Matters.town after deployment.
5
+ * Requires authentication via webview (domain: matters.town)
6
+ */
7
+
8
+ import type {
9
+ ProcessContext,
10
+ SyndicateContext,
11
+ HookResult,
12
+ ArticleInfo,
13
+ } from "./types";
14
+ import {
15
+ reportError,
16
+ setCurrentHookName,
17
+ sleep,
18
+ formatArticleSyncSummary,
19
+ } from "./utils";
20
+ import { startTask } from "@symbiosis-lab/moss-api";
21
+ import type { TaskHandle } from "@symbiosis-lab/moss-api";
22
+ import {
23
+ fetchAllArticlesSince,
24
+ fetchAllDraftsSince,
25
+ fetchAllCollections,
26
+ fetchUserProfile,
27
+ fetchArticleComments,
28
+ fetchAllArticleCommentCounts,
29
+ createDraft,
30
+ fetchDraft,
31
+ uploadAssetMultipart,
32
+ apiConfig,
33
+ MattersAuthError,
34
+ } from "./api";
35
+ import {
36
+ clearTokenCache,
37
+ getSessionState,
38
+ shouldNudgeSessionExpired,
39
+ captureLogin,
40
+ beginFreshLogin,
41
+ prepareWebviewAuth,
42
+ } from "./credential";
43
+ import { resolveAuthRoute, isUserPresent } from "./auth-route";
44
+ import { syncToLocalFiles, scanLocalArticles, detectBoundUser } from "./sync";
45
+ import { downloadMediaAndUpdate, rewriteAllInternalLinks } from "./downloader";
46
+ import { getConfig, saveConfig } from "./config";
47
+ import { overallProgress, type ProgressReporter } from "./progress";
48
+ import { loadSocialData, saveSocialData, mergeSocialData, reconcileLegacySocialData } from "./social";
49
+ import {
50
+ readFile,
51
+ writeFile,
52
+ readSiteFile,
53
+ showToast,
54
+ dismissToast,
55
+ readPluginFile,
56
+ writePluginFile,
57
+ pluginFileExists,
58
+ getPluginEnvVar,
59
+ emitEvent,
60
+ onEvent,
61
+ } from "@symbiosis-lab/moss-api";
62
+ import { parseFrontmatter, regenerateFrontmatter } from "./converter";
63
+ import {
64
+ initializeDomain,
65
+ getDomain,
66
+ loginUrl,
67
+ draftUrl,
68
+ articleUrl,
69
+ isMattersUrl,
70
+ } from "./domain";
71
+ import { looksLikePublishedArticleUrl } from "./url-detect";
72
+
73
+ // ============================================================================
74
+ // Social-fetch predicates (exported for unit tests — used in Phase 8 below)
75
+ // ============================================================================
76
+
77
+ /**
78
+ * Should the social fetch for this article be SKIPPED?
79
+ *
80
+ * Skip only when:
81
+ * - remoteCounts discovery succeeded (remoteCount defined)
82
+ * - we've synced before with this code path (storedCount defined)
83
+ * - remote count matches what we last recorded
84
+ * - AND we genuinely have data: either zero comments everywhere, or we have
85
+ * stored comments to prove the count was accurate on last sync.
86
+ *
87
+ * A poisoned entry (storedCount=57, existingComments=[]) must NOT skip —
88
+ * the count was recorded without actually fetching the data.
89
+ */
90
+ export function shouldSkipSocialFetch(
91
+ remoteCount: number | undefined,
92
+ storedCount: number | undefined,
93
+ existingCommentsLength: number,
94
+ ): boolean {
95
+ return (
96
+ remoteCount !== undefined &&
97
+ storedCount !== undefined &&
98
+ remoteCount === storedCount &&
99
+ (remoteCount === 0 || existingCommentsLength > 0)
100
+ );
101
+ }
102
+
103
+ /**
104
+ * What sinceTimestamp should be passed to fetchArticleComments?
105
+ *
106
+ * Pass lastSyncedAt only when we already have local comments AND the stored
107
+ * count is defined. An entry whose count was cleared by reconcile (poisoned
108
+ * entry) but which still has a FEW stored comments must do a FULL refetch —
109
+ * giving it lastSyncedAt would drop all older comments via the since-filter,
110
+ * re-recording a near-empty count and re-locking the skip permanently.
111
+ */
112
+ export function resolveSinceTimestamp(
113
+ existingCommentsLength: number,
114
+ storedCount: number | undefined,
115
+ lastSyncedAt: string | undefined,
116
+ ): string | undefined {
117
+ return existingCommentsLength > 0 && storedCount !== undefined ? lastSyncedAt : undefined;
118
+ }
119
+
120
+ // ============================================================================
121
+ // Draft Tracking
122
+ // ============================================================================
123
+
124
+ /**
125
+ * Draft entry stored in drafts.json
126
+ */
127
+ export interface DraftEntry {
128
+ draftId: string;
129
+ createdAt: string;
130
+ }
131
+
132
+ /**
133
+ * Map of source_path -> draft entry
134
+ */
135
+ export type DraftMap = Record<string, DraftEntry>;
136
+
137
+ const DRAFTS_FILE = "drafts.json";
138
+
139
+ /**
140
+ * Read the draft tracking map from plugin storage.
141
+ * Returns empty object if file not found or invalid.
142
+ */
143
+ export async function getDraftMap(): Promise<DraftMap> {
144
+ try {
145
+ const exists = await pluginFileExists(DRAFTS_FILE);
146
+ if (!exists) return {};
147
+ const content = await readPluginFile(DRAFTS_FILE);
148
+ return JSON.parse(content) as DraftMap;
149
+ } catch {
150
+ return {};
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Write the draft tracking map to plugin storage.
156
+ */
157
+ export async function saveDraftMap(map: DraftMap): Promise<void> {
158
+ const content = JSON.stringify(map, null, 2);
159
+ await writePluginFile(DRAFTS_FILE, content);
160
+ }
161
+
162
+ /**
163
+ * Look up a tracked draft ID for a source path.
164
+ * Returns undefined if no draft is tracked.
165
+ */
166
+ export async function getDraftId(sourcePath: string): Promise<string | undefined> {
167
+ const map = await getDraftMap();
168
+ return map[sourcePath]?.draftId;
169
+ }
170
+
171
+ /**
172
+ * Persist a draft ID for a source path.
173
+ */
174
+ export async function saveDraftId(sourcePath: string, draftId: string): Promise<void> {
175
+ const map = await getDraftMap();
176
+ map[sourcePath] = {
177
+ draftId,
178
+ createdAt: new Date().toISOString(),
179
+ };
180
+ await saveDraftMap(map);
181
+ }
182
+
183
+ /**
184
+ * Remove a tracked draft for a source path (e.g., after publish).
185
+ */
186
+ export async function removeDraftId(sourcePath: string): Promise<void> {
187
+ const map = await getDraftMap();
188
+ delete map[sourcePath];
189
+ await saveDraftMap(map);
190
+ }
191
+
192
+ // ============================================================================
193
+ // Browser Utilities (via SDK)
194
+ // ============================================================================
195
+
196
+ import {
197
+ openBrowser,
198
+ closeBrowser,
199
+ returnToEditor,
200
+ type BrowserHandle,
201
+ } from "@symbiosis-lab/moss-api";
202
+
203
+ // ============================================================================
204
+ // Authentication Helpers
205
+ // ============================================================================
206
+
207
+ /**
208
+ * Session-expired nudge. Throttled once per expiry event via a nudgedAt
209
+ * stamp in auth.json (engine-independent: module state may reset per build
210
+ * under the off-webview runtime). Every suppressed occurrence still logs.
211
+ */
212
+ async function notifySessionExpired(): Promise<void> {
213
+ console.warn("⚠️ Matters session expired; drafts and syndication paused until re-login");
214
+ if (await shouldNudgeSessionExpired()) {
215
+ await showToast({
216
+ message: "Matters session expired. Log in to resume drafts and syndication.",
217
+ variant: "warning",
218
+ persistent: true,
219
+ });
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Wait for access token by polling for cookie
225
+ *
226
+ * @param browserHandle - Handle from openBrowser() to detect window closure
227
+ * @param initialDelayMs - Wait before first check (default: 20s)
228
+ * @param pollIntervalMs - Time between checks (default: 2s)
229
+ * @param maxWaitMs - Maximum total wait time (default: 5 minutes)
230
+ * @returns true if token found, false if window closed or timeout
231
+ */
232
+ async function waitForToken(
233
+ browserHandle: BrowserHandle,
234
+ initialDelayMs = 20000,
235
+ pollIntervalMs = 2000,
236
+ maxWaitMs = 300000
237
+ ): Promise<boolean> {
238
+ console.log(`⏳ Waiting ${initialDelayMs / 1000}s before checking for token...`);
239
+ await sleep(initialDelayMs);
240
+
241
+ const startTime = Date.now();
242
+ let windowClosed = false;
243
+
244
+ // Listen for window close
245
+ browserHandle.closed.then(() => {
246
+ windowClosed = true;
247
+ });
248
+
249
+ while (Date.now() - startTime < maxWaitMs - initialDelayMs) {
250
+ // Exit immediately if window was closed
251
+ if (windowClosed) {
252
+ console.log("🚪 Browser window closed by user");
253
+ return false;
254
+ }
255
+
256
+ clearTokenCache();
257
+ // During login flow, capture the freshly-set cookie (captureLogin checks the
258
+ // global WebKit store) — it also persists the token to project storage.
259
+ const token = await captureLogin();
260
+
261
+ // Exit if context was lost (SDK returns undefined)
262
+ if (token === undefined) {
263
+ console.log("⚠️ Plugin context lost, stopping auth check");
264
+ return false;
265
+ }
266
+
267
+ if (token) {
268
+ console.log("🔑 Token found!");
269
+ return true;
270
+ }
271
+
272
+ await sleep(pollIntervalMs);
273
+ }
274
+
275
+ return false;
276
+ }
277
+
278
+ /**
279
+ * Prompt user to login to Matters.town
280
+ */
281
+ // ============================================================================
282
+ // Test-harness escape hatch (T8a, 2026-05-28)
283
+ // ============================================================================
284
+ //
285
+ // `MOSS_MATTERS_TEST_PROFILE` lets the onboarding e2e harness bypass the
286
+ // real Matters auth flow entirely. When set, the plugin:
287
+ //
288
+ // 1. (API layer) flips `apiConfig.queryMode = "user"` and sets
289
+ // `apiConfig.testUserName = <profile>`, switching all GraphQL traffic
290
+ // through `graphqlQueryPublic` (no cookies, no token cache).
291
+ //
292
+ // 2. (UI layer) skips `promptLogin()` outright. The auth webview never
293
+ // opens — without this, the harness still observes the matters.news/
294
+ // login page load and can't assert "import starts immediately".
295
+ //
296
+ // Both flips are required for end-to-end harness coverage; an API-only
297
+ // flip leaves the auth webview visible. The env var is read via the
298
+ // allow-listed `get_plugin_env_var` Tauri command (the plugin runtime
299
+ // has no direct `process.env` access). Default test profile is `@guo`.
300
+ //
301
+ // Production behavior is unchanged when the env var is unset — every
302
+ // call site degrades to the legacy auth flow.
303
+
304
+ let testProfileCache: string | null | undefined;
305
+
306
+ /**
307
+ * Resolve the Matters test profile. Memoized — env vars don't change
308
+ * mid-session, so we read once at first call. Returns `null` (not
309
+ * `undefined`) when explicitly unset so consumers can distinguish
310
+ * "not configured" from "not yet checked".
311
+ *
312
+ * Strips a leading `@` if present so the harness can pass either
313
+ * `@guo` or `guo` and get the same result. `apiConfig.testUserName`
314
+ * stores the bare username.
315
+ */
316
+ async function getMattersTestProfile(): Promise<string | null> {
317
+ if (testProfileCache !== undefined) return testProfileCache;
318
+ const raw = await getPluginEnvVar("MOSS_MATTERS_TEST_PROFILE");
319
+ if (!raw) {
320
+ testProfileCache = null;
321
+ return null;
322
+ }
323
+ const trimmed = raw.startsWith("@") ? raw.slice(1) : raw;
324
+ testProfileCache = trimmed;
325
+ console.log(`🧪 Matters: MOSS_MATTERS_TEST_PROFILE=${raw} → public-fetch mode (@${trimmed})`);
326
+ return trimmed;
327
+ }
328
+
329
+ /**
330
+ * Apply the test-profile escape hatch to `apiConfig`. No-op when the env
331
+ * var is unset, so safe to call unconditionally. Returns the profile
332
+ * name if the escape hatch was applied, `null` otherwise — callers use
333
+ * the return value to decide whether to skip auth UI.
334
+ */
335
+ async function applyTestProfileEscapeHatch(): Promise<string | null> {
336
+ const profile = await getMattersTestProfile();
337
+ if (!profile) return null;
338
+ apiConfig.queryMode = "user";
339
+ apiConfig.testUserName = profile;
340
+ return profile;
341
+ }
342
+
343
+ /**
344
+ * Bind the current folder to the just-authenticated Matters account. Login is
345
+ * the single authoritative binding event (per-folder isolation spec): this runs
346
+ * on EVERY successful login, so all three promptLogin call sites re-bind without
347
+ * touching the call sites. Best-effort — a profile-fetch failure must not fail
348
+ * an otherwise-successful login.
349
+ */
350
+ async function affirmBindingFromProfile(): Promise<void> {
351
+ try {
352
+ const profile = await fetchUserProfile();
353
+ const config = await getConfig();
354
+ if (config.boundUserName !== profile.userName || config.userName !== profile.userName) {
355
+ await saveConfig({ ...config, boundUserName: profile.userName, userName: profile.userName });
356
+ console.log(`🔗 Bound this folder to @${profile.userName} via login`);
357
+ }
358
+ } catch (e) {
359
+ console.warn(`⚠️ Failed to affirm Matters binding after login: ${e}`);
360
+ }
361
+ }
362
+
363
+ async function promptLogin(): Promise<boolean> {
364
+ // UI-layer escape hatch: when MOSS_MATTERS_TEST_PROFILE is set, skip
365
+ // the login webview entirely and pretend authentication succeeded.
366
+ // The API layer (apiConfig.queryMode = "user") already routes all
367
+ // queries through the public path, so there is nothing to authenticate.
368
+ const testProfile = await getMattersTestProfile();
369
+ if (testProfile) {
370
+ console.log(`🧪 Matters: skipping login UI (test profile @${testProfile})`);
371
+ return true;
372
+ }
373
+
374
+ // Force-fresh login (per-folder isolation): clear THIS folder's stored token
375
+ // AND the matters-domain cookies before opening the webview, so (a) the login
376
+ // page shows a real credential screen (no lingering server session) and (b)
377
+ // captureLogin can only capture a freshly-logged-in token — never a stale
378
+ // auth.json token (it reads the stored token before the cookie).
379
+ await beginFreshLogin();
380
+
381
+ console.log(`🔐 Opening ${getDomain()} login page...`);
382
+
383
+ try {
384
+ const browser = await openBrowser(loginUrl());
385
+
386
+ console.log(`🌐 Browser opened. Please log in to ${getDomain()}.`);
387
+ console.log("⏳ Will check for authentication after 20 seconds...");
388
+
389
+ const authenticated = await waitForToken(browser, 20000, 2000, 300000);
390
+
391
+ if (authenticated) {
392
+ console.log("✅ Login successful, closing browser...");
393
+ // Login is the authoritative binding event: bind THIS folder to the
394
+ // account just authenticated (covers all three promptLogin call sites).
395
+ await affirmBindingFromProfile();
396
+ try {
397
+ await closeBrowser();
398
+ } catch {
399
+ // Browser might already be closed
400
+ }
401
+ return true;
402
+ } else {
403
+ console.warn("⏱️ Login timeout or window closed. Closing browser...");
404
+ try {
405
+ await closeBrowser();
406
+ } catch {
407
+ // Ignore close errors
408
+ }
409
+ return false;
410
+ }
411
+ } catch (error) {
412
+ console.error(`❌ Login flow failed: ${error}`);
413
+ try {
414
+ await closeBrowser();
415
+ } catch {
416
+ // Ignore close errors
417
+ }
418
+ return false;
419
+ }
420
+ }
421
+
422
+ // ============================================================================
423
+ // Hook Implementations
424
+ // ============================================================================
425
+
426
+ /**
427
+ * process hook - Check authentication and sync articles from Matters
428
+ *
429
+ * This capability pre-processes content before generation.
430
+ */
431
+ export async function process(context: ProcessContext): Promise<HookResult> {
432
+ setCurrentHookName("process");
433
+ clearTokenCache();
434
+ await initializeDomain();
435
+
436
+ // T8a escape hatch: if MOSS_MATTERS_TEST_PROFILE is set, flip apiConfig
437
+ // to public-fetch mode BEFORE any auth/binding logic runs. The promptLogin
438
+ // helper also checks the env var and skips its UI — together these two
439
+ // flips give the e2e harness a no-auth-webview import path. Idempotent;
440
+ // no-op in production.
441
+ const testProfile = await applyTestProfileEscapeHatch();
442
+
443
+ // Start a PanelTask so the breadcrumb hairline animates during import.
444
+ // hook = "import" — the process hook syncs articles from Matters (an import
445
+ // operation), not a transform/enhance.
446
+ // trigger — moss owns this context (ADR-015): it stamps `context.trigger` when
447
+ // it invokes the hook. The onboarding card path → "onboarding_flow", which the
448
+ // router maps to (ActionPanel, Ambient) → the hairline; every build/preview
449
+ // rebuild → "background" (quiet). The plugin must NOT guess this from its own
450
+ // state (e.g. the test-profile env var) — that conflated "test mode" with
451
+ // "user-initiated onboarding" and left the hairline dead in production.
452
+ // Absent (older moss) ⇒ "background", the safe quiet default.
453
+ const task = await startTask("Importing from Matters", {
454
+ hook: "import",
455
+ trigger: context.trigger ?? "background",
456
+ hasProgress: true,
457
+ cancellable: false,
458
+ });
459
+
460
+ console.log("🔐 Matters: process hook started");
461
+
462
+ try {
463
+ // Binding guard: only sync if project is bound to a Matters account
464
+ {
465
+ const bindingConfig = await getConfig();
466
+ // Under the test-profile escape hatch the project is "bound" to the
467
+ // test user implicitly — skip the binding detection / login prompt
468
+ // since the harness already picked the profile.
469
+ if (testProfile) {
470
+ if (bindingConfig.boundUserName !== testProfile) {
471
+ await saveConfig({
472
+ ...bindingConfig,
473
+ boundUserName: testProfile,
474
+ userName: testProfile,
475
+ });
476
+ console.log(`🧪 Matters: auto-bound to @${testProfile} (test profile)`);
477
+ }
478
+ } else if (!bindingConfig.boundUserName) {
479
+ const detectedUser = await detectBoundUser();
480
+ if (detectedUser) {
481
+ // Auto-bind from existing articles
482
+ await saveConfig({ ...bindingConfig, boundUserName: detectedUser, userName: detectedUser });
483
+ console.log(`🔗 Auto-bound to @${detectedUser} from existing articles`);
484
+ } else {
485
+ // Fresh project — binding requires login, which only a present
486
+ // user can do. Background rebuilds exit quietly (spec §3.3:
487
+ // background never opens a login window).
488
+ if (!isUserPresent(context.trigger)) {
489
+ console.log("🔗 Not bound and no user present; skipping sync quietly");
490
+ await task.succeeded("No Matters account bound");
491
+ return {
492
+ success: true,
493
+ message: "No Matters account bound. Skipping sync.",
494
+ };
495
+ }
496
+ // Suspend the Rust 60s inactivity watchdog (task.awaiting sets the
497
+ // hook as awaiting permanently until hook teardown — the watchdog
498
+ // skips it while waiting for the user). Immediately follow with a
499
+ // quiet progress label so the UI shows "Connect to Matters" rather
500
+ // than the Awaiting amber pulse (spec: in-band login must not use
501
+ // the Awaiting tone).
502
+ await task.awaiting("Connect to Matters", "", "cancel");
503
+ await task.progress(undefined, "Connect to Matters");
504
+ const loginSuccess = await promptLogin();
505
+ if (!loginSuccess) {
506
+ // Terminate the task before returning, or it stays Running in the
507
+ // registry forever. Not bound ⇒ nothing to import; a clean success.
508
+ await task.succeeded("No Matters account bound");
509
+ // Return to the editor so the user isn't left with an empty panel.
510
+ void returnToEditor().catch(() => { /* best-effort */ });
511
+ return {
512
+ success: true,
513
+ message: "No Matters account bound. Skipping sync.",
514
+ };
515
+ }
516
+ // promptLogin() now affirms the binding (affirmBindingFromProfile) on
517
+ // success — the folder is bound to the just-authenticated account.
518
+ }
519
+ }
520
+ }
521
+
522
+ // Phase 1: Authentication — tri-state session check + trigger routing.
523
+ // Background must never open a login window (spec §3.3).
524
+ await task.progress(overallProgress("authentication", 0, 1) / 100, "Checking authentication...");
525
+ const sessionState = await getSessionState();
526
+ const authConfig = await getConfig();
527
+ // queryMode may be stale from a previous fallback run: public_fallback
528
+ // flips it to "user" and module state persists across hook invocations
529
+ // in the webview runtime. Reset to "viewer" here; the test profile owns
530
+ // the flip when set (applied before this phase).
531
+ if (!testProfile) {
532
+ apiConfig.queryMode = "viewer";
533
+ }
534
+ const route = testProfile
535
+ ? "proceed"
536
+ : resolveAuthRoute(sessionState, context.trigger, Boolean(authConfig.userName));
537
+ let isAuthenticated = sessionState === "valid";
538
+ let usingUnauthenticatedMode = false;
539
+
540
+ switch (route) {
541
+ case "proceed":
542
+ await task.progress(overallProgress("authentication", 1, 1) / 100, "Authenticated");
543
+ console.log("✅ Matters: session usable, proceeding");
544
+ break;
545
+
546
+ case "public_fallback":
547
+ console.log(`🔓 Session ${sessionState}, importing public articles for @${authConfig.userName}`);
548
+ console.log(" Note: Drafts will not be available in unauthenticated mode");
549
+ apiConfig.queryMode = "user";
550
+ apiConfig.testUserName = authConfig.userName!;
551
+ usingUnauthenticatedMode = true;
552
+ isAuthenticated = false;
553
+ if (sessionState === "expired") await notifySessionExpired();
554
+ await task.progress(overallProgress("authentication", 1, 1) / 100, `Using saved user: @${authConfig.userName}`);
555
+ break;
556
+
557
+ case "prompt_login": {
558
+ console.log(`🔐 Session ${sessionState}, prompting login (trigger: ${context.trigger})...`);
559
+ // Suspend the Rust 60s inactivity watchdog before showing the login
560
+ // panel (same rationale as the binding path above). Follow immediately
561
+ // with a quiet progress label (spec: no Awaiting amber pulse for
562
+ // in-panel login).
563
+ await task.awaiting("Connect to Matters", "", "cancel");
564
+ await task.progress(undefined, "Connect to Matters");
565
+ const loginSuccess = await promptLogin();
566
+ if (!loginSuccess) {
567
+ await task.failed("Login failed or timeout", true);
568
+ await reportError("Login failed or timeout", "authentication", true);
569
+ // Return to the editor so the user isn't left with an empty panel.
570
+ void returnToEditor().catch(() => { /* best-effort */ });
571
+ return {
572
+ success: false,
573
+ message: "Login failed or timeout. Please try again.",
574
+ };
575
+ }
576
+ isAuthenticated = true;
577
+ await task.progress(overallProgress("authentication", 1, 1) / 100, "Authenticated");
578
+ console.log("✅ Matters: Authenticated");
579
+ break;
580
+ }
581
+
582
+ case "soft_fail": {
583
+ // 'expired' gets the nudge; 'none' is quiet (no session event to
584
+ // report, and sync_on_build would re-toast every build).
585
+ const message =
586
+ sessionState === "expired"
587
+ ? "session expired, log in again to import."
588
+ : "not logged in, log in to import.";
589
+ if (sessionState === "expired") await notifySessionExpired();
590
+ await task.failed(message, true);
591
+ return { success: false, message };
592
+ }
593
+ }
594
+
595
+ // Check if sync is enabled
596
+ const syncOnBuild = context.config?.sync_on_build ?? true;
597
+ if (!syncOnBuild) {
598
+ console.log("ℹ️ Sync on build is disabled, skipping...");
599
+ // Terminate the task before returning (else it leaks as Running).
600
+ // Sync intentionally off ⇒ a clean success, but only claim
601
+ // "Authenticated" when the route actually proceeded with a usable
602
+ // session; fallback/expired routes get the plain message.
603
+ await task.succeeded("Sync disabled");
604
+ return {
605
+ success: true,
606
+ message: route === "proceed" ? "Authenticated (sync disabled)" : "Sync disabled",
607
+ };
608
+ }
609
+
610
+ // Get config for incremental sync
611
+ const pluginConfig = await getConfig();
612
+ const lastSyncedAt = pluginConfig.lastSyncedAt;
613
+ if (lastSyncedAt) {
614
+ console.log(`📅 Last synced at: ${lastSyncedAt}`);
615
+ } else {
616
+ console.log("📅 No previous sync - will fetch all articles");
617
+ }
618
+
619
+ // Phase 2: Fetch articles (with incremental sync)
620
+ await task.progress(overallProgress("fetching_articles", 0, 1) / 100, "Fetching articles from Matters.town...");
621
+ const { articles, userName } = await fetchAllArticlesSince(lastSyncedAt);
622
+ await task.progress(overallProgress("fetching_articles", 1, 1) / 100, `Found ${articles.length} article(s) to sync`);
623
+ console.log(` Found ${articles.length} article(s) to sync`);
624
+
625
+ // Phase 3: Fetch drafts
626
+ await task.progress(overallProgress("fetching_drafts", 0, 1) / 100, "Fetching drafts from Matters.town...");
627
+ const drafts = await fetchAllDraftsSince(lastSyncedAt);
628
+ await task.progress(overallProgress("fetching_drafts", 1, 1) / 100, `Found ${drafts.length} draft(s)`);
629
+ console.log(` Found ${drafts.length} draft(s)`);
630
+
631
+ // Phase 4: Fetch collections
632
+ await task.progress(overallProgress("fetching_collections", 0, 1) / 100, "Fetching collections from Matters.town...");
633
+ const allCollections = await fetchAllCollections();
634
+ const knownCollectionIds = new Set(pluginConfig.knownCollectionIds || []);
635
+ const newCollections = allCollections.filter(c => !knownCollectionIds.has(c.id));
636
+ const allCollectionIds = allCollections.map(c => c.id);
637
+ await task.progress(overallProgress("fetching_collections", 1, 1) / 100, `Found ${newCollections.length} new collection(s) (${allCollections.length} total)`);
638
+ console.log(` Found ${newCollections.length} new collection(s) (${allCollections.length} total)`);
639
+
640
+ // Phase 5: Fetch user profile (for homepage and language detection)
641
+ await task.progress(overallProgress("fetching_profile", 0, 1) / 100, "Fetching user profile...");
642
+ const profile = await fetchUserProfile();
643
+ await task.progress(overallProgress("fetching_profile", 1, 1) / 100, `Profile: ${profile.displayName}`);
644
+ console.log(` Profile: ${profile.displayName} (language: ${profile.language || "default"})`);
645
+
646
+ // Save userName to config for future unauthenticated fallback (only when authenticated)
647
+ if (isAuthenticated && !usingUnauthenticatedMode) {
648
+ try {
649
+ const existingConfig = await getConfig();
650
+ if (existingConfig.userName !== profile.userName || existingConfig.language !== profile.language) {
651
+ await saveConfig({
652
+ ...existingConfig,
653
+ userName: profile.userName,
654
+ language: profile.language,
655
+ });
656
+ console.log(` Saved username @${profile.userName} to config for future unauthenticated access`);
657
+ }
658
+ } catch (error) {
659
+ // Non-fatal: just log the error
660
+ console.warn(` Failed to save config: ${error}`);
661
+ }
662
+ }
663
+
664
+ // Phase 6: Sync to local files
665
+ // Route sub-phase progress (per-item sync, per-image media download) to the
666
+ // unified import task so the hairline advances THROUGH the long phases
667
+ // instead of stalling. Takes the (phase, absolute-0-100, total=100) shape
668
+ // the sub-phases emit and converts to the task's 0-1 fraction; fire-and-
669
+ // forget so a slow IPC never blocks the import worker. Replaces the legacy
670
+ // `reportProgress` SDK path, which the progress panel drops for `process`.
671
+ const reportToTask: ProgressReporter = (_phase, current, total, message) => {
672
+ void task.progress(total > 0 ? current / total : 0, message).catch(() => {});
673
+ };
674
+
675
+ const syncTotal = articles.length + drafts.length + allCollections.length + 1;
676
+ await task.progress(overallProgress("syncing", 0, syncTotal) / 100, "Starting sync...");
677
+ const { result: syncResult, articlePathMap } = await syncToLocalFiles(
678
+ articles,
679
+ drafts,
680
+ allCollections,
681
+ userName,
682
+ context.config || {},
683
+ profile,
684
+ context.project_info.homepage_file,
685
+ context.project_info.folder_name,
686
+ reportToTask,
687
+ );
688
+
689
+ // Build the NOUN-LED article summary ("12 articles already up to date"),
690
+ // not a bare "12 unchanged". One headline fact for the progress surface.
691
+ const summary = formatArticleSyncSummary({
692
+ created: syncResult.created,
693
+ updated: syncResult.updated,
694
+ skipped: syncResult.skipped,
695
+ failed: syncResult.errors.length,
696
+ });
697
+ await task.progress(overallProgress("syncing", syncTotal, syncTotal) / 100, `Sync complete: ${summary}`);
698
+ console.log(`✅ Sync complete: ${summary}`);
699
+
700
+ // Phase 7: Post-sync processing (run SEQUENTIALLY to avoid race conditions)
701
+ // Both operations read/write the same markdown files, so they must not run in parallel.
702
+ // Order: Media download first (updates image references), then link rewriting
703
+ const mediaResult = await downloadMediaAndUpdate(reportToTask);
704
+ await task.progress(overallProgress("rewriting_links", 0, 1) / 100, "Rewriting internal links...");
705
+ const linkResult = await rewriteAllInternalLinks(articlePathMap, userName);
706
+ await task.progress(overallProgress("rewriting_links", 1, 1) / 100, `Rewrote ${linkResult.linksRewritten} internal links`);
707
+
708
+ // Media outcomes do NOT clutter the success receipt: downloads/skips stay
709
+ // silent ("success makes no sound"), and each FAILED image is proposed as
710
+ // its own advisory carrying the image's source URL (the dead CDN reference
711
+ // still in the body) so the user sees WHICH image broke — not an opaque
712
+ // "1 failed" count. moss groups same-reason advisories into one notice that
713
+ // lists the URLs (capped + "+N more"). ShippedDegraded: the sync still
714
+ // succeeded, so this is a quiet hairline dot, never a blocker (gavel: R13).
715
+ // `?? []`: media is a non-critical path — a result missing this field (e.g.
716
+ // a partial test mock) must never abort the whole sync over an advisory.
717
+ for (const url of mediaResult.failedImageUrls ?? []) {
718
+ await task.advise({
719
+ scope: "Remote",
720
+ severity: "ShippedDegraded",
721
+ item: url,
722
+ what: "Image could not be downloaded from Matters",
723
+ action: "None",
724
+ });
725
+ }
726
+
727
+ const linkSummary =
728
+ linkResult.linksRewritten > 0
729
+ ? `, ${linkResult.linksRewritten} internal links rewritten`
730
+ : "";
731
+
732
+ // Phase 8: Fetch social data (comments only)
733
+ // First, ask Matters once for `commentCount` per article (one paginated
734
+ // pass). For each local article, skip the per-article comments query
735
+ // entirely when the remote count matches what we recorded last sync.
736
+ // Inside the per-article fetch, the existing `knownIds` + `lastSyncedAt`
737
+ // short-circuits still apply to bound page-level work.
738
+ let socialSummary = "";
739
+ const articlesForSocialFetch = await scanLocalArticles();
740
+ console.log(`📊 Checking social data for ${articlesForSocialFetch.length} local articles`);
741
+
742
+ // Build shortHash → uid map for the legacy reconcile (issue #793).
743
+ // scanLocalArticles() returns both fields; a null uid means the file
744
+ // hasn't been built yet — those entries stay keyed by shortHash.
745
+ const shortHashToUid = new Map<string, string>();
746
+ for (const a of articlesForSocialFetch) {
747
+ if (a.uid) shortHashToUid.set(a.shortHash, a.uid);
748
+ }
749
+
750
+ // One-time migration: if .moss/social/matters.json (legacy path) still
751
+ // exists, merge it into .moss/data/social/matters.json and retire it.
752
+ // loadSocialData() loads from the new canonical path; reconcile is called
753
+ // on the initial load result so the merge happens before any fetch below.
754
+ //
755
+ // We call reconcile here (after scanLocalArticles) because the uid↔shortHash
756
+ // mapping is needed to remap legacy shortHash-keyed entries to uid keys.
757
+ {
758
+ const preReconcile = await loadSocialData();
759
+ const migrated = await reconcileLegacySocialData(preReconcile, shortHashToUid);
760
+ if (migrated) {
761
+ console.log("[matters] Legacy social data reconciled — reloading canonical store");
762
+ }
763
+ }
764
+
765
+ if (articlesForSocialFetch.length > 0) {
766
+ await task.progress(overallProgress("fetching_social", 0, articlesForSocialFetch.length) / 100, "Checking for new comments...");
767
+
768
+ // One paginated query that returns {shortHash, commentCount} for the
769
+ // user's whole library. ~4 queries per 200 articles vs. the previous
770
+ // 200+ per-article queries. If this fails (network, etc.) we fall back
771
+ // to checking every article so we don't silently drop new comments.
772
+ let remoteCounts: Map<string, number> | null = null;
773
+ try {
774
+ remoteCounts = await fetchAllArticleCommentCounts();
775
+ console.log(`📊 Got commentCount for ${remoteCounts.size} remote articles`);
776
+ } catch (error) {
777
+ console.warn(` commentCount discovery failed (${error}); falling back to per-article fetch`);
778
+ }
779
+
780
+ const socialData = await loadSocialData();
781
+ let totalComments = 0;
782
+ let fetched = 0;
783
+ let skipped = 0;
784
+
785
+ for (let i = 0; i < articlesForSocialFetch.length; i++) {
786
+ const article = articlesForSocialFetch[i];
787
+ await task.progress(
788
+ overallProgress("fetching_social", i + 1, articlesForSocialFetch.length) / 100,
789
+ `Social data: ${article.title}`
790
+ );
791
+
792
+ try {
793
+ // Compute social key: uid when available, fall back to path
794
+ const socialKey = article.uid || article.path;
795
+ if (!article.uid) {
796
+ console.warn(` Article "${article.title}" has no uid, falling back to path as social data key`);
797
+ }
798
+
799
+ // Skip the comments fetch when the remote count exactly matches
800
+ // what we saw last sync. See shouldSkipSocialFetch (above) for the
801
+ // full predicate rationale and known soft-correctness gap.
802
+ const remoteCount = remoteCounts?.get(article.shortHash);
803
+ const storedCount = socialData.articles[socialKey]?.lastKnownCommentCount;
804
+
805
+ // Pass known comment IDs for early-exit pagination optimization
806
+ const existingComments = socialData.articles[socialKey]?.comments || [];
807
+ if (shouldSkipSocialFetch(remoteCount, storedCount, existingComments.length)) {
808
+ skipped++;
809
+ continue;
810
+ }
811
+
812
+ const knownIds = new Set(existingComments.map(c => c.id));
813
+ // See resolveSinceTimestamp (above) for the full rationale.
814
+ const sinceTimestamp = resolveSinceTimestamp(existingComments.length, storedCount, lastSyncedAt);
815
+ const comments = await fetchArticleComments(article.shortHash, knownIds, sinceTimestamp);
816
+
817
+ mergeSocialData(socialData, socialKey, comments, [], [], remoteCount);
818
+
819
+ totalComments += comments.length;
820
+ fetched++;
821
+
822
+ // Save after each article to avoid losing data if later fetches hang
823
+ await saveSocialData(socialData);
824
+ } catch (error) {
825
+ console.warn(` Failed to fetch social data for ${article.title}: ${error}`);
826
+ }
827
+ }
828
+
829
+ // Only announce comments when there ARE new ones — "0 new comments" is
830
+ // noise that bloated the receipt and pushed the real outcome past the
831
+ // truncation edge.
832
+ if (totalComments > 0) {
833
+ socialSummary = `, ${totalComments} new comment${totalComments === 1 ? "" : "s"}`;
834
+ }
835
+ console.log(`✅ Social data: ${fetched} fetched, ${skipped} skipped (no change), ${totalComments} new comments`);
836
+ }
837
+
838
+ // Phase 9: Update lastSyncedAt timestamp
839
+ const syncEndTime = new Date().toISOString();
840
+ try {
841
+ const currentConfig = await getConfig();
842
+ await saveConfig({
843
+ ...currentConfig,
844
+ lastSyncedAt: syncEndTime,
845
+ knownCollectionIds: allCollectionIds,
846
+ });
847
+ console.log(`📅 Updated lastSyncedAt to ${syncEndTime}`);
848
+ } catch (error) {
849
+ console.warn(`Failed to save lastSyncedAt: ${error}`);
850
+ }
851
+
852
+ // Only core sync errors are critical; media/link errors are non-critical (nice-to-have)
853
+ // This allows partial success (e.g., all articles synced but some images failed to download)
854
+ const criticalErrors = syncResult.errors;
855
+ const finalMessage = `Synced from Matters: ${summary}${linkSummary}${socialSummary}`;
856
+
857
+ // Honest receipt for EVERY unauthenticated-mode run (spec §3.3),
858
+ // state-aware: expired session vs never-logged-in route differently.
859
+ // Leading ". " closes the unpunctuated summary before it (otherwise
860
+ // the receipt reads "no changes Matters session expired; ...").
861
+ const unauthNote = usingUnauthenticatedMode
862
+ ? sessionState === "expired"
863
+ ? ". Matters session expired; log in to resume drafts and syndication."
864
+ : ". Not logged in; log in to also import drafts."
865
+ : "";
866
+
867
+ if (criticalErrors.length === 0) {
868
+ await task.succeeded(`${summary}${linkSummary}${socialSummary}${unauthNote}`);
869
+ } else {
870
+ await task.failed(`${criticalErrors.length} sync error(s)`, true);
871
+ }
872
+
873
+ return {
874
+ success: criticalErrors.length === 0,
875
+ message: finalMessage,
876
+ };
877
+ } catch (error) {
878
+ if (error instanceof MattersAuthError) {
879
+ // Pre-flight passed but the server revoked the token mid-run (rare).
880
+ // graphqlQuery already stamped invalidatedAt, so the NEXT run routes
881
+ // through the expired-session table; here we fail with honest copy.
882
+ const message = "session expired, log in again to import.";
883
+ await notifySessionExpired();
884
+ await task.failed(message, true);
885
+ console.error("❌ Matters: sync aborted, session rejected by server");
886
+ return { success: false, message };
887
+ }
888
+ const cause = error instanceof Error ? error.message : String(error);
889
+ await task.failed(`Sync failed: ${cause}`);
890
+ await reportError(`Sync failed: ${cause}`, "process", true);
891
+ console.error(`❌ Matters: Sync failed: ${cause}`);
892
+ return {
893
+ success: false,
894
+ message: `Sync failed: ${cause}`,
895
+ };
896
+ }
897
+ }
898
+
899
+ /**
900
+ * syndicate hook - Syndicate articles to Matters.town
901
+ *
902
+ * This capability publishes content to external platforms after deployment.
903
+ * Articles are syndicated one at a time (sequentially) to allow user review.
904
+ */
905
+ export async function syndicate(context: SyndicateContext): Promise<HookResult> {
906
+ setCurrentHookName("syndicate");
907
+ clearTokenCache();
908
+ await initializeDomain();
909
+
910
+ console.log("📡 Matters: Starting syndication...");
911
+
912
+ // Drive a moss Job for the syndication run (Step 3 Phase 5 Task 5.3).
913
+ // The verb/noun MEANING is declared in the manifest's `contributes.jobs`
914
+ // ("Syndicated" · "posts"); moss normalizes the verb (R13) and renders the
915
+ // receipt "Syndicated · N posts". A partial failure is PROPOSED as an
916
+ // advisory via `task.advise(...)`; moss holds the severity gavel.
917
+ // Syndication runs after deploy (no onboarding gesture), so the trigger is
918
+ // "background" — the quiet Workspace+Ambient surface. moss does not stamp a
919
+ // `trigger` on SyndicateContext (unlike ProcessContext), so we choose the
920
+ // safe quiet default directly rather than reading a field that isn't there.
921
+ const task = await startTask("Syndicate", {
922
+ hook: "syndicate",
923
+ trigger: "background",
924
+ hasProgress: true,
925
+ cancellable: false,
926
+ // Reference the manifest's `contributes.jobs.syndicate` descriptor
927
+ // ("Syndicated" · "posts"). moss normalizes the verb (R13) and, on the
928
+ // terminal `succeeded(_, count)`, renders "Syndicated · N posts" from its
929
+ // OWN verb + amount — we no longer hand it a pre-formatted string.
930
+ job: "syndicate",
931
+ });
932
+
933
+ try {
934
+ if (!context.deployment) {
935
+ await task.failed("No deployment information available");
936
+ return {
937
+ success: false,
938
+ message: "No deployment information available",
939
+ };
940
+ }
941
+
942
+ const { url: siteUrl, deployed_at } = context.deployment;
943
+ const { articles } = context;
944
+
945
+ // Filter to only articles that don't already have a Matters syndication URL
946
+ const articlesToSyndicate = articles.filter((article) => {
947
+ const syndicated = (article.frontmatter.syndicated as string[] | undefined) || [];
948
+ return !syndicated.some((url: string) => isMattersUrl(url));
949
+ });
950
+
951
+ if (articlesToSyndicate.length === 0) {
952
+ console.log("ℹ️ No new articles to syndicate (all already syndicated to Matters)");
953
+ await task.succeeded("No new articles to syndicate");
954
+ return {
955
+ success: true,
956
+ message: "No new articles to syndicate",
957
+ };
958
+ }
959
+
960
+ console.log(`📡 Syndicating ${articlesToSyndicate.length} article(s) to Matters.town`);
961
+ console.log(`🌐 Deployed site: ${siteUrl}`);
962
+ console.log(`📅 Deployed at: ${deployed_at}`);
963
+
964
+ // Check the session. Syndication is user-initiated by construction
965
+ // (every trigger site is inside the user-clicked publish flow) and
966
+ // write-scoped: no public fallback exists, so any non-valid session
967
+ // goes straight to login.
968
+ const sessionState = await getSessionState();
969
+ if (sessionState !== "valid") {
970
+ console.log(`🔐 Session state: ${sessionState}, prompting login...`);
971
+ // Law 2: login-required is a blocking auth state — must persist.
972
+ // Law 4: dismiss the toast if login succeeds so a resolved warning
973
+ // doesn't linger alongside the terminal ack.
974
+ await showToast({ message: "Matters login required", variant: "warning", persistent: true, id: "matters-login-required" });
975
+ // Suspend the Rust 60s inactivity watchdog. Follow with a quiet progress
976
+ // label (spec: no Awaiting amber pulse for in-panel login).
977
+ await task.awaiting("Connect to Matters", "", "cancel");
978
+ await task.progress(undefined, "Connect to Matters");
979
+ const loginSuccess = await promptLogin();
980
+ if (loginSuccess) {
981
+ await dismissToast("matters-login-required");
982
+ }
983
+ if (!loginSuccess) {
984
+ // Propose an actionable account advisory: the user must sign in to
985
+ // finish. moss holds the gavel — a NeedsAction stays a quiet dot, an
986
+ // actionable Blocking pops the panel; here we let moss decide.
987
+ await task.advise({
988
+ scope: "Account",
989
+ severity: "NeedsAction",
990
+ item: null,
991
+ what: "Sign in to Matters to syndicate your posts",
992
+ action: { InApp: { op: "SignIn", args: null, label: "Sign in" } },
993
+ });
994
+ await task.failed("Login required for syndication");
995
+ return {
996
+ success: false,
997
+ message: "Login required for syndication",
998
+ };
999
+ }
1000
+ }
1001
+
1002
+ // Get userName from config or profile
1003
+ const pluginConfig = await getConfig();
1004
+ let userName = pluginConfig.userName;
1005
+ if (!userName) {
1006
+ const profile = await fetchUserProfile();
1007
+ userName = profile.userName;
1008
+ }
1009
+
1010
+ const config = context.config || {};
1011
+ const addCanonicalLink = config.add_canonical_link ?? true;
1012
+ const lang = context.project_info.lang ?? "en";
1013
+
1014
+ // Syndicate articles sequentially (one at a time for user review)
1015
+ let published = 0;
1016
+ let draftsCreated = 0;
1017
+ const errors: string[] = [];
1018
+
1019
+ const totalToSyndicate = articlesToSyndicate.length;
1020
+ let processed = 0;
1021
+ for (const article of articlesToSyndicate) {
1022
+ await task.progress(processed / totalToSyndicate, `Syndicating ${article.title}`);
1023
+ try {
1024
+ // Verify article is actually live at its deployed URL before syndicating.
1025
+ // Prevents publishing broken links (e.g., new article not yet deployed,
1026
+ // or GitHub Pages build failed).
1027
+ const live = await isArticleLive(siteUrl, article.url_path);
1028
+ if (!live) {
1029
+ console.log(` ⏭ Skipping ${article.title} — not yet live at ${siteUrl}/${article.url_path}`);
1030
+ processed++;
1031
+ continue;
1032
+ }
1033
+
1034
+ const result = await syndicateArticle(article, siteUrl, userName, {
1035
+ addCanonicalLink: addCanonicalLink as boolean,
1036
+ lang,
1037
+ }, task);
1038
+
1039
+ if (result.publishedUrl) {
1040
+ published++;
1041
+ } else {
1042
+ draftsCreated++;
1043
+ }
1044
+ } catch (error) {
1045
+ if (error instanceof MattersAuthError) throw error; // session is dead: abort the run
1046
+ console.error(` ✗ Failed to syndicate ${article.title}:`, error);
1047
+ errors.push(`${article.title}: ${error}`);
1048
+ }
1049
+ processed++;
1050
+ }
1051
+
1052
+ const parts: string[] = [];
1053
+ if (published > 0) parts.push(`${published} published`);
1054
+ if (draftsCreated > 0) parts.push(`${draftsCreated} drafts created`);
1055
+ if (errors.length > 0) parts.push(`${errors.length} failed`);
1056
+
1057
+ const summary = parts.join(", ");
1058
+
1059
+ // A partial failure is PROPOSED as a per-run advisory (R13); moss clamps
1060
+ // it. ShippedDegraded ⇒ a quiet hairline dot (the run still succeeded).
1061
+ if (errors.length > 0) {
1062
+ await task.advise({
1063
+ scope: "Remote",
1064
+ severity: "ShippedDegraded",
1065
+ item: null,
1066
+ what: `${errors.length} post(s) could not be syndicated: ${errors[0]}`,
1067
+ action: "None",
1068
+ });
1069
+ }
1070
+
1071
+ // Terminal: report the COUNT, not a pre-formatted string. moss owns the
1072
+ // receipt — it pairs the manifest's normalized verb ("Syndicated") with
1073
+ // this count + the declared noun ("posts") to render "Syndicated · N posts"
1074
+ // from its OWN value objects (Step 3 Phase 5, §8 + R13).
1075
+ // #808: a created-but-unpublished draft is NOT a syndication. Only an
1076
+ // API-confirmed publish (draft.article non-null) counts toward the success
1077
+ // receipt/toast. A draft saved for later is surfaced as a NeedsAction advisory.
1078
+ const syndicatedCount = published;
1079
+
1080
+ if (errors.length > 0) {
1081
+ console.warn(`⚠️ Syndication complete: ${summary}`);
1082
+ } else {
1083
+ console.log(`✅ Syndication complete: ${summary}`);
1084
+ }
1085
+ await task.succeeded(undefined, syndicatedCount);
1086
+
1087
+ // One terminal L3 shelf ack — the durable positive result (Law 3).
1088
+ // task.succeeded() already rendered "Syndicated · N posts" in L1;
1089
+ // this showToast({ variant: 'success' }) is the shelf record with the
1090
+ // "View profile" action link.
1091
+ // NOTE: showAck() is a frontend-only function (toast-manager.ts) and is
1092
+ // NOT exported from @symbiosis-lab/moss-api. Use showToast({ variant: 'success' }).
1093
+ if (syndicatedCount > 0) {
1094
+ const profileUrl = `https://${getDomain()}/@${userName}`;
1095
+ await showToast({
1096
+ message: `Syndicated ${syndicatedCount} article${syndicatedCount === 1 ? "" : "s"} to Matters`,
1097
+ variant: "success",
1098
+ persistent: true,
1099
+ actions: [{ label: "View profile", url: profileUrl }],
1100
+ });
1101
+ }
1102
+
1103
+ return {
1104
+ success: true,
1105
+ message: `Syndication: ${summary}`,
1106
+ };
1107
+ } catch (error) {
1108
+ if (error instanceof MattersAuthError) {
1109
+ // Same contract as the process hook's catch: the server revoked the
1110
+ // token mid-run; fail the task with honest copy (recoverable=true —
1111
+ // a re-login fixes it) and nudge the session-expired surface.
1112
+ const message = "session expired, log in again to publish.";
1113
+ await notifySessionExpired();
1114
+ await task.failed(message, true);
1115
+ console.error("❌ Matters: syndication aborted, session rejected by server");
1116
+ return { success: false, message };
1117
+ }
1118
+ const cause = error instanceof Error ? error.message : String(error);
1119
+ console.error("❌ Matters: Syndication failed:", cause);
1120
+ await task.failed(`Syndication failed: ${cause}`);
1121
+ return {
1122
+ success: false,
1123
+ message: `Syndication failed: ${cause}`,
1124
+ };
1125
+ }
1126
+ }
1127
+
1128
+ /**
1129
+ * Check if an article is live at its deployed URL.
1130
+ *
1131
+ * Sends a HEAD request to the derived URL. Returns true if the server
1132
+ * responds with a 2xx status, false otherwise (404, network error, etc.).
1133
+ *
1134
+ * Used before syndication to avoid publishing links to articles that
1135
+ * haven't been deployed yet (e.g., new articles during concurrent syndication).
1136
+ */
1137
+ export async function isArticleLive(siteUrl: string, articleUrlPath: string): Promise<boolean> {
1138
+ const base = siteUrl.replace(/\/$/, "");
1139
+ const path = articleUrlPath.replace(/^\//, "");
1140
+ const fullUrl = `${base}/${path}`;
1141
+ try {
1142
+ const response = await fetch(fullUrl, { method: "HEAD" });
1143
+ return response.ok;
1144
+ } catch {
1145
+ return false;
1146
+ }
1147
+ }
1148
+
1149
+ // ============================================================================
1150
+ // Syndication Helpers
1151
+ // ============================================================================
1152
+
1153
+ /**
1154
+ * Syndicate a single article to Matters.town
1155
+ *
1156
+ * Workflow:
1157
+ * 1. Upload cover image if present in frontmatter
1158
+ * 2. Create draft via API
1159
+ * 3. Open draft in browser for user to review
1160
+ * 4. Poll for publish state change
1161
+ * 5. On publish: close browser, update local frontmatter
1162
+ * 6. On timeout: close browser, leave draft for later
1163
+ *
1164
+ * Exported for unit testing.
1165
+ */
1166
+ export async function syndicateArticle(
1167
+ article: ArticleInfo,
1168
+ siteUrl: string,
1169
+ userName: string,
1170
+ options: { addCanonicalLink: boolean; lang: string },
1171
+ task: TaskHandle,
1172
+ ): Promise<{ draftId: string; publishedUrl?: string }> {
1173
+ console.log(` → Syndicating: ${article.title}`);
1174
+
1175
+ const canonicalUrl = `${siteUrl.replace(/\/$/, "")}/${article.url_path.replace(/^\//, "")}`;
1176
+
1177
+ // Step 1: Get content
1178
+ const { content: articleContent, isHtml } = getArticleContent(article);
1179
+ let content = articleContent;
1180
+
1181
+ // Step 2: Normalize HTML (headings + image wrapping) — only for HTML content.
1182
+ // Strip moss's auto-injected article-title <h1> first (matters has its own
1183
+ // title field, so leaving the h1 in the body produces a visible duplicate
1184
+ // in the matters draft).
1185
+ if (isHtml) {
1186
+ content = stripArticleTitleH1(content, article.title);
1187
+ content = normalizeHtmlForMatters(content);
1188
+ }
1189
+
1190
+ // Step 3: Add canonical link with lang
1191
+ if (options.addCanonicalLink) {
1192
+ content = addCanonicalLinkToContent(content, canonicalUrl, isHtml, options.lang);
1193
+ }
1194
+
1195
+ // Step 4: Absolutize relative <a href> values against the article URL.
1196
+ // Without this, matters.town serves `<a href="../../foo.html">` from its own
1197
+ // domain and the link 404s. Same article-relative resolution rule as asset
1198
+ // srcs. Asset BYTE uploads happen post-draft (Step 8) — Matters'
1199
+ // singleFileUpload requires `entityId` for embeds, just as it does for cover.
1200
+ if (isHtml) {
1201
+ content = absolutizeRelativeHrefs(content, canonicalUrl);
1202
+ // Restructure moss audio embeds into matters' `<figure class="audio">` shape
1203
+ // and absolutize the `<source>` src to the deployed URL. That absolutized URL
1204
+ // is the FALLBACK — Step 8 then uploads the audio bytes and swaps in the
1205
+ // durable matters CDN URL on success. Without this wrap matters strips moss's
1206
+ // bare `<audio>` entirely. See wrapAudioForMatters.
1207
+ content = wrapAudioForMatters(content, canonicalUrl);
1208
+ }
1209
+
1210
+ // Step 5: Check for existing tracked draft
1211
+ const existingDraftId = article.source_path ? await getDraftId(article.source_path) : undefined;
1212
+ if (existingDraftId) {
1213
+ console.log(` 📋 Found existing draft ID: ${existingDraftId}`);
1214
+ }
1215
+
1216
+ // Step 6: Create/update draft via API (with optional summary from description)
1217
+ const summary = article.frontmatter.description as string | undefined;
1218
+ const draftInput = {
1219
+ title: article.title,
1220
+ content,
1221
+ tags: article.tags,
1222
+ ...(existingDraftId ? { id: existingDraftId } : {}),
1223
+ ...(summary ? { summary } : {}),
1224
+ };
1225
+
1226
+ let draft;
1227
+ try {
1228
+ draft = await createDraft(draftInput);
1229
+ } catch (error) {
1230
+ if (existingDraftId) {
1231
+ // Stale draft ID — fall back to creating a new draft without id
1232
+ console.warn(` ⚠️ Existing draft ${existingDraftId} failed, creating new draft: ${error}`);
1233
+ const { id: _removed, ...inputWithoutId } = draftInput;
1234
+ draft = await createDraft(inputWithoutId);
1235
+ } else {
1236
+ throw error;
1237
+ }
1238
+ }
1239
+
1240
+ console.log(` 📝 Draft ${existingDraftId ? "updated" : "created"} with ID: ${draft.id}`);
1241
+
1242
+ // Step 7: Upload cover if present in frontmatter (requires draft ID as entityId).
1243
+ // Cover paths are conventionally site-relative (e.g. `/og-image.png` or
1244
+ // `assets/covers/foo.jpg`). We read the BYTES from the local build output and
1245
+ // upload them directly — matters' server cannot reliably fetch covers by URL
1246
+ // from a deployed site (see uploadAssetMultipart).
1247
+ const coverPath = article.frontmatter.cover as string | undefined;
1248
+ if (coverPath) {
1249
+ try {
1250
+ const coverSitePath = decodeURIComponent(coverPath.replace(/^\//, ""));
1251
+ const base64 = await readSiteFile(coverSitePath);
1252
+ const filename = coverSitePath.split("/").pop() || "cover";
1253
+ const coverAsset = await uploadAssetMultipart(
1254
+ base64,
1255
+ filename,
1256
+ imageMimeForPath(coverSitePath),
1257
+ "cover",
1258
+ draft.id,
1259
+ );
1260
+ console.log(` 🖼️ Cover uploaded (bytes): ${coverAsset.id}`);
1261
+ // Update draft with cover
1262
+ await createDraft({ id: draft.id, title: draft.title, cover: coverAsset.id });
1263
+ console.log(` 🖼️ Draft updated with cover`);
1264
+ } catch (error) {
1265
+ console.warn(` ⚠️ Cover upload failed, continuing without cover: ${error}`);
1266
+ }
1267
+ }
1268
+
1269
+ // Step 8: Upload embedded body images and re-put the draft with CDN URLs.
1270
+ // Same `entityId` requirement as cover, so this also has to wait until the
1271
+ // draft exists. The first putDraft above sent the relative-src content; if
1272
+ // any uploads succeed, this overwrites the body with the CDN-rewritten one.
1273
+ //
1274
+ // Wrapped in try/catch to match the cover flow's "continue on failure"
1275
+ // semantics. Without this, a single re-put error (e.g. matters API 5xx)
1276
+ // would skip the toast/openBrowser path and leave the user looking at a
1277
+ // generic syndication failure instead of the still-usable draft.
1278
+ if (isHtml) {
1279
+ try {
1280
+ let rewritten = await uploadAndReplaceLocalImages(content, canonicalUrl, draft.id);
1281
+ // Upload audio bytes too (embedaudio). The figure.audio src is currently
1282
+ // the absolutized deployed URL (from wrapAudioForMatters); on success this
1283
+ // swaps it for the durable matters CDN URL, on failure it stays as the
1284
+ // streamed site URL.
1285
+ rewritten = await uploadAndReplaceLocalAudio(rewritten, canonicalUrl, draft.id);
1286
+ if (rewritten !== content) {
1287
+ await createDraft({ id: draft.id, title: draft.title, content: rewritten });
1288
+ console.log(` 🖼️ Draft updated with uploaded asset URLs`);
1289
+ }
1290
+ } catch (error) {
1291
+ console.warn(` ⚠️ Asset upload step failed, draft body keeps original srcs: ${error}`);
1292
+ }
1293
+ }
1294
+
1295
+ // Step 9: Open draft in browser for user review; then signal L1 "waiting for you"
1296
+ // Law 3: waitForPublishOrClose is textbook Awaiting semantics.
1297
+ // task.awaiting() fires AFTER openBrowser() so the amber dot appears when
1298
+ // the editor is already visible (semantically accurate: "waiting for you IN
1299
+ // Matters editor" — the editor is open when the wait starts).
1300
+ // task.awaiting() also suspends the Rust 60s inactivity watchdog, so a slow
1301
+ // user is never killed by inactivity while the browser is open.
1302
+ const draftPageUrl = draftUrl(draft.id);
1303
+ console.log(` 🌐 Opening draft for review: ${draftPageUrl}`);
1304
+ // Project THIS folder's token into the matters cookie so the draft-room
1305
+ // webview authenticates as the bound account (the cookie is the webview's
1306
+ // only credential — see credential.prepareWebviewAuth), not whoever logged
1307
+ // in last in the process-shared WebKit store.
1308
+ await prepareWebviewAuth();
1309
+ const browserHandle = await openBrowser(draftPageUrl);
1310
+ await task.awaiting("publish the draft", "Matters editor", "cancel");
1311
+
1312
+ // Step 10: Poll for publish state change — resolves only on publish or browser close.
1313
+ // No wall-clock ceiling: the 60s Rust inactivity watchdog is suspended by the
1314
+ // task.awaiting() signal above, so the hook waits as long as the user needs.
1315
+ const publishedArticle = await waitForPublishOrClose(draft.id, browserHandle);
1316
+
1317
+ if (publishedArticle) {
1318
+ // Step 11: Article was published - update local frontmatter
1319
+ const publishedUrl = articleUrl(userName, publishedArticle.slug, publishedArticle.shortHash);
1320
+ console.log(` ✅ Published: ${publishedUrl}`);
1321
+
1322
+ // Update the local markdown file's frontmatter
1323
+ if (article.source_path) {
1324
+ await updateFrontmatterSyndicated(article.source_path, publishedUrl);
1325
+ console.log(` 📝 Updated frontmatter with syndicated URL`);
1326
+ }
1327
+
1328
+ // Remove draft from tracking (published successfully)
1329
+ if (article.source_path) {
1330
+ try {
1331
+ await removeDraftId(article.source_path);
1332
+ } catch (err) {
1333
+ console.warn(` ⚠️ Failed to remove draft tracking: ${err}`);
1334
+ }
1335
+ }
1336
+
1337
+ return { draftId: draft.id, publishedUrl };
1338
+ }
1339
+
1340
+ // Step 12: Closed/timeout without publishing — signal the ship conductor.
1341
+ // Emit 'matters-room-skipped' so the conductor in the main shell can advance
1342
+ // the ring segment for matters as 'skipped' (not 'failed'). The tauri_bridge
1343
+ // special-cases this event to broadcast to all windows (not just the browser
1344
+ // panel), matching how 'email-room-skipped' reaches the conductor. Best-effort:
1345
+ // failure to emit must not abort the cleanup below.
1346
+ try {
1347
+ await emitEvent("matters-room-skipped");
1348
+ } catch (err) {
1349
+ console.warn(` ⚠️ Failed to emit matters-room-skipped: ${err}`);
1350
+ }
1351
+
1352
+ // R9: Post-settle reconciliation — close/skip won the race but the article
1353
+ // may have been published in the same window (e.g. user published → closed
1354
+ // fast enough for close to win). Avoid leaving a misleading "Draft saved"
1355
+ // advisory when the article is actually live. Best-effort: a network failure
1356
+ // here must not abort the cleanup.
1357
+ let latePublish: { shortHash: string; slug: string } | null = null;
1358
+ try {
1359
+ const reconcileDraft = await fetchDraft(draft.id);
1360
+ if (reconcileDraft?.article) {
1361
+ latePublish = {
1362
+ shortHash: reconcileDraft.article.shortHash,
1363
+ slug: reconcileDraft.article.slug,
1364
+ };
1365
+ console.log(` 🔄 R9 reconciliation: article was published despite close/timeout`);
1366
+ }
1367
+ } catch (err) {
1368
+ console.warn(` ⚠️ R9 reconciliation check failed: ${err}`);
1369
+ }
1370
+
1371
+ if (latePublish) {
1372
+ // Article is actually live — update frontmatter and return as published.
1373
+ const publishedUrl = articleUrl(userName, latePublish.slug, latePublish.shortHash);
1374
+ if (article.source_path) {
1375
+ await updateFrontmatterSyndicated(article.source_path, publishedUrl).catch((err) => {
1376
+ console.warn(` ⚠️ R9: Failed to update frontmatter: ${err}`);
1377
+ });
1378
+ await removeDraftId(article.source_path).catch(() => {});
1379
+ }
1380
+ await task.advise({
1381
+ scope: "Remote",
1382
+ severity: "ShippedDegraded",
1383
+ item: article.title,
1384
+ what: "Article published on Matters — frontmatter will sync",
1385
+ action: { Link: { href: publishedUrl, label: "View article" } },
1386
+ });
1387
+ return { draftId: draft.id, publishedUrl };
1388
+ }
1389
+
1390
+ // Save draft ID for reuse next time
1391
+ if (article.source_path) {
1392
+ try {
1393
+ await saveDraftId(article.source_path, draft.id);
1394
+ console.log(` 💾 Draft ID saved for reuse`);
1395
+ } catch (err) {
1396
+ console.warn(` ⚠️ Failed to save draft tracking: ${err}`);
1397
+ }
1398
+ }
1399
+ console.log(` ⏱️ Publish timeout/close — draft saved for later`);
1400
+ // Draft timed out or user closed without publish; leave an actionable advisory
1401
+ // in the pill popover (Law 2: actionable state must not auto-fade in 5s).
1402
+ // advise() accumulates on the handle and flushes when task.succeeded() is
1403
+ // called after the loop — this is correct SDK behavior, not a leak.
1404
+ await task.advise({
1405
+ scope: "Remote",
1406
+ severity: "NeedsAction",
1407
+ item: article.title,
1408
+ what: "Draft saved — publish it on Matters when ready",
1409
+ action: { Link: { href: draftUrl(draft.id), label: "Open draft" } },
1410
+ });
1411
+ return { draftId: draft.id };
1412
+ }
1413
+
1414
+ /**
1415
+ * Wait for draft to be published or the browser to be closed.
1416
+ *
1417
+ * Rewritten (R6) from a `while`-loop into a single `new Promise` executor
1418
+ * with a shared `settle()` guard so exactly ONE resolution wins. Three racing
1419
+ * branches all call `settle`:
1420
+ *
1421
+ * (a) Poll loop — sleep(5s) then fetchDraft; if draft.article → settle published.
1422
+ * Uses the `sleep` utility so tests can mock it to a no-op.
1423
+ * (b) browserHandle.closed → settle(null) [user closed the editor].
1424
+ * (c) onEvent('browser-url-changed') — if URL looks like a published article,
1425
+ * immediately call fetchDraft; if draft.article → settle published.
1426
+ * The URL is a TRIGGER; the API is the source of truth.
1427
+ *
1428
+ * There is NO wall-clock ceiling. The wait terminates only when:
1429
+ * - the article is confirmed published (a or c), or
1430
+ * - the user closes the browser (b).
1431
+ * Crash recovery is handled by the 60s Rust inactivity watchdog, which is
1432
+ * suspended while the hook signals Awaiting (via task.awaiting() in the
1433
+ * caller) — so a slow user is never killed by inactivity.
1434
+ *
1435
+ * Cleanup (url-listener) is guaranteed on EVERY resolution path.
1436
+ * The poll loop exits naturally once `settled` is true. A leaked
1437
+ * `onEvent` listener double-fires on the next article — unlisten is mandatory.
1438
+ *
1439
+ * Returns { shortHash, slug } on confirmed publish; null on browser close.
1440
+ *
1441
+ * NOTE: Matters is currently the last channel. When multi-channel ordering
1442
+ * changes, "done detection finishes the ship" must be revisited.
1443
+ *
1444
+ * @internal exported for unit tests only
1445
+ */
1446
+ export async function waitForPublishOrClose(
1447
+ draftId: string,
1448
+ browserHandle?: BrowserHandle
1449
+ ): Promise<{ shortHash: string; slug: string } | null> {
1450
+ console.log(` ⏳ Waiting for publish (no wall-clock ceiling — resolves on publish or browser close)...`);
1451
+
1452
+ const pollIntervalMs = 5000; // 5 seconds
1453
+
1454
+ return new Promise<{ shortHash: string; slug: string } | null>((resolve) => {
1455
+ let settled = false;
1456
+ let unlistenUrl: (() => void) | undefined;
1457
+
1458
+ function settle(value: { shortHash: string; slug: string } | null): void {
1459
+ if (settled) return;
1460
+ settled = true;
1461
+
1462
+ // Cleanup: clear the URL listener so it does not fire again on a
1463
+ // subsequent article. The poll loop exits via the `settled` guard.
1464
+ if (unlistenUrl) unlistenUrl();
1465
+
1466
+ if (value) {
1467
+ console.log(` 🎉 Publish detected!`);
1468
+ // R19 — Held confirmation beat: signal the shell bar first, hold
1469
+ // ~800ms so the "Published to Matters" bar is visible, THEN close
1470
+ // the browser. Prevents the "moss stole my tab" feeling. The 800ms
1471
+ // uses the same `sleep` helper as the poll loop so tests can mock it.
1472
+ (async () => {
1473
+ try {
1474
+ await emitEvent("matters-room-published");
1475
+ } catch (err) {
1476
+ console.warn(` ⚠️ Failed to emit matters-room-published: ${err}`);
1477
+ }
1478
+ await sleep(800);
1479
+ closeBrowser().catch(() => { /* already closed */ });
1480
+ resolve(value);
1481
+ })();
1482
+ } else {
1483
+ resolve(value);
1484
+ }
1485
+ }
1486
+
1487
+ // Branch (a): poll loop — uses sleep() so tests can mock it to a no-op.
1488
+ (async () => {
1489
+ while (!settled) {
1490
+ await sleep(pollIntervalMs);
1491
+ if (settled) break;
1492
+ try {
1493
+ const draft = await fetchDraft(draftId);
1494
+ if (draft?.article) {
1495
+ settle({ shortHash: draft.article.shortHash, slug: draft.article.slug });
1496
+ }
1497
+ } catch (err) {
1498
+ console.warn(` ⚠️ Error checking draft status: ${err}`);
1499
+ }
1500
+ }
1501
+ })();
1502
+
1503
+ // Branch (b): browser close — user explicitly dismissed the editor.
1504
+ if (browserHandle) {
1505
+ browserHandle.closed.then(() => {
1506
+ console.log(` 🚪 Browser closed by user`);
1507
+ settle(null);
1508
+ });
1509
+ }
1510
+
1511
+ // Branch (c): URL-triggered immediate verify (latency optimisation; API is truth)
1512
+ onEvent<{ url: string }>("browser-url-changed", async (payload) => {
1513
+ // guard the leaked-listener race: if we've already settled (e.g. unlisten not yet stored), do nothing
1514
+ if (settled) return;
1515
+ const url = payload.url;
1516
+ console.log("[matters] browser-url-changed", url);
1517
+ if (!looksLikePublishedArticleUrl(url)) return;
1518
+ try {
1519
+ const draft = await fetchDraft(draftId);
1520
+ if (draft?.article) {
1521
+ settle({ shortHash: draft.article.shortHash, slug: draft.article.slug });
1522
+ }
1523
+ } catch (err) {
1524
+ console.warn(` ⚠️ URL-triggered verify failed: ${err}`);
1525
+ }
1526
+ }).then((fn) => {
1527
+ unlistenUrl = fn;
1528
+ }).catch((err) => {
1529
+ // If onEvent fails (e.g. no Tauri runtime in test env), log and continue.
1530
+ // The API poll provides the correctness backstop regardless.
1531
+ console.warn(` ⚠️ Could not register browser-url-changed listener: ${err}`);
1532
+ });
1533
+
1534
+ // NOTE: There is intentionally no branch (d) wall-clock timeout.
1535
+ // The Rust inactivity watchdog (60s, awaiting-aware) is the sole crash guard.
1536
+ // A slow user writing their Matters draft is never killed by a deadline.
1537
+ });
1538
+ }
1539
+
1540
+ /**
1541
+ * Update the syndicated field in article frontmatter
1542
+ */
1543
+ async function updateFrontmatterSyndicated(
1544
+ filePath: string,
1545
+ publishedUrl: string
1546
+ ): Promise<void> {
1547
+ try {
1548
+ const content = await readFile(filePath);
1549
+ const parsed = parseFrontmatter(content);
1550
+
1551
+ if (!parsed) {
1552
+ console.warn(` ⚠️ Could not parse frontmatter for ${filePath}`);
1553
+ return;
1554
+ }
1555
+
1556
+ // Add to syndicated array if not already present
1557
+ const syndicated = (parsed.frontmatter.syndicated as string[]) || [];
1558
+ if (!syndicated.includes(publishedUrl)) {
1559
+ syndicated.push(publishedUrl);
1560
+ parsed.frontmatter.syndicated = syndicated;
1561
+ }
1562
+
1563
+ // Regenerate file with updated frontmatter
1564
+ const newContent = regenerateFrontmatter(parsed.frontmatter) + "\n\n" + parsed.body;
1565
+ await writeFile(filePath, newContent);
1566
+ } catch (error) {
1567
+ console.warn(` ⚠️ Failed to update frontmatter: ${error}`);
1568
+ }
1569
+ }
1570
+
1571
+ /**
1572
+ * Get the best content from an article for syndication.
1573
+ * Prefers rendered HTML (for platforms like Matters that expect HTML),
1574
+ * falls back to markdown content.
1575
+ */
1576
+ export function getArticleContent(article: ArticleInfo): { content: string; isHtml: boolean } {
1577
+ if (article.html_content) {
1578
+ return { content: article.html_content, isHtml: true };
1579
+ }
1580
+ return { content: article.content, isHtml: false };
1581
+ }
1582
+
1583
+ /**
1584
+ * Normalize HTML content for Matters.town compatibility.
1585
+ *
1586
+ * Matters only accepts h2 and h3 headings. This function:
1587
+ * - Downgrades h1 → h2
1588
+ * - Keeps h2 and h3 unchanged
1589
+ * - Collapses h4, h5, h6 → h3 (to prevent removal by Matters)
1590
+ *
1591
+ * Image wrapping is also matters-specific. Matters' server-side HTML
1592
+ * sanitizer strips any `<img>` not inside `<figure class="image">` with
1593
+ * a `<figcaption>` child, and also strips `<figure>` with any other
1594
+ * class (`moss-image`, plain `<figure>`, etc.). Smoke test against
1595
+ * `server.matters.icu` on 2026-05-27 confirmed this contract empirically
1596
+ * (see `.credentials/accounts.md` for the test wallet).
1597
+ *
1598
+ * Phase 2A of the unified-image-emission migration (2026-05-25) removed
1599
+ * the plugin's matters-shape wrap on the assumption moss's
1600
+ * `<figure class="moss-image">` output would round-trip through matters.
1601
+ * It does not — matters strips that wrap entirely. So we restore the
1602
+ * wrap, but as a matters-specific pre-upload transform (not a
1603
+ * regression of moss-core's emission). See `wrapImagesForMatters`.
1604
+ */
1605
+ /**
1606
+ * Strip moss's auto-injected article-title `<h1 class="moss-article-title">`
1607
+ * when its plain text equals the article's title. matters.town has its own
1608
+ * title field on the draft, so leaving the h1 in the body content produces a
1609
+ * visible duplicate ("Title" rendered as the heading + "Title" rendered as
1610
+ * the matters page H1 above it).
1611
+ *
1612
+ * Tolerates other `<h1>` tags in the body — only removes the moss-class one,
1613
+ * and only when its content matches. Authors who genuinely want a leading H1
1614
+ * with the same text as the title can opt out by removing the moss class.
1615
+ *
1616
+ * Exported for unit testing.
1617
+ */
1618
+ export function stripArticleTitleH1(html: string, articleTitle: string): string {
1619
+ // Match <h1 ...class="...moss-article-title..."...>INNER</h1>
1620
+ const re = /<h1\b[^>]*class="[^"]*\bmoss-article-title\b[^"]*"[^>]*>([\s\S]*?)<\/h1>/gi;
1621
+ return html.replace(re, (full, inner: string) => {
1622
+ // Compare plain text — strip tags and collapse whitespace on both sides.
1623
+ const innerText = inner.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
1624
+ const titleText = articleTitle.replace(/\s+/g, " ").trim();
1625
+ return innerText === titleText ? "" : full;
1626
+ });
1627
+ }
1628
+
1629
+ /**
1630
+ * Absolutize relative `href` values on `<a>` elements against `baseUrl`.
1631
+ *
1632
+ * matters.town's editor preserves whatever URL we pass in the draft HTML.
1633
+ * Relative hrefs (e.g. `../../scale-compare.html`) end up resolved by the
1634
+ * matters editor against `https://matters.town/...`, breaking every internal
1635
+ * link from the original site. Resolve against the article's own canonical
1636
+ * URL so links retain their original meaning when viewed inside matters.
1637
+ *
1638
+ * - Skips absolute URLs (http://, https://, mailto:, data:, etc.)
1639
+ * - Skips fragment-only links (`#section`) — those reference the matters
1640
+ * draft's own headings and should stay intra-document.
1641
+ * - Skips hrefs we can't resolve (logs and leaves them alone).
1642
+ *
1643
+ * Exported for unit testing.
1644
+ */
1645
+ /**
1646
+ * Resolve a single URL against `baseUrl`, returning it UNCHANGED when it is
1647
+ * already absolute (has a scheme), scheme-relative (`//host`), a fragment
1648
+ * (`#x`), or cannot be parsed. Shared by `absolutizeRelativeHrefs` (links) and
1649
+ * `wrapAudioForMatters` (audio `<source>` srcs) so both resolve article-relative
1650
+ * references against the article's canonical URL the same way.
1651
+ */
1652
+ function absolutizeUrl(url: string, baseUrl: string): string {
1653
+ if (/^([a-z][a-z0-9+.-]*:|\/\/|#)/i.test(url)) {
1654
+ return url;
1655
+ }
1656
+ try {
1657
+ return new URL(url, baseUrl).href;
1658
+ } catch (error) {
1659
+ console.warn(` ⚠️ Could not resolve URL ${url} against ${baseUrl}: ${error}`);
1660
+ return url;
1661
+ }
1662
+ }
1663
+
1664
+ export function absolutizeRelativeHrefs(html: string, baseUrl: string): string {
1665
+ // Linear: scan each <a> tag, then rewrite its href within the short tag
1666
+ // string. One regex with [^>]*? … [^>]* around href= backtracks polynomially
1667
+ // on hostile input (CodeQL js/polynomial-redos); splitting the scan from the
1668
+ // attribute read removes the ambiguity. The function replacer keeps any `$`
1669
+ // in the URL literal.
1670
+ return html.replace(/<a\b[^>]*>/gi, (tag) =>
1671
+ tag.replace(/(\shref=")([^"]+)(")/i, (m, pre: string, href: string, post: string) => {
1672
+ const absolute = absolutizeUrl(href, baseUrl);
1673
+ return absolute === href ? m : `${pre}${absolute}${post}`;
1674
+ }),
1675
+ );
1676
+ }
1677
+
1678
+ /**
1679
+ * Strip moss's heading-anchor permalinks from headings.
1680
+ *
1681
+ * moss appends `<a class="moss-heading-anchor" href="#…"><span
1682
+ * aria-hidden="true">#</span></a>` to every heading for web navigation. On the
1683
+ * site the `#` is hover-only chrome (CSS), but matters' sanitizer keeps the
1684
+ * anchor's text, so headings syndicate as e.g. "1.#" (a stray, linked `#`).
1685
+ * The `#` is not content, so we remove the whole anchor before syndication.
1686
+ * Verified 2026-06-16 against `server.matters.icu`.
1687
+ *
1688
+ * Exported for unit testing.
1689
+ */
1690
+ export function stripHeadingAnchors(html: string): string {
1691
+ return html.replace(
1692
+ /<a\b[^>]*\bclass="[^"]*\bmoss-heading-anchor\b[^"]*"[^>]*>[\s\S]*?<\/a>/gi,
1693
+ "",
1694
+ );
1695
+ }
1696
+
1697
+ export function normalizeHtmlForMatters(html: string): string {
1698
+ let result = html;
1699
+
1700
+ // Step 0: Remove moss heading-anchor permalinks (web-only chrome whose `#`
1701
+ // would otherwise leak into matters' heading text). See stripHeadingAnchors.
1702
+ result = stripHeadingAnchors(result);
1703
+
1704
+ // Step 1: Collapse h4, h5, h6 → h3 (process these BEFORE h1 to avoid double-shifting)
1705
+ result = result.replace(/<(\/?)h[456](\s[^>]*)?>/gi, (_match, slash, attrs) => {
1706
+ return `<${slash}h3${attrs || ""}>`;
1707
+ });
1708
+
1709
+ // Step 2: Downgrade h1 → h2
1710
+ result = result.replace(/<(\/?)h1(\s[^>]*)?>/gi, (_match, slash, attrs) => {
1711
+ return `<${slash}h2${attrs || ""}>`;
1712
+ });
1713
+
1714
+ // Step 3: Wrap images in matters' required <figure class="image"> shell.
1715
+ result = wrapImagesForMatters(result);
1716
+
1717
+ return result;
1718
+ }
1719
+
1720
+ /**
1721
+ * Convert every moss image-emission pattern into matters' required shape:
1722
+ * `<figure class="image"><img src="..."><figcaption>...</figcaption></figure>`.
1723
+ *
1724
+ * Matters' server-side sanitizer is strict (smoke-tested 2026-05-27 against
1725
+ * `server.matters.icu`):
1726
+ *
1727
+ * - `<img>` outside a `<figure class="image">` → STRIPPED
1728
+ * - `<figure class="moss-image">` → STRIPPED (along with contents)
1729
+ * - `<figure>` (no class) → STRIPPED
1730
+ * - `<figure class="image">` without a `<figcaption>` child → causes a
1731
+ * server error ("Cannot read properties of undefined (reading 'firstChild')")
1732
+ * - `<figure class="image">` with `<figcaption>` (empty is fine) → KEPT
1733
+ * - `<picture>` inside `<figure class="image">` → kept on POST, server
1734
+ * normalizes to bare `<img>` on read
1735
+ *
1736
+ * So we restore the wrap the matters plugin used to apply pre-Phase-2A,
1737
+ * but adapted for moss's new emission patterns (`<picture>` wrappers and
1738
+ * `<figure class="moss-image">` shells).
1739
+ *
1740
+ * Exported for unit testing.
1741
+ */
1742
+ export function wrapImagesForMatters(html: string): string {
1743
+ let result = html;
1744
+
1745
+ // Step A: Rename `<figure class="moss-image">` → `<figure class="image">`,
1746
+ // adding an empty `<figcaption>` if the body doesn't already have one.
1747
+ result = result.replace(
1748
+ /<figure\b[^>]*\bclass="[^"]*\bmoss-image\b[^"]*"[^>]*>([\s\S]*?)<\/figure>/gi,
1749
+ (_full, body) => {
1750
+ const hasFigcap = /<figcaption\b/i.test(body);
1751
+ const bodyWithCap = hasFigcap ? body : `${body}<figcaption></figcaption>`;
1752
+ return `<figure class="image">${bodyWithCap}</figure>`;
1753
+ },
1754
+ );
1755
+
1756
+ // Helper: is `offset` inside an open `<figure>` or `<picture>` in `src`?
1757
+ // Looks back 400 chars (longer than any plausible single element start) for
1758
+ // an unclosed tag.
1759
+ const isInside = (src: string, offset: number, tag: "figure" | "picture"): boolean => {
1760
+ const preceding = src.substring(Math.max(0, offset - 400), offset);
1761
+ const openIdx = preceding.lastIndexOf(`<${tag}`);
1762
+ const closeIdx = preceding.lastIndexOf(`</${tag}`);
1763
+ return openIdx > closeIdx;
1764
+ };
1765
+
1766
+ // Step B: `<p>` containing only a `<picture>` → hoist to a figure wrap.
1767
+ // A `<figure>` inside a `<p>` is invalid HTML and matters' parser splits the
1768
+ // `<p>` around it, producing stray empty `<p></p>` siblings. Hoist first.
1769
+ result = result.replace(
1770
+ /<p>\s*(<picture\b[^>]*>[\s\S]*?<\/picture>)\s*<\/p>/gi,
1771
+ (_full, picture: string) => `<figure class="image">${picture}<figcaption></figcaption></figure>`,
1772
+ );
1773
+
1774
+ // Step C: Wrap remaining standalone `<picture>` blocks (not already inside
1775
+ // a `<figure>`). matters normalizes the picture down to just the `<img>` on
1776
+ // storage, but the wrap is what saves the image from being stripped.
1777
+ result = result.replace(
1778
+ /<picture\b[^>]*>[\s\S]*?<\/picture>/gi,
1779
+ (full, offset: number) => {
1780
+ if (isInside(result, offset, "figure")) return full;
1781
+ return `<figure class="image">${full}<figcaption></figcaption></figure>`;
1782
+ },
1783
+ );
1784
+
1785
+ // Step D: `<p>` containing only an `<img>` → same hoist as Step B.
1786
+ result = result.replace(
1787
+ /<p>\s*(<img\b[^>]*>)\s*<\/p>/gi,
1788
+ (_full, img: string) => `<figure class="image">${img}<figcaption></figcaption></figure>`,
1789
+ );
1790
+
1791
+ // Step E: Remaining bare `<img>` tags (not already inside a `<figure>` or
1792
+ // `<picture>`). After Step D this is rare — usually an inline image mixed
1793
+ // with text. We wrap regardless; matters would strip it otherwise.
1794
+ result = result.replace(/<img\b[^>]*>/gi, (imgTag, offset: number) => {
1795
+ if (isInside(result, offset, "figure")) return imgTag;
1796
+ if (isInside(result, offset, "picture")) return imgTag;
1797
+ return `<figure class="image">${imgTag}<figcaption></figcaption></figure>`;
1798
+ });
1799
+
1800
+ return result;
1801
+ }
1802
+
1803
+ /**
1804
+ * Convert moss's audio embed into matters' required `<figure class="audio">`
1805
+ * shape and absolutize the `<source>` URL against the article URL.
1806
+ *
1807
+ * moss emits a bare:
1808
+ * <audio class="moss-embed moss-embed-audio" controls preload="metadata">
1809
+ * <source src="REL" type="MIME">Your browser does not support…</audio>
1810
+ *
1811
+ * matters' server-side sanitizer STRIPS that entirely (the `<audio>` vanishes
1812
+ * and the fallback text leaks out as a stray `<p>`). The only audio shape it
1813
+ * keeps — verified 2026-06-16 against `server.matters.icu` — is:
1814
+ * <figure class="audio"><audio controls><source src="URL" type="MIME"></audio>
1815
+ * <figcaption></figcaption></figure>
1816
+ * with three hard requirements found empirically:
1817
+ * 1. the URL MUST be on a `<source>` child — a `src` on `<audio>` is dropped;
1818
+ * 2. a `<figcaption>` child is REQUIRED (its absence is a server error,
1819
+ * "Cannot read properties of undefined (reading 'firstChild')"); empty OK;
1820
+ * 3. matters keeps an EXTERNAL `<source src>` verbatim and its player streams
1821
+ * from it, so the absolutized deployed URL is a valid src on its own. This
1822
+ * is the FALLBACK: `uploadAndReplaceLocalAudio` (post-draft) then uploads
1823
+ * the audio bytes via `embedaudio` and swaps in the durable matters CDN URL
1824
+ * on success. (matters' `embedaudio` rejects url-upload, hence byte-upload.)
1825
+ *
1826
+ * moss does not yet emit audio captions, so the `<figcaption>` is always empty.
1827
+ *
1828
+ * Exported for unit testing.
1829
+ */
1830
+ export function wrapAudioForMatters(html: string, baseUrl: string): string {
1831
+ // moss audio is identified by the `moss-embed-audio` class. Capture the inner
1832
+ // markup (the `<source>` + fallback text) so we can extract the source.
1833
+ const audioPattern =
1834
+ '<audio\\b[^>]*\\bclass="[^"]*\\bmoss-embed-audio\\b[^"]*"[^>]*>([\\s\\S]*?)</audio>';
1835
+
1836
+ const buildFigure = (inner: string): string => {
1837
+ const srcMatch = inner.match(/<source\b[^>]*\bsrc="([^"]*)"[^>]*>/i);
1838
+ const src = srcMatch ? srcMatch[1] : "";
1839
+ const typeMatch = inner.match(/<source\b[^>]*\btype="([^"]*)"[^>]*>/i);
1840
+ const type = typeMatch ? typeMatch[1] : undefined;
1841
+
1842
+ const absSrc = absolutizeUrl(src, baseUrl);
1843
+ const sourceTag = type
1844
+ ? `<source src="${absSrc}" type="${type}">`
1845
+ : `<source src="${absSrc}">`;
1846
+ // Empty figcaption is mandatory (see requirement 2 above). The `<audio>`
1847
+ // fallback text node is intentionally dropped.
1848
+ return `<figure class="audio"><audio controls>${sourceTag}</audio><figcaption></figcaption></figure>`;
1849
+ };
1850
+
1851
+ let result = html;
1852
+ // Pass 1: hoist `<p>…audio…</p>` out of the paragraph — a `<figure>` inside a
1853
+ // `<p>` is invalid HTML and matters splits the `<p>`, leaving stray empties.
1854
+ result = result.replace(
1855
+ new RegExp(`<p>\\s*(?:${audioPattern})\\s*</p>`, "gi"),
1856
+ (_full, inner: string) => buildFigure(inner),
1857
+ );
1858
+ // Pass 2: any remaining standalone moss audio.
1859
+ result = result.replace(
1860
+ new RegExp(audioPattern, "gi"),
1861
+ (_full, inner: string) => buildFigure(inner),
1862
+ );
1863
+ return result;
1864
+ }
1865
+
1866
+ /**
1867
+ * Add canonical link to article content
1868
+ *
1869
+ * @param lang - Language code; when starting with "zh", uses Chinese text
1870
+ */
1871
+ export function addCanonicalLinkToContent(
1872
+ content: string,
1873
+ canonicalUrl: string,
1874
+ isHtml: boolean = false,
1875
+ lang?: string
1876
+ ): string {
1877
+ const isZh = lang?.startsWith("zh") ?? false;
1878
+ const linkText = isZh ? "原文链接" : "Original link";
1879
+
1880
+ if (isHtml) {
1881
+ return content + `<hr><p><a href="${canonicalUrl}">${linkText}</a></p>`;
1882
+ }
1883
+ const canonicalNotice = `\n\n---\n\n[${linkText}](${canonicalUrl})\n`;
1884
+ return content + canonicalNotice;
1885
+ }
1886
+
1887
+ /** Map an image file extension to the MIME type sent on upload. */
1888
+ export function imageMimeForPath(path: string): string {
1889
+ const ext = path.split(".").pop()?.toLowerCase() ?? "";
1890
+ switch (ext) {
1891
+ case "jpg":
1892
+ case "jpeg":
1893
+ return "image/jpeg";
1894
+ case "png":
1895
+ return "image/png";
1896
+ case "webp":
1897
+ return "image/webp";
1898
+ case "gif":
1899
+ return "image/gif";
1900
+ case "avif":
1901
+ return "image/avif";
1902
+ case "svg":
1903
+ return "image/svg+xml";
1904
+ default:
1905
+ return "application/octet-stream";
1906
+ }
1907
+ }
1908
+
1909
+ /** Map an audio file extension to the MIME type sent on upload (mirrors moss-core). */
1910
+ export function audioMimeForPath(path: string): string {
1911
+ const ext = path.split(".").pop()?.toLowerCase() ?? "";
1912
+ switch (ext) {
1913
+ case "mp3":
1914
+ return "audio/mpeg";
1915
+ case "wav":
1916
+ return "audio/wav";
1917
+ case "ogg":
1918
+ return "audio/ogg";
1919
+ case "flac":
1920
+ return "audio/flac";
1921
+ case "m4a":
1922
+ return "audio/mp4";
1923
+ case "opus":
1924
+ return "audio/opus";
1925
+ default:
1926
+ return "application/octet-stream";
1927
+ }
1928
+ }
1929
+
1930
+ /**
1931
+ * Resolve an asset `src` (as it appears in the rendered article HTML — a
1932
+ * site-absolute `/image/x.jpg`, an article-relative `../assets/x.jpg`, or an
1933
+ * already-absolutized same-origin URL) to a path relative to the deployed site
1934
+ * root, suitable for `readSiteFile`.
1935
+ *
1936
+ * Returns `null` for `data:` URIs and for any URL whose origin differs from the
1937
+ * site (e.g. an external CDN or an already-uploaded matters URL) — those are
1938
+ * not local build artifacts and must be left untouched.
1939
+ *
1940
+ * Exported for unit testing.
1941
+ */
1942
+ export function siteRelativePathFromSrc(src: string, baseUrl: string): string | null {
1943
+ if (/^data:/i.test(src)) return null;
1944
+ let resolved: URL;
1945
+ let base: URL;
1946
+ try {
1947
+ resolved = new URL(src, baseUrl);
1948
+ base = new URL(baseUrl);
1949
+ } catch {
1950
+ return null;
1951
+ }
1952
+ if (resolved.origin !== base.origin) return null;
1953
+ // pathname is percent-encoded; decode for the filesystem read.
1954
+ const path = decodeURIComponent(resolved.pathname.replace(/^\//, ""));
1955
+ return path || null;
1956
+ }
1957
+
1958
+ /**
1959
+ * Upload local images to Matters and replace their `<img src>` with the
1960
+ * returned matters CDN URL.
1961
+ *
1962
+ * Bytes are read from the LOCAL build output (`readSiteFile`) and uploaded
1963
+ * directly via multipart — matters' server cannot reliably fetch images by URL
1964
+ * from a deployed site (Caddy/moss-seta hosts return `UNABLE_TO_UPLOAD_FROM_URL`).
1965
+ *
1966
+ * - Skips external (other-origin) and `data:` srcs.
1967
+ * - Deduplicates: same src is uploaded once.
1968
+ * - Graceful fallback: on read/upload failure the src is rewritten to the
1969
+ * absolutized deployed URL (matters keeps an external `<img src>` and the
1970
+ * reader's browser loads it from the live site), so the image still displays
1971
+ * instead of breaking.
1972
+ *
1973
+ * @param content - HTML content containing img tags
1974
+ * @param baseUrl - The article's canonical URL (e.g. "https://example.com/posts/foo/"),
1975
+ * used both to resolve relative srcs and as the origin for site-local detection.
1976
+ * @param entityId - Draft ID the embeds attach to (matters requires it).
1977
+ * @returns HTML with local image srcs replaced by matters CDN (or absolutized) URLs
1978
+ */
1979
+ export async function uploadAndReplaceLocalImages(
1980
+ content: string,
1981
+ baseUrl: string,
1982
+ entityId: string,
1983
+ ): Promise<string> {
1984
+ // Linear tag scan + per-tag src read — avoids the polynomial backtracking of
1985
+ // two [^>]* around src= (CodeQL js/polynomial-redos). `\ssrc="` matches the
1986
+ // real src attribute (whitespace-anchored), not a data-src tail.
1987
+ const imgTagRegex = /<img\b[^>]*>/gi;
1988
+ const srcs = new Set<string>();
1989
+ let match: RegExpExecArray | null;
1990
+ while ((match = imgTagRegex.exec(content)) !== null) {
1991
+ const srcMatch = /\ssrc="([^"]+)"/i.exec(match[0]);
1992
+ if (srcMatch && !/^data:/i.test(srcMatch[1])) srcs.add(srcMatch[1]);
1993
+ }
1994
+ if (srcs.size === 0) return content;
1995
+
1996
+ const replacements = new Map<string, string>();
1997
+ for (const src of srcs) {
1998
+ const sitePath = siteRelativePathFromSrc(src, baseUrl);
1999
+ let fallbackUrl: string | undefined;
2000
+ try {
2001
+ fallbackUrl = new URL(src, baseUrl).href;
2002
+ } catch {
2003
+ fallbackUrl = undefined;
2004
+ }
2005
+
2006
+ if (!sitePath) {
2007
+ // External / cross-origin asset: at most absolutize so it isn't a broken
2008
+ // relative path; never try to upload it.
2009
+ if (fallbackUrl && fallbackUrl !== src) replacements.set(src, fallbackUrl);
2010
+ continue;
2011
+ }
2012
+
2013
+ try {
2014
+ const base64 = await readSiteFile(sitePath);
2015
+ const filename = sitePath.split("/").pop() || "image";
2016
+ const asset = await uploadAssetMultipart(base64, filename, imageMimeForPath(sitePath), "embed", entityId);
2017
+ replacements.set(src, asset.path);
2018
+ console.log(` 🖼️ Image uploaded (bytes): ${src} → ${asset.path}`);
2019
+ } catch (error) {
2020
+ const fb = fallbackUrl ?? src;
2021
+ replacements.set(src, fb);
2022
+ console.warn(` ⚠️ Image byte-upload failed for ${src}, using site URL ${fb}: ${error}`);
2023
+ }
2024
+ }
2025
+
2026
+ return applySrcReplacements(content, replacements);
2027
+ }
2028
+
2029
+ /**
2030
+ * Upload local audio to Matters and replace the `<source src>` inside
2031
+ * `<figure class="audio">` with the returned matters CDN URL.
2032
+ *
2033
+ * Mirrors {@link uploadAndReplaceLocalImages} but for `type:"embedaudio"`. At
2034
+ * this point the `<source src>` is already the absolutized deployed URL (set by
2035
+ * `wrapAudioForMatters`), which is the fallback: on success it becomes the
2036
+ * durable matters CDN URL; on failure it stays as the streamed site URL.
2037
+ *
2038
+ * Scoped to `<source src>` INSIDE `<figure class="audio">` so a hand-authored
2039
+ * raw-HTML `<video><source src>` block elsewhere is never mistaken for audio.
2040
+ * (`<picture>` variants use `srcset`, not `src`, so they wouldn't match anyway.)
2041
+ *
2042
+ * @returns HTML with local audio srcs replaced by matters CDN URLs where possible
2043
+ */
2044
+ export async function uploadAndReplaceLocalAudio(
2045
+ content: string,
2046
+ baseUrl: string,
2047
+ entityId: string,
2048
+ ): Promise<string> {
2049
+ const figureAudioRegex = /<figure\b[^>]*\bclass="audio"[^>]*>([\s\S]*?)<\/figure>/gi;
2050
+ // Linear tag scan + per-tag src read — avoids the polynomial backtracking of
2051
+ // two [^>]* around src= (CodeQL js/polynomial-redos).
2052
+ const sourceTagRegex = /<source\b[^>]*>/gi;
2053
+ const srcs = new Set<string>();
2054
+ let figure: RegExpExecArray | null;
2055
+ while ((figure = figureAudioRegex.exec(content)) !== null) {
2056
+ let tag: RegExpExecArray | null;
2057
+ while ((tag = sourceTagRegex.exec(figure[1])) !== null) {
2058
+ const srcMatch = /\ssrc="([^"]+)"/i.exec(tag[0]);
2059
+ if (srcMatch && !/^data:/i.test(srcMatch[1])) srcs.add(srcMatch[1]);
2060
+ }
2061
+ }
2062
+ if (srcs.size === 0) return content;
2063
+
2064
+ const replacements = new Map<string, string>();
2065
+ for (const src of srcs) {
2066
+ const sitePath = siteRelativePathFromSrc(src, baseUrl);
2067
+ if (!sitePath) continue; // external/cross-origin — leave the streamed URL
2068
+ try {
2069
+ const base64 = await readSiteFile(sitePath);
2070
+ const filename = sitePath.split("/").pop() || "audio";
2071
+ const asset = await uploadAssetMultipart(base64, filename, audioMimeForPath(sitePath), "embedaudio", entityId);
2072
+ replacements.set(src, asset.path);
2073
+ console.log(` 🔊 Audio uploaded (bytes): ${src} → ${asset.path}`);
2074
+ } catch (error) {
2075
+ // Leave the absolutized deployed URL in place — matters streams from it.
2076
+ console.warn(` ⚠️ Audio byte-upload failed for ${src}, keeping site URL: ${error}`);
2077
+ }
2078
+ }
2079
+
2080
+ return applySrcReplacements(content, replacements);
2081
+ }
2082
+
2083
+ /** Replace every `src="<original>"` occurrence with the mapped URL. */
2084
+ function applySrcReplacements(content: string, replacements: Map<string, string>): string {
2085
+ let result = content;
2086
+ for (const [originalSrc, newUrl] of replacements) {
2087
+ const escaped = originalSrc.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2088
+ result = result.replace(new RegExp(`src="${escaped}"`, "g"), `src="${newUrl}"`);
2089
+ }
2090
+ return result;
2091
+ }
2092
+