@symbiosis-lab/moss-plugin-matters 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +18 -0
  3. package/assets/icon.svg +1 -0
  4. package/assets/manifest.json +36 -0
  5. package/codegen.ts +26 -0
  6. package/e2e/moss-cli.test.ts +338 -0
  7. package/features/api/fetch-articles.feature +39 -0
  8. package/features/auth/wallet-auth.feature +27 -0
  9. package/features/download/retry-logic.feature +36 -0
  10. package/features/download/self-correcting.feature +83 -0
  11. package/features/download/worker-pool.feature +29 -0
  12. package/features/social/fetch-social-data.feature +40 -0
  13. package/features/steps/api.steps.ts +180 -0
  14. package/features/steps/download.steps.ts +423 -0
  15. package/features/steps/incremental-sync.steps.ts +105 -0
  16. package/features/steps/self-correcting.steps.ts +575 -0
  17. package/features/steps/social.steps.ts +257 -0
  18. package/features/steps/syndication.steps.ts +264 -0
  19. package/features/steps/wallet-auth.steps.ts +185 -0
  20. package/features/sync/article-sync.feature +49 -0
  21. package/features/sync/homepage-grid.feature +43 -0
  22. package/features/sync/incremental-sync.feature +28 -0
  23. package/features/syndication/create-draft.feature +35 -0
  24. package/package.json +58 -0
  25. package/src/__generated__/schema.graphql +4289 -0
  26. package/src/__generated__/types.ts +5355 -0
  27. package/src/__tests__/api.test.ts +678 -0
  28. package/src/__tests__/auth-route.test.ts +38 -0
  29. package/src/__tests__/auth-routing.test.ts +462 -0
  30. package/src/__tests__/auto-detect.test.ts +412 -0
  31. package/src/__tests__/binding-guard.test.ts +256 -0
  32. package/src/__tests__/config.test.ts +212 -0
  33. package/src/__tests__/converter.test.ts +289 -0
  34. package/src/__tests__/credential.test.ts +332 -0
  35. package/src/__tests__/domain.test.ts +341 -0
  36. package/src/__tests__/downloader.test.ts +679 -0
  37. package/src/__tests__/folder-detection.test.ts +289 -0
  38. package/src/__tests__/force-fresh-login.test.ts +236 -0
  39. package/src/__tests__/main.test.ts +2437 -0
  40. package/src/__tests__/progress.test.ts +93 -0
  41. package/src/__tests__/session.test.ts +375 -0
  42. package/src/__tests__/social-integration.test.ts +386 -0
  43. package/src/__tests__/social-sync-logic.test.ts +107 -0
  44. package/src/__tests__/social.test.ts +788 -0
  45. package/src/__tests__/sync.test.ts +1273 -0
  46. package/src/__tests__/syndication-toast-law.test.ts +649 -0
  47. package/src/__tests__/syndication.test.ts +125 -0
  48. package/src/__tests__/test-profile-escape.test.ts +209 -0
  49. package/src/__tests__/url-detect.test.ts +79 -0
  50. package/src/__tests__/utils.test.ts +226 -0
  51. package/src/api.ts +1366 -0
  52. package/src/auth-route.ts +38 -0
  53. package/src/config.ts +80 -0
  54. package/src/converter.ts +305 -0
  55. package/src/credential.ts +329 -0
  56. package/src/domain.ts +183 -0
  57. package/src/downloader.ts +761 -0
  58. package/src/main.ts +2092 -0
  59. package/src/progress.ts +89 -0
  60. package/src/queries/user.graphql +85 -0
  61. package/src/queries/viewer.graphql +104 -0
  62. package/src/social.ts +413 -0
  63. package/src/sync.ts +818 -0
  64. package/src/types.ts +477 -0
  65. package/src/url-detect.ts +49 -0
  66. package/src/utils.ts +305 -0
  67. package/test-fixtures/syndication-test-site/input/index.md +8 -0
  68. package/test-fixtures/syndication-test-site/input/posts/rich-test-article.md +90 -0
  69. package/test-helpers/TEST_ACCOUNT.md +151 -0
  70. package/test-helpers/api-client.ts +252 -0
  71. package/test-helpers/fixtures/articles.ts +147 -0
  72. package/test-helpers/wallet-auth.ts +305 -0
  73. package/test-setup/e2e.ts +93 -0
  74. package/tsconfig.json +23 -0
  75. package/vitest.config.ts +39 -0
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Pure auth-routing decision for the process hook.
3
+ *
4
+ * Inputs: the tri-state session check (api.ts getSessionState), moss's
5
+ * trigger stamp (ADR-015, snake_case; absent ⇒ "background"), and whether a
6
+ * userName is saved in config (enables public-mode import).
7
+ *
8
+ * Invariant: background NEVER opens a login window. A quiet rebuild popping
9
+ * a browser uninvited is the bug class this module exists to kill.
10
+ */
11
+
12
+ import type { SessionState } from "./credential";
13
+
14
+ export type AuthRoute = "proceed" | "prompt_login" | "public_fallback" | "soft_fail";
15
+
16
+ const USER_PRESENT_TRIGGERS = new Set(["onboarding_flow", "settings_manual", "manual_one"]);
17
+
18
+ /** Unknown or absent triggers count as background (quiet default). */
19
+ export function isUserPresent(trigger: string | undefined): boolean {
20
+ return trigger !== undefined && USER_PRESENT_TRIGGERS.has(trigger);
21
+ }
22
+
23
+ export function resolveAuthRoute(
24
+ state: SessionState,
25
+ trigger: string | undefined,
26
+ hasUserName: boolean
27
+ ): AuthRoute {
28
+ if (state === "valid") return "proceed";
29
+
30
+ if (isUserPresent(trigger)) {
31
+ // Expired session + present user: re-login beats a degraded import.
32
+ if (state === "expired") return "prompt_login";
33
+ // Never logged in: keep today's behavior (public fallback when bound).
34
+ return hasUserName ? "public_fallback" : "prompt_login";
35
+ }
36
+
37
+ return hasUserName ? "public_fallback" : "soft_fail";
38
+ }
package/src/config.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Plugin configuration management
3
+ *
4
+ * Uses the moss-api plugin storage API to automatically store config
5
+ * in the plugin's private directory (.moss/plugins/{plugin-name}/).
6
+ * No need to know the path - just call readPluginFile("config.json").
7
+ */
8
+
9
+ import {
10
+ readPluginFile,
11
+ writePluginFile,
12
+ pluginFileExists,
13
+ } from "@symbiosis-lab/moss-api";
14
+
15
+ // ============================================================================
16
+ // Types
17
+ // ============================================================================
18
+
19
+ /**
20
+ * Plugin configuration stored in config.json
21
+ */
22
+ export interface MattersPluginConfig {
23
+ /** Matters username this project is bound to (guards auto-sync) */
24
+ boundUserName?: string;
25
+ /** Matters.town username (allows unauthenticated mode when cookie unavailable) */
26
+ userName?: string;
27
+ /** User's language preference (e.g., "zh_hans", "zh_hant", "en") */
28
+ language?: string;
29
+ /** ISO timestamp of last successful sync completion (for incremental sync) */
30
+ lastSyncedAt?: string;
31
+ /** Whether to sync drafts (default: false) */
32
+ sync_drafts?: boolean;
33
+ /** Explicit article folder name override (auto-detected if not set) */
34
+ articleFolder?: string;
35
+ /** Override Matters domain (default: "matters.town", test: "matters.icu") */
36
+ domain?: string;
37
+ /** Known collection IDs from previous syncs (collections lack createdAt, so we track IDs to detect new ones) */
38
+ knownCollectionIds?: string[];
39
+ }
40
+
41
+ // ============================================================================
42
+ // Functions
43
+ // ============================================================================
44
+
45
+ /**
46
+ * Read plugin configuration from storage
47
+ *
48
+ * Config is automatically stored in the plugin's private directory.
49
+ * Must be called from within a plugin hook.
50
+ *
51
+ * @returns Plugin configuration object (empty object if not found or invalid)
52
+ */
53
+ export async function getConfig(): Promise<MattersPluginConfig> {
54
+ try {
55
+ const exists = await pluginFileExists("config.json");
56
+ if (!exists) {
57
+ return {};
58
+ }
59
+
60
+ const content = await readPluginFile("config.json");
61
+ return JSON.parse(content) as MattersPluginConfig;
62
+ } catch {
63
+ // Return empty config on any error (file not found, parse error, etc.)
64
+ return {};
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Save plugin configuration to storage
70
+ *
71
+ * Config is automatically stored in the plugin's private directory.
72
+ * Must be called from within a plugin hook.
73
+ *
74
+ * @param config - Configuration object to save
75
+ * @throws Error if write fails
76
+ */
77
+ export async function saveConfig(config: MattersPluginConfig): Promise<void> {
78
+ const content = JSON.stringify(config, null, 2);
79
+ await writePluginFile("config.json", content);
80
+ }
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Content conversion utilities: frontmatter handling + image/link extraction.
3
+ *
4
+ * NOTE: HTML→Markdown is NOT done here. Production converts via moss's shared
5
+ * Rust `htmd` converter, imported as `htmlToMarkdown` from `@symbiosis-lab/moss-api`
6
+ * (see `sync.ts`). The hand-rolled DOM-walking converter that used to live here
7
+ * was deleted (B4) — it duplicated functionality moss already owns and shipped
8
+ * the lone-backslash `<br>` bug (B3).
9
+ */
10
+
11
+ import type { FrontmatterData, ParsedFrontmatter } from "./types";
12
+
13
+ // ============================================================================
14
+ // Frontmatter Handling
15
+ // ============================================================================
16
+
17
+ /**
18
+ * Escape string for YAML (escape backslashes first, then quotes)
19
+ */
20
+ function escapeYaml(str: string): string {
21
+ return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
22
+ }
23
+
24
+ /**
25
+ * Generate frontmatter YAML from data object
26
+ */
27
+ export function generateFrontmatter(data: FrontmatterData): string {
28
+ const lines: string[] = ["---"];
29
+
30
+ lines.push(`title: "${escapeYaml(data.title)}"`);
31
+
32
+ if (data.home) {
33
+ lines.push("home: true");
34
+ }
35
+
36
+ if (data.description) {
37
+ lines.push(`description: "${escapeYaml(data.description)}"`);
38
+ }
39
+
40
+ if (data.date) {
41
+ lines.push(`date: "${data.date}"`);
42
+ }
43
+ if (data.updated) {
44
+ lines.push(`updated: "${data.updated}"`);
45
+ }
46
+
47
+ if (data.tags && data.tags.length > 0) {
48
+ lines.push("tags:");
49
+ for (const tag of data.tags) {
50
+ lines.push(` - "${escapeYaml(tag)}"`);
51
+ }
52
+ }
53
+
54
+ if (data.cover) {
55
+ lines.push(`cover: "${data.cover}"`);
56
+ }
57
+
58
+ if (data.syndicated && data.syndicated.length > 0) {
59
+ lines.push("syndicated:");
60
+ for (const url of data.syndicated) {
61
+ lines.push(` - "${url}"`);
62
+ }
63
+ }
64
+
65
+ if (data.collections) {
66
+ if (Array.isArray(data.collections)) {
67
+ // Array format: collections as list of slugs
68
+ if (data.collections.length > 0) {
69
+ lines.push("collections:");
70
+ for (const slug of data.collections) {
71
+ lines.push(` - "${slug}"`);
72
+ }
73
+ }
74
+ } else if (Object.keys(data.collections).length > 0) {
75
+ // Object format: collections with order numbers
76
+ lines.push("collections:");
77
+ for (const [slug, order] of Object.entries(data.collections)) {
78
+ lines.push(` ${slug}: ${order}`);
79
+ }
80
+ }
81
+ }
82
+
83
+ if (data.order && data.order.length > 0) {
84
+ lines.push("order:");
85
+ for (const filename of data.order) {
86
+ lines.push(` - "${filename}"`);
87
+ }
88
+ }
89
+
90
+ lines.push("---");
91
+
92
+ return lines.join("\n");
93
+ }
94
+
95
+ /**
96
+ * Parse frontmatter from markdown content
97
+ */
98
+ export function parseFrontmatter(content: string): ParsedFrontmatter | null {
99
+ const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
100
+ if (!match) {
101
+ return null;
102
+ }
103
+
104
+ const frontmatterStr = match[1];
105
+ const body = match[2];
106
+
107
+ const frontmatter: Record<string, unknown> = {};
108
+ const lines = frontmatterStr.split("\n");
109
+ let currentKey = "";
110
+ let currentArray: string[] = [];
111
+
112
+ for (const line of lines) {
113
+ if (line.startsWith(" - ")) {
114
+ const value = line.substring(4).replace(/^"(.*)"$/, "$1");
115
+ currentArray.push(value);
116
+ } else if (line.includes(":")) {
117
+ // Save previous array if any
118
+ if (currentKey && currentArray.length > 0) {
119
+ frontmatter[currentKey] = currentArray;
120
+ currentArray = [];
121
+ }
122
+
123
+ const colonIndex = line.indexOf(":");
124
+ const key = line.substring(0, colonIndex);
125
+ const rest = line.substring(colonIndex + 1).trim();
126
+
127
+ if (rest === "") {
128
+ // Array or object key (e.g., "tags:")
129
+ currentKey = key;
130
+ currentArray = [];
131
+ } else {
132
+ // Simple key-value pair
133
+ currentKey = "";
134
+ frontmatter[key] = rest.replace(/^"(.*)"$/, "$1");
135
+ }
136
+ }
137
+ }
138
+
139
+ // Save last array if any
140
+ if (currentKey && currentArray.length > 0) {
141
+ frontmatter[currentKey] = currentArray;
142
+ }
143
+
144
+ return { frontmatter, body };
145
+ }
146
+
147
+ /**
148
+ * Regenerate frontmatter YAML from parsed object
149
+ */
150
+ export function regenerateFrontmatter(frontmatter: Record<string, unknown>): string {
151
+ const lines: string[] = ["---"];
152
+
153
+ const formatValue = (value: unknown): string => {
154
+ if (typeof value === "string") {
155
+ if (value.includes(":") || value.includes("#") || value.includes('"') || value.startsWith(" ")) {
156
+ return `"${escapeYaml(value)}"`;
157
+ }
158
+ return `"${value}"`;
159
+ }
160
+ return String(value);
161
+ };
162
+
163
+ const fieldOrder = ["title", "description", "date", "updated", "tags", "cover", "syndicated", "collections", "order"];
164
+
165
+ for (const key of fieldOrder) {
166
+ if (!(key in frontmatter)) continue;
167
+ const value = frontmatter[key];
168
+
169
+ if (Array.isArray(value)) {
170
+ lines.push(`${key}:`);
171
+ for (const item of value) {
172
+ lines.push(` - ${formatValue(item)}`);
173
+ }
174
+ } else if (typeof value === "object" && value !== null) {
175
+ lines.push(`${key}:`);
176
+ for (const [subKey, subValue] of Object.entries(value)) {
177
+ lines.push(` ${subKey}: ${subValue}`);
178
+ }
179
+ } else if (typeof value === "boolean") {
180
+ lines.push(`${key}: ${value}`);
181
+ } else {
182
+ lines.push(`${key}: ${formatValue(value)}`);
183
+ }
184
+ }
185
+
186
+ for (const [key, value] of Object.entries(frontmatter)) {
187
+ if (fieldOrder.includes(key)) continue;
188
+
189
+ if (Array.isArray(value)) {
190
+ lines.push(`${key}:`);
191
+ for (const item of value) {
192
+ lines.push(` - ${formatValue(item)}`);
193
+ }
194
+ } else if (typeof value === "object" && value !== null) {
195
+ lines.push(`${key}:`);
196
+ for (const [subKey, subValue] of Object.entries(value)) {
197
+ lines.push(` ${subKey}: ${subValue}`);
198
+ }
199
+ } else if (typeof value === "boolean") {
200
+ lines.push(`${key}: ${value}`);
201
+ } else {
202
+ lines.push(`${key}: ${formatValue(value)}`);
203
+ }
204
+ }
205
+
206
+ lines.push("---");
207
+ return lines.join("\n");
208
+ }
209
+
210
+ /**
211
+ * Extract all markdown links from content (not images)
212
+ * Used for detecting internal Matters links that need rewriting
213
+ */
214
+ export function extractMarkdownLinks(content: string): Array<{
215
+ url: string;
216
+ fullMatch: string;
217
+ }> {
218
+ const results: Array<{ url: string; fullMatch: string }> = [];
219
+ // Match markdown links [text](url) but NOT images ![alt](url)
220
+ // Negative lookbehind (?<!!") ensures we don't match image syntax
221
+ const linkPattern = /(?<!!)\[([^\]]*)\]\(([^)]+)\)/g;
222
+ let match;
223
+
224
+ while ((match = linkPattern.exec(content)) !== null) {
225
+ results.push({
226
+ fullMatch: match[0],
227
+ url: match[2].trim(),
228
+ });
229
+ }
230
+
231
+ return results;
232
+ }
233
+
234
+ /**
235
+ * Extract remote image URLs from markdown content
236
+ */
237
+ export function extractRemoteImageUrls(
238
+ content: string
239
+ ): Array<{ url: string; localFilename: string }> {
240
+ const results: Array<{ url: string; localFilename: string }> = [];
241
+ const seen = new Set<string>();
242
+
243
+ const imagePattern = /!\[[^\]]*\]\(([^)]+)\)/g;
244
+ let match;
245
+
246
+ while ((match = imagePattern.exec(content)) !== null) {
247
+ const url = match[1].trim();
248
+
249
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
250
+ continue;
251
+ }
252
+
253
+ if (seen.has(url)) {
254
+ continue;
255
+ }
256
+ seen.add(url);
257
+
258
+ const localFilename = generateLocalFilenameFromUrl(url);
259
+ if (localFilename) {
260
+ results.push({ url, localFilename });
261
+ }
262
+ }
263
+
264
+ return results;
265
+ }
266
+
267
+ /**
268
+ * Generate local filename from URL (duplicated here to avoid circular dependency)
269
+ */
270
+ function generateLocalFilenameFromUrl(url: string): string | null {
271
+ try {
272
+ const urlObj = new URL(url);
273
+ const pathname = urlObj.pathname;
274
+ const cleanPath = pathname.replace(/\/public$/, "");
275
+ const segments = cleanPath.split("/").filter((s) => s.length > 0);
276
+
277
+ for (let i = segments.length - 1; i >= 0; i--) {
278
+ const segment = segments[i];
279
+ const extMatch = segment.match(/\.(\w+)$/);
280
+ if (extMatch) {
281
+ const ext = extMatch[1].toLowerCase();
282
+ if (i > 0 && /^[a-f0-9-]{36}$/i.test(segments[i - 1])) {
283
+ return `${segments[i - 1]}.${ext}`;
284
+ }
285
+ return segment;
286
+ }
287
+ }
288
+
289
+ for (const segment of segments) {
290
+ if (/^[a-f0-9-]{36}$/i.test(segment)) {
291
+ return segment;
292
+ }
293
+ }
294
+
295
+ // Simple hash fallback
296
+ let hash = 0;
297
+ for (let i = 0; i < url.length; i++) {
298
+ hash = ((hash << 5) - hash) + url.charCodeAt(i);
299
+ hash = hash & hash;
300
+ }
301
+ return Math.abs(hash).toString(16);
302
+ } catch {
303
+ return null;
304
+ }
305
+ }