@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.
- package/CHANGELOG.md +88 -0
- package/README.md +18 -0
- package/assets/icon.svg +1 -0
- package/assets/manifest.json +36 -0
- package/codegen.ts +26 -0
- package/e2e/moss-cli.test.ts +338 -0
- package/features/api/fetch-articles.feature +39 -0
- package/features/auth/wallet-auth.feature +27 -0
- package/features/download/retry-logic.feature +36 -0
- package/features/download/self-correcting.feature +83 -0
- package/features/download/worker-pool.feature +29 -0
- package/features/social/fetch-social-data.feature +40 -0
- package/features/steps/api.steps.ts +180 -0
- package/features/steps/download.steps.ts +423 -0
- package/features/steps/incremental-sync.steps.ts +105 -0
- package/features/steps/self-correcting.steps.ts +575 -0
- package/features/steps/social.steps.ts +257 -0
- package/features/steps/syndication.steps.ts +264 -0
- package/features/steps/wallet-auth.steps.ts +185 -0
- package/features/sync/article-sync.feature +49 -0
- package/features/sync/homepage-grid.feature +43 -0
- package/features/sync/incremental-sync.feature +28 -0
- package/features/syndication/create-draft.feature +35 -0
- package/package.json +58 -0
- package/src/__generated__/schema.graphql +4289 -0
- package/src/__generated__/types.ts +5355 -0
- package/src/__tests__/api.test.ts +678 -0
- package/src/__tests__/auth-route.test.ts +38 -0
- package/src/__tests__/auth-routing.test.ts +462 -0
- package/src/__tests__/auto-detect.test.ts +412 -0
- package/src/__tests__/binding-guard.test.ts +256 -0
- package/src/__tests__/config.test.ts +212 -0
- package/src/__tests__/converter.test.ts +289 -0
- package/src/__tests__/credential.test.ts +332 -0
- package/src/__tests__/domain.test.ts +341 -0
- package/src/__tests__/downloader.test.ts +679 -0
- package/src/__tests__/folder-detection.test.ts +289 -0
- package/src/__tests__/force-fresh-login.test.ts +236 -0
- package/src/__tests__/main.test.ts +2437 -0
- package/src/__tests__/progress.test.ts +93 -0
- package/src/__tests__/session.test.ts +375 -0
- package/src/__tests__/social-integration.test.ts +386 -0
- package/src/__tests__/social-sync-logic.test.ts +107 -0
- package/src/__tests__/social.test.ts +788 -0
- package/src/__tests__/sync.test.ts +1273 -0
- package/src/__tests__/syndication-toast-law.test.ts +649 -0
- package/src/__tests__/syndication.test.ts +125 -0
- package/src/__tests__/test-profile-escape.test.ts +209 -0
- package/src/__tests__/url-detect.test.ts +79 -0
- package/src/__tests__/utils.test.ts +226 -0
- package/src/api.ts +1366 -0
- package/src/auth-route.ts +38 -0
- package/src/config.ts +80 -0
- package/src/converter.ts +305 -0
- package/src/credential.ts +329 -0
- package/src/domain.ts +183 -0
- package/src/downloader.ts +761 -0
- package/src/main.ts +2092 -0
- package/src/progress.ts +89 -0
- package/src/queries/user.graphql +85 -0
- package/src/queries/viewer.graphql +104 -0
- package/src/social.ts +413 -0
- package/src/sync.ts +818 -0
- package/src/types.ts +477 -0
- package/src/url-detect.ts +49 -0
- package/src/utils.ts +305 -0
- package/test-fixtures/syndication-test-site/input/index.md +8 -0
- package/test-fixtures/syndication-test-site/input/posts/rich-test-article.md +90 -0
- package/test-helpers/TEST_ACCOUNT.md +151 -0
- package/test-helpers/api-client.ts +252 -0
- package/test-helpers/fixtures/articles.ts +147 -0
- package/test-helpers/wallet-auth.ts +305 -0
- package/test-setup/e2e.ts +93 -0
- package/tsconfig.json +23 -0
- 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
|
+
|