@symbiosis-lab/moss-plugin-matters 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +18 -0
  3. package/assets/icon.svg +1 -0
  4. package/assets/manifest.json +36 -0
  5. package/codegen.ts +26 -0
  6. package/e2e/moss-cli.test.ts +338 -0
  7. package/features/api/fetch-articles.feature +39 -0
  8. package/features/auth/wallet-auth.feature +27 -0
  9. package/features/download/retry-logic.feature +36 -0
  10. package/features/download/self-correcting.feature +83 -0
  11. package/features/download/worker-pool.feature +29 -0
  12. package/features/social/fetch-social-data.feature +40 -0
  13. package/features/steps/api.steps.ts +180 -0
  14. package/features/steps/download.steps.ts +423 -0
  15. package/features/steps/incremental-sync.steps.ts +105 -0
  16. package/features/steps/self-correcting.steps.ts +575 -0
  17. package/features/steps/social.steps.ts +257 -0
  18. package/features/steps/syndication.steps.ts +264 -0
  19. package/features/steps/wallet-auth.steps.ts +185 -0
  20. package/features/sync/article-sync.feature +49 -0
  21. package/features/sync/homepage-grid.feature +43 -0
  22. package/features/sync/incremental-sync.feature +28 -0
  23. package/features/syndication/create-draft.feature +35 -0
  24. package/package.json +58 -0
  25. package/src/__generated__/schema.graphql +4289 -0
  26. package/src/__generated__/types.ts +5355 -0
  27. package/src/__tests__/api.test.ts +678 -0
  28. package/src/__tests__/auth-route.test.ts +38 -0
  29. package/src/__tests__/auth-routing.test.ts +462 -0
  30. package/src/__tests__/auto-detect.test.ts +412 -0
  31. package/src/__tests__/binding-guard.test.ts +256 -0
  32. package/src/__tests__/config.test.ts +212 -0
  33. package/src/__tests__/converter.test.ts +289 -0
  34. package/src/__tests__/credential.test.ts +332 -0
  35. package/src/__tests__/domain.test.ts +341 -0
  36. package/src/__tests__/downloader.test.ts +679 -0
  37. package/src/__tests__/folder-detection.test.ts +289 -0
  38. package/src/__tests__/force-fresh-login.test.ts +236 -0
  39. package/src/__tests__/main.test.ts +2437 -0
  40. package/src/__tests__/progress.test.ts +93 -0
  41. package/src/__tests__/session.test.ts +375 -0
  42. package/src/__tests__/social-integration.test.ts +386 -0
  43. package/src/__tests__/social-sync-logic.test.ts +107 -0
  44. package/src/__tests__/social.test.ts +788 -0
  45. package/src/__tests__/sync.test.ts +1273 -0
  46. package/src/__tests__/syndication-toast-law.test.ts +649 -0
  47. package/src/__tests__/syndication.test.ts +125 -0
  48. package/src/__tests__/test-profile-escape.test.ts +209 -0
  49. package/src/__tests__/url-detect.test.ts +79 -0
  50. package/src/__tests__/utils.test.ts +226 -0
  51. package/src/api.ts +1366 -0
  52. package/src/auth-route.ts +38 -0
  53. package/src/config.ts +80 -0
  54. package/src/converter.ts +305 -0
  55. package/src/credential.ts +329 -0
  56. package/src/domain.ts +183 -0
  57. package/src/downloader.ts +761 -0
  58. package/src/main.ts +2092 -0
  59. package/src/progress.ts +89 -0
  60. package/src/queries/user.graphql +85 -0
  61. package/src/queries/viewer.graphql +104 -0
  62. package/src/social.ts +413 -0
  63. package/src/sync.ts +818 -0
  64. package/src/types.ts +477 -0
  65. package/src/url-detect.ts +49 -0
  66. package/src/utils.ts +305 -0
  67. package/test-fixtures/syndication-test-site/input/index.md +8 -0
  68. package/test-fixtures/syndication-test-site/input/posts/rich-test-article.md +90 -0
  69. package/test-helpers/TEST_ACCOUNT.md +151 -0
  70. package/test-helpers/api-client.ts +252 -0
  71. package/test-helpers/fixtures/articles.ts +147 -0
  72. package/test-helpers/wallet-auth.ts +305 -0
  73. package/test-setup/e2e.ts +93 -0
  74. package/tsconfig.json +23 -0
  75. package/vitest.config.ts +39 -0
package/src/utils.ts ADDED
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Utility functions for the Matters Syndicator Plugin
3
+ *
4
+ * This module wraps SDK utilities with plugin-specific functionality
5
+ * and provides Matters-specific helper functions.
6
+ */
7
+
8
+ import {
9
+ setMessageContext,
10
+ sendMessage as sdkSendMessage,
11
+ reportError as sdkReportError,
12
+ fetchUrl,
13
+ downloadAsset as sdkDownloadAsset,
14
+ type PluginMessage,
15
+ } from "@symbiosis-lab/moss-api";
16
+
17
+ // ============================================================================
18
+ // Plugin Configuration
19
+ // ============================================================================
20
+
21
+ const PLUGIN_NAME = "matters";
22
+
23
+ // Initialize message context on load
24
+ setMessageContext(PLUGIN_NAME, "");
25
+
26
+ // ============================================================================
27
+ // Re-exports from SDK (with plugin context)
28
+ // ============================================================================
29
+
30
+ /**
31
+ * Set the current hook name for message routing
32
+ */
33
+ export function setCurrentHookName(name: string): void {
34
+ setMessageContext(PLUGIN_NAME, name);
35
+ }
36
+
37
+ /**
38
+ * Get the current hook name (for compatibility)
39
+ */
40
+ let _currentHookName = "";
41
+ export function getCurrentHookName(): string {
42
+ return _currentHookName;
43
+ }
44
+
45
+ // Override setCurrentHookName to also track locally
46
+ const originalSetCurrentHookName = setCurrentHookName;
47
+ export { originalSetCurrentHookName };
48
+
49
+ /**
50
+ * Send a message to moss (logs, progress, errors)
51
+ */
52
+ export async function sendMessage(message: PluginMessage): Promise<void> {
53
+ await sdkSendMessage(message);
54
+ }
55
+
56
+ /**
57
+ * Report an error to moss during hook execution
58
+ */
59
+ export async function reportError(
60
+ error: string,
61
+ context?: string,
62
+ fatal = false
63
+ ): Promise<void> {
64
+ await sdkReportError(error, context, fatal);
65
+ }
66
+
67
+ // ============================================================================
68
+ // String Utilities
69
+ // ============================================================================
70
+
71
+ /**
72
+ * Generate a URL-safe slug from text
73
+ * Preserves Unicode characters (CJK, Cyrillic, Arabic, etc.)
74
+ */
75
+ export function slugify(text: string): string {
76
+ return text
77
+ .toLowerCase()
78
+ .replace(/[^\p{L}\p{N}\s-]/gu, "")
79
+ .replace(/\s+/g, "-")
80
+ .replace(/--+/g, "-")
81
+ .replace(/^-+/, "")
82
+ .replace(/-+$/, "");
83
+ }
84
+
85
+ /**
86
+ * Simple hash function for generating filenames
87
+ */
88
+ export function simpleHash(str: string): string {
89
+ let hash = 0;
90
+ for (let i = 0; i < str.length; i++) {
91
+ const char = str.charCodeAt(i);
92
+ hash = (hash << 5) - hash + char;
93
+ hash = hash & hash;
94
+ }
95
+ return Math.abs(hash).toString(16);
96
+ }
97
+
98
+ // ============================================================================
99
+ // Filename Utilities
100
+ // ============================================================================
101
+
102
+ /**
103
+ * Generate a local filename from a URL
104
+ */
105
+ export function generateLocalFilename(url: string): string | null {
106
+ try {
107
+ const urlObj = new URL(url);
108
+ const pathname = urlObj.pathname;
109
+ const cleanPath = pathname.replace(/\/public$/, "");
110
+ const segments = cleanPath.split("/").filter((s) => s.length > 0);
111
+
112
+ // Find a segment with an extension
113
+ for (let i = segments.length - 1; i >= 0; i--) {
114
+ const segment = segments[i];
115
+ const extMatch = segment.match(/\.(\w+)$/);
116
+ if (extMatch) {
117
+ const ext = extMatch[1].toLowerCase();
118
+ if (i > 0 && /^[a-f0-9-]{36}$/i.test(segments[i - 1])) {
119
+ return `${segments[i - 1]}.${ext}`;
120
+ }
121
+ return segment;
122
+ }
123
+ }
124
+
125
+ // No extension found, try to find a UUID
126
+ for (const segment of segments) {
127
+ if (/^[a-f0-9-]{36}$/i.test(segment)) {
128
+ return segment;
129
+ }
130
+ }
131
+
132
+ // Fallback: hash the URL
133
+ return simpleHash(url);
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Get file extension from Content-Type header
141
+ */
142
+ export function getExtensionFromContentType(contentType: string): string | null {
143
+ const mapping: Record<string, string> = {
144
+ "image/jpeg": "jpg",
145
+ "image/jpg": "jpg",
146
+ "image/png": "png",
147
+ "image/gif": "gif",
148
+ "image/webp": "webp",
149
+ "image/svg+xml": "svg",
150
+ };
151
+
152
+ for (const [type, ext] of Object.entries(mapping)) {
153
+ if (contentType.includes(type)) {
154
+ return ext;
155
+ }
156
+ }
157
+ return null;
158
+ }
159
+
160
+ // ============================================================================
161
+ // Async Utilities
162
+ // ============================================================================
163
+
164
+ /**
165
+ * Sleep for specified milliseconds
166
+ */
167
+ export function sleep(ms: number): Promise<void> {
168
+ return new Promise((resolve) => setTimeout(resolve, ms));
169
+ }
170
+
171
+ // ============================================================================
172
+ // HTTP Utilities (using moss-api)
173
+ // ============================================================================
174
+
175
+ /**
176
+ * Result from downloadAsset function
177
+ */
178
+ export interface DownloadAssetResult {
179
+ status: number;
180
+ ok: boolean;
181
+ content_type: string | null;
182
+ bytes_written: number;
183
+ actual_path: string;
184
+ }
185
+
186
+ /**
187
+ * Fetch a URL using moss-api (bypasses WebKit CORS).
188
+ *
189
+ * Returns a Response-like object for compatibility with existing code.
190
+ */
191
+ export async function fetchWithTimeout(
192
+ url: string,
193
+ timeoutMs = 30000
194
+ ): Promise<Response> {
195
+ const result = await fetchUrl(url, { timeoutMs });
196
+
197
+ // Create Response-like object
198
+ const headers = new Headers();
199
+ if (result.contentType) {
200
+ headers.set("content-type", result.contentType);
201
+ }
202
+
203
+ return new Response(result.body.buffer as ArrayBuffer, {
204
+ status: result.status,
205
+ headers,
206
+ });
207
+ }
208
+
209
+ /**
210
+ * Download a URL and save directly to disk using moss-api.
211
+ *
212
+ * This function downloads a file and writes it directly to disk without
213
+ * passing the binary data through JavaScript. This avoids event loop blocking
214
+ * that occurs with large files when using base64 encoding/decoding.
215
+ *
216
+ * moss handles filename derivation from the URL and adds file extension from
217
+ * Content-Type if the URL has no extension.
218
+ *
219
+ * @param url - URL to download
220
+ * @param targetDir - Target directory within project (e.g., "assets")
221
+ * @param timeoutMs - Optional timeout in milliseconds (defaults to 30 seconds)
222
+ * @returns Result with status, content-type, bytes written, and actual_path
223
+ */
224
+ export async function downloadAsset(
225
+ url: string,
226
+ targetDir: string,
227
+ timeoutMs = 30000
228
+ ): Promise<DownloadAssetResult> {
229
+ const result = await sdkDownloadAsset(url, targetDir, { timeoutMs });
230
+
231
+ // Map moss-api result to existing interface for backward compatibility
232
+ return {
233
+ status: result.status,
234
+ ok: result.ok,
235
+ content_type: result.contentType,
236
+ bytes_written: result.bytesWritten,
237
+ actual_path: result.actualPath,
238
+ };
239
+ }
240
+
241
+ // ============================================================================
242
+ // Sync receipt formatting
243
+ // ============================================================================
244
+
245
+ /**
246
+ * Format the article-sync outcome as a NOUN-LED receipt line for the progress
247
+ * surface — "12 articles already up to date", not a bare "12 unchanged" whose
248
+ * subject the reader has to guess. Pure + total-aware so the headline reads as
249
+ * one fact (how many articles, and what happened to them) rather than a string
250
+ * of disconnected counts.
251
+ *
252
+ * Image/link/comment outcomes are NOT folded in here — successes stay silent
253
+ * ("success makes no sound") and failed images ride a per-image advisory.
254
+ */
255
+ export function formatArticleSyncSummary(counts: {
256
+ created: number;
257
+ updated: number;
258
+ skipped: number;
259
+ failed: number;
260
+ }): string {
261
+ const { created, updated, skipped, failed } = counts;
262
+ const synced = created + updated + skipped;
263
+ const s = (n: number): string => (n === 1 ? "" : "s");
264
+
265
+ if (synced === 0) {
266
+ return failed > 0
267
+ ? `${failed} article${s(failed)} failed to sync`
268
+ : "no articles to sync";
269
+ }
270
+
271
+ let base: string;
272
+ if (created === 0 && updated === 0) {
273
+ // Every synced article already matched its remote — the common "nothing
274
+ // changed" run. This is the line the user found opaque as "5 unchanged".
275
+ base = `${synced} article${s(synced)} already up to date`;
276
+ } else {
277
+ const changed: string[] = [];
278
+ if (created > 0) changed.push(`${created} new`);
279
+ if (updated > 0) changed.push(`${updated} updated`);
280
+ if (skipped > 0) changed.push(`${skipped} unchanged`);
281
+ base = `${synced} article${s(synced)}: ${changed.join(", ")}`;
282
+ }
283
+
284
+ // Trailing failure clause with an explicit verb so it reads as a SEPARATE
285
+ // cohort, not part of the synced set — "5 articles already up to date,
286
+ // 2 failed to sync" (the bare ", 2 failed" looked like 2 of the 5 broke).
287
+ return failed > 0 ? `${base}, ${failed} failed to sync` : base;
288
+ }
289
+
290
+ // ============================================================================
291
+ // Binary Utilities
292
+ // ============================================================================
293
+
294
+ /**
295
+ * Convert Uint8Array to base64 string in chunks to avoid stack overflow
296
+ */
297
+ export function uint8ArrayToBase64(bytes: Uint8Array): string {
298
+ let binary = "";
299
+ const chunkSize = 8192;
300
+ for (let i = 0; i < bytes.length; i += chunkSize) {
301
+ const chunk = bytes.subarray(i, i + chunkSize);
302
+ binary += String.fromCharCode.apply(null, chunk as unknown as number[]);
303
+ }
304
+ return btoa(binary);
305
+ }
@@ -0,0 +1,8 @@
1
+ ---
2
+ title: Syndication Test Site
3
+ uid: 37facec5
4
+ ---
5
+
6
+ # Syndication Test Site
7
+
8
+ A test site for validating the Matters plugin syndication flow.
@@ -0,0 +1,90 @@
1
+ ---
2
+ title: "Exploring Decentralized Publishing: A Test Article"
3
+ uid: 6aef0cb6
4
+ date: 2025-02-10
5
+ tags:
6
+ - web3
7
+ - publishing
8
+ - decentralization
9
+ - test
10
+ description: A comprehensive test article for validating Matters syndication with rich markdown content including code, quotes, lists, and more.
11
+ ---
12
+
13
+ ## Introduction
14
+
15
+ This article tests the full syndication pipeline from a local moss project to [Matters](https://matters.icu), a decentralized publishing platform built on IPFS. The goal is to verify that all markdown elements render correctly after conversion to HTML.
16
+
17
+ ## Why Decentralized Publishing Matters
18
+
19
+ Traditional publishing platforms come with **inherent risks**: content can be censored, servers can go down, and users don't truly *own* their work. Decentralized platforms address these issues by:
20
+
21
+ 1. Storing content on **distributed networks** like IPFS
22
+ 2. Giving authors **cryptographic ownership** of their publications
23
+ 3. Enabling **peer-to-peer** content distribution
24
+ 4. Providing **censorship resistance** through redundancy
25
+
26
+ > "The internet was designed to be decentralized. We're simply returning to first principles."
27
+ > -- Tim Berners-Lee
28
+
29
+ ## Technical Deep Dive
30
+
31
+ The syndication process works through a POSSE (Publish Own Site, Syndicate Elsewhere) workflow:
32
+
33
+ ### Step 1: Content Processing
34
+
35
+ The plugin reads local markdown files and converts them to HTML using a custom converter. Here's a simplified example of the conversion pipeline:
36
+
37
+ ```python
38
+ def convert_article(markdown_content: str) -> str:
39
+ """Convert markdown to Matters-compatible HTML."""
40
+ html = markdown_to_html(markdown_content)
41
+ html = rewrite_image_urls(html)
42
+ html = add_canonical_link(html)
43
+ return html
44
+ ```
45
+
46
+ ### Step 2: Draft Creation
47
+
48
+ After conversion, the plugin creates a draft on Matters via the GraphQL API:
49
+
50
+ ```graphql
51
+ mutation PutDraft($input: PutDraftInput!) {
52
+ putDraft(input: $input) {
53
+ id
54
+ title
55
+ content
56
+ publishState
57
+ }
58
+ }
59
+ ```
60
+
61
+ ### Step 3: User Review & Publish
62
+
63
+ The draft opens in a browser window for review. The user can:
64
+
65
+ - Edit the title and summary
66
+ - Add or modify tags
67
+ - Upload a cover image
68
+ - Click **Publish** when ready
69
+
70
+ ## Feature Checklist
71
+
72
+ Here's what we're testing with this article:
73
+
74
+ - [x] Basic paragraphs and text formatting
75
+ - [x] Headings (H2, H3)
76
+ - [x] Bold and italic text
77
+ - [x] Ordered and unordered lists
78
+ - [x] Code blocks with syntax highlighting
79
+ - [x] Blockquotes
80
+ - [x] External links
81
+ - [x] Horizontal rules
82
+ - [ ] Image embedding (requires separate test)
83
+
84
+ ---
85
+
86
+ ## Conclusion
87
+
88
+ If you're reading this on Matters, the syndication was successful. The article was originally published on a local test site and automatically syndicated using the moss Matters plugin.
89
+
90
+ For more information about moss, visit the [project repository](https://github.com/nicholasgasior/gostatic).
@@ -0,0 +1,151 @@
1
+ > **Warning:** This file documents how to configure test credentials. NEVER commit a real wallet private key. Use a throwaway test account funded only with disposable test tokens.
2
+
3
+ # Matters Test Account Setup
4
+
5
+ This document describes how to set up a test account for E2E testing on Matters.icu (test environment).
6
+
7
+ ## Overview
8
+
9
+ The Matters plugin uses Ethereum wallet authentication for E2E tests. This allows programmatic login without requiring email verification, making it suitable for CI/CD environments.
10
+
11
+ ## Environment
12
+
13
+ - **Test Server**: `https://server.matters.icu/graphql`
14
+ - **Test Website**: `https://matters.icu`
15
+ - **Authentication**: Ethereum wallet (EIP-4361 Sign-In with Ethereum)
16
+
17
+ ## Setting Up a Test Account
18
+
19
+ ### 1. Generate a Test Wallet
20
+
21
+ You can generate a new Ethereum wallet using ethers.js:
22
+
23
+ ```typescript
24
+ import { Wallet } from "ethers";
25
+
26
+ const wallet = Wallet.createRandom();
27
+ console.log("Private Key:", wallet.privateKey);
28
+ console.log("Address:", wallet.address);
29
+ ```
30
+
31
+ Or using the command line with Foundry:
32
+
33
+ ```bash
34
+ cast wallet new
35
+ ```
36
+
37
+ ### 2. Store the Private Key
38
+
39
+ Add the private key to your GitHub repository secrets:
40
+
41
+ 1. Go to your repository Settings → Secrets and variables → Actions
42
+ 2. Create a new secret: `MATTERS_TEST_WALLET_PRIVATE_KEY`
43
+ 3. Paste the private key (with or without `0x` prefix)
44
+
45
+ For local development, add to your `.env` file (never commit this!):
46
+
47
+ ```bash
48
+ MATTERS_TEST_WALLET_PRIVATE_KEY=0x...your-private-key...
49
+ ```
50
+
51
+ ### 3. First Login Creates Account
52
+
53
+ The first time you authenticate with the wallet, Matters will automatically create a new account. Subsequent logins will use the existing account.
54
+
55
+ ## Usage in Tests
56
+
57
+ ### Basic Authentication
58
+
59
+ ```typescript
60
+ import { walletLogin, createAuthenticatedClient } from "./wallet-auth";
61
+
62
+ // Login with environment variable
63
+ const auth = await walletLogin();
64
+ console.log("Logged in as:", auth.user.userName);
65
+
66
+ // Create authenticated client
67
+ const query = createAuthenticatedClient(auth.token);
68
+
69
+ // Make authenticated requests
70
+ const result = await query(`
71
+ query {
72
+ viewer {
73
+ id
74
+ userName
75
+ }
76
+ }
77
+ `);
78
+ ```
79
+
80
+ ### With Custom Endpoint
81
+
82
+ ```typescript
83
+ const auth = await walletLogin(
84
+ process.env.MATTERS_TEST_WALLET_PRIVATE_KEY,
85
+ "https://server.matters.icu/graphql"
86
+ );
87
+ ```
88
+
89
+ ## CI Configuration
90
+
91
+ ### GitHub Actions Example
92
+
93
+ ```yaml
94
+ name: E2E Tests
95
+
96
+ on: [push, pull_request]
97
+
98
+ jobs:
99
+ test:
100
+ runs-on: ubuntu-latest
101
+ env:
102
+ MATTERS_TEST_WALLET_PRIVATE_KEY: ${{ secrets.MATTERS_TEST_WALLET_PRIVATE_KEY }}
103
+ steps:
104
+ - uses: actions/checkout@v4
105
+ - uses: actions/setup-node@v4
106
+ with:
107
+ node-version: "20"
108
+ - run: npm ci
109
+ - run: npm run test:e2e
110
+ ```
111
+
112
+ ## Security Notes
113
+
114
+ 1. **Never commit private keys** to the repository
115
+ 2. Use GitHub Secrets for CI/CD
116
+ 3. The test wallet should only hold test assets
117
+ 4. Consider using a dedicated test wallet, not a production wallet
118
+ 5. The test environment (matters.icu) is separate from production (matters.town)
119
+
120
+ ## Troubleshooting
121
+
122
+ ### "ethers.js is required"
123
+
124
+ Install ethers as a dev dependency:
125
+
126
+ ```bash
127
+ npm install --save-dev ethers
128
+ ```
129
+
130
+ ### "Login failed: auth returned false"
131
+
132
+ - Verify the private key is correct
133
+ - Check if the endpoint is reachable
134
+ - Ensure the signing message hasn't expired (10 minute validity)
135
+
136
+ ### Rate Limiting
137
+
138
+ The Matters API has rate limits. If you see 429 errors, wait a few minutes before retrying.
139
+
140
+ ## Test Data Considerations
141
+
142
+ When writing tests that create content (articles, comments, etc.):
143
+
144
+ 1. Use descriptive titles with test identifiers (e.g., `[TEST] My Article`)
145
+ 2. Clean up test data after tests when possible
146
+ 3. Consider using unique identifiers to avoid conflicts between parallel test runs
147
+
148
+ ## Related Files
149
+
150
+ - [wallet-auth.ts](./wallet-auth.ts) - Wallet authentication implementation
151
+ - [api-client.ts](./api-client.ts) - GraphQL client for public queries