openwriter 0.40.1 → 0.40.3

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 (65) hide show
  1. package/dist/client/assets/{index-Dxbv2n2m.js → index-BjaX2BWN.js} +49 -49
  2. package/dist/client/index.html +1 -1
  3. package/dist/plugins/authors-voice/dist/index.d.ts +48 -0
  4. package/dist/plugins/authors-voice/dist/index.js +235 -0
  5. package/dist/plugins/authors-voice/package.json +24 -0
  6. package/dist/plugins/authors-voice/skill/LICENSE +21 -0
  7. package/dist/plugins/authors-voice/skill/README.md +126 -0
  8. package/dist/plugins/authors-voice/skill/SKILL.md +151 -0
  9. package/dist/plugins/authors-voice/skill/catalog/ai-tells.md +144 -0
  10. package/dist/plugins/authors-voice/skill/catalog/anchor-prompt.md +189 -0
  11. package/dist/plugins/authors-voice/skill/catalog/author-hints.md +119 -0
  12. package/dist/plugins/authors-voice/skill/catalog/fingerprints.md +175 -0
  13. package/dist/plugins/authors-voice/skill/catalog/hurdle.md +76 -0
  14. package/dist/plugins/authors-voice/skill/catalog/post-write-audit.md +105 -0
  15. package/dist/plugins/authors-voice/skill/docs/analysis.md +31 -0
  16. package/dist/plugins/authors-voice/skill/docs/anchor-iteration.md +176 -0
  17. package/dist/plugins/authors-voice/skill/docs/api/import.md +78 -0
  18. package/dist/plugins/authors-voice/skill/docs/api/protocol.md +140 -0
  19. package/dist/plugins/authors-voice/skill/docs/api/setup.md +37 -0
  20. package/dist/plugins/authors-voice/skill/docs/api/tools.md +102 -0
  21. package/dist/plugins/authors-voice/skill/docs/api/troubleshooting.md +7 -0
  22. package/dist/plugins/authors-voice/skill/docs/apply-protocol-deep.md +191 -0
  23. package/dist/plugins/authors-voice/skill/docs/context-hygiene.md +33 -0
  24. package/dist/plugins/authors-voice/skill/docs/setup.md +74 -0
  25. package/dist/plugins/authors-voice/skill/docs/tiers.md +13 -0
  26. package/dist/plugins/authors-voice/skill/package.json +35 -0
  27. package/dist/plugins/authors-voice/skill/prompts/skeleton.md +29 -0
  28. package/dist/plugins/authors-voice/skill/voice/README.md +51 -0
  29. package/dist/plugins/authors-voice/skill/voice/corpus/.gitkeep +0 -0
  30. package/dist/plugins/github/dist/blog-tools.d.ts +84 -0
  31. package/dist/plugins/github/dist/blog-tools.js +1208 -0
  32. package/dist/plugins/github/dist/git-sync.d.ts +46 -0
  33. package/dist/plugins/github/dist/git-sync.js +335 -0
  34. package/dist/plugins/github/dist/helpers.d.ts +127 -0
  35. package/dist/plugins/github/dist/helpers.js +67 -0
  36. package/dist/plugins/github/dist/index.d.ts +12 -0
  37. package/dist/plugins/github/dist/index.js +112 -0
  38. package/dist/plugins/github/package.json +24 -0
  39. package/dist/plugins/image-gen/dist/index.d.ts +35 -0
  40. package/dist/plugins/image-gen/dist/index.js +149 -0
  41. package/dist/plugins/image-gen/package.json +26 -0
  42. package/dist/plugins/publish/dist/helpers.d.ts +66 -0
  43. package/dist/plugins/publish/dist/helpers.js +199 -0
  44. package/dist/plugins/publish/dist/index.d.ts +3 -0
  45. package/dist/plugins/publish/dist/index.js +1156 -0
  46. package/dist/plugins/publish/dist/newsletter-tools.d.ts +2 -0
  47. package/dist/plugins/publish/dist/newsletter-tools.js +394 -0
  48. package/dist/plugins/publish/package.json +31 -0
  49. package/dist/plugins/x-api/dist/index.d.ts +27 -0
  50. package/dist/plugins/x-api/dist/index.js +368 -0
  51. package/dist/plugins/x-api/dist/server-bridge.d.ts +22 -0
  52. package/dist/plugins/x-api/dist/server-bridge.js +43 -0
  53. package/dist/plugins/x-api/package.json +27 -0
  54. package/dist/server/blog-routes.js +160 -0
  55. package/dist/server/documents.js +56 -9
  56. package/dist/server/git-sync.js +273 -0
  57. package/dist/server/marks.js +182 -0
  58. package/dist/server/mcp.js +68 -18
  59. package/dist/server/sync-routes.js +75 -0
  60. package/package.json +1 -1
  61. package/skill/SKILL.md +15 -7
  62. package/skill/docs/enrichment.md +180 -0
  63. package/skill/docs/footnotes.md +178 -0
  64. package/skill/docs/setup.md +62 -0
  65. package/skill/docs/welcome.md +21 -0
@@ -0,0 +1,368 @@
1
+ /**
2
+ * X API plugin for OpenWriter.
3
+ * Registers routes for checking X connection status and posting tweets.
4
+ * Uses @xdevplatform/xdk with OAuth1 credentials from plugin config.
5
+ */
6
+ import { Client, OAuth1 } from '@xdevplatform/xdk';
7
+ import { join, extname } from 'path';
8
+ import { readFileSync, existsSync } from 'fs';
9
+ import sharp from 'sharp';
10
+ import twitter from 'twitter-text';
11
+ import { getServerBridge } from './server-bridge.js';
12
+ const { parseTweet } = twitter;
13
+ /** X Articles API base. Two-step: draft, then publish. */
14
+ const X_API_BASE = 'https://api.x.com/2';
15
+ /** Build the OAuth1 signer from plugin config / env. Returns null when any of
16
+ * the four credentials is missing. Shared by the SDK client and the raw
17
+ * Articles calls (which the SDK doesn't model). */
18
+ function createOAuth1(config) {
19
+ const apiKey = config['api-key'] || process.env.X_API_KEY || '';
20
+ const apiSecret = config['api-secret'] || process.env.X_API_SECRET || '';
21
+ const accessToken = config['access-token'] || process.env.X_ACCESS_TOKEN || '';
22
+ const accessTokenSecret = config['access-token-secret'] || process.env.X_ACCESS_TOKEN_SECRET || '';
23
+ if (!apiKey || !apiSecret || !accessToken || !accessTokenSecret)
24
+ return null;
25
+ return new OAuth1({
26
+ apiKey,
27
+ apiSecret,
28
+ callback: 'oob',
29
+ accessToken,
30
+ accessTokenSecret,
31
+ });
32
+ }
33
+ function createXClient(config) {
34
+ const oauth1 = createOAuth1(config);
35
+ if (!oauth1)
36
+ return null;
37
+ return new Client({ oauth1 });
38
+ }
39
+ /** Make an OAuth1-signed JSON request to the X API. The SDK has no Articles
40
+ * resource, so we sign + fetch directly. `buildRequestHeader` correctly
41
+ * excludes a JSON body from the OAuth1 signature base string. */
42
+ async function xApiFetch(oauth1, method, path, body) {
43
+ const url = `${X_API_BASE}${path}`;
44
+ const bodyStr = body ? JSON.stringify(body) : '';
45
+ const authHeader = await oauth1.buildRequestHeader(method, url, bodyStr);
46
+ const headers = { Authorization: authHeader };
47
+ if (body)
48
+ headers['Content-Type'] = 'application/json';
49
+ const res = await fetch(url, { method, headers, body: body ? bodyStr : undefined });
50
+ const data = await res.json().catch(() => ({}));
51
+ return { ok: res.ok, status: res.status, data };
52
+ }
53
+ const plugin = {
54
+ name: '@openwriter/plugin-x-api',
55
+ version: '0.1.0',
56
+ description: 'Post tweets from OpenWriter',
57
+ category: 'social-media',
58
+ configSchema: {
59
+ 'api-key': { type: 'string', env: 'X_API_KEY', description: 'X API Key' },
60
+ 'api-secret': { type: 'string', env: 'X_API_SECRET', description: 'X API Secret' },
61
+ 'access-token': { type: 'string', env: 'X_ACCESS_TOKEN', description: 'X Access Token' },
62
+ 'access-token-secret': { type: 'string', env: 'X_ACCESS_TOKEN_SECRET', description: 'X Access Token Secret' },
63
+ },
64
+ registerRoutes(ctx) {
65
+ // GET /api/x/status — check if plugin is configured + authenticated
66
+ ctx.app.get('/api/x/status', async (_req, res) => {
67
+ try {
68
+ const client = createXClient(ctx.config);
69
+ if (!client) {
70
+ res.json({ connected: false });
71
+ return;
72
+ }
73
+ const me = await client.users.getMe();
74
+ const username = me?.data?.username;
75
+ res.json({ connected: true, username: username || undefined });
76
+ }
77
+ catch (err) {
78
+ console.error('[X Plugin] Status check failed:', err.message);
79
+ res.json({ connected: false, error: err.message });
80
+ }
81
+ });
82
+ // POST /api/x/post — post a tweet (with optional media)
83
+ ctx.app.post('/api/x/post', async (req, res) => {
84
+ try {
85
+ const { text, replyTo, quoteTweetId, mediaIds } = req.body;
86
+ if ((!text || typeof text !== 'string') && (!Array.isArray(mediaIds) || mediaIds.length === 0)) {
87
+ res.status(400).json({ success: false, error: 'text or mediaIds is required' });
88
+ return;
89
+ }
90
+ if (mediaIds && (!Array.isArray(mediaIds) || mediaIds.length > 4)) {
91
+ res.status(400).json({ success: false, error: 'mediaIds must be an array of 1-4 IDs' });
92
+ return;
93
+ }
94
+ const client = createXClient(ctx.config);
95
+ if (!client) {
96
+ res.status(400).json({ success: false, error: 'X API credentials not configured' });
97
+ return;
98
+ }
99
+ const body = {};
100
+ if (text)
101
+ body.text = text;
102
+ if (replyTo) {
103
+ body.reply = { inReplyToTweetId: replyTo };
104
+ }
105
+ if (quoteTweetId) {
106
+ body.quoteTweetId = quoteTweetId;
107
+ }
108
+ if (mediaIds && mediaIds.length > 0) {
109
+ body.media = { media_ids: mediaIds };
110
+ }
111
+ const result = await client.posts.create(body);
112
+ const tweetId = result?.data?.id;
113
+ const tweetUrl = tweetId ? `https://x.com/i/status/${tweetId}` : undefined;
114
+ res.json({ success: true, tweetId, tweetUrl });
115
+ }
116
+ catch (err) {
117
+ const detail = err.data ? JSON.stringify(err.data) : err.message;
118
+ console.error('[X Plugin] Post failed:', detail);
119
+ res.status(500).json({ success: false, error: detail });
120
+ }
121
+ });
122
+ // POST /api/x/post-thread — post a full thread as a reply chain (with optional media per tweet)
123
+ ctx.app.post('/api/x/post-thread', async (req, res) => {
124
+ try {
125
+ const { tweets, replyTo } = req.body;
126
+ if (!Array.isArray(tweets) || tweets.length === 0) {
127
+ res.status(400).json({ success: false, error: 'tweets must be a non-empty array' });
128
+ return;
129
+ }
130
+ // Normalize: accept string[] or { text, mediaIds? }[]
131
+ const normalized = tweets.map((t) => typeof t === 'string' ? { text: t, mediaIds: undefined } : t);
132
+ // Validate character limits using X's weighted counting (emojis=2, URLs=23, CJK=2)
133
+ const CHAR_LIMIT = 25000;
134
+ const overLimit = normalized.map((t, i) => ({ i, len: parseTweet(t.text).weightedLength })).filter(x => x.len > CHAR_LIMIT);
135
+ if (overLimit.length > 0) {
136
+ res.status(400).json({
137
+ success: false,
138
+ error: `${overLimit.length} tweet(s) exceed ${CHAR_LIMIT} chars: ${overLimit.map(x => `#${x.i + 1} (${x.len})`).join(', ')}`,
139
+ });
140
+ return;
141
+ }
142
+ // Validate mediaIds per tweet
143
+ for (let i = 0; i < normalized.length; i++) {
144
+ const ids = normalized[i].mediaIds;
145
+ if (ids && (!Array.isArray(ids) || ids.length > 4)) {
146
+ res.status(400).json({ success: false, error: `Tweet ${i + 1}: mediaIds must be an array of 1-4 IDs` });
147
+ return;
148
+ }
149
+ }
150
+ const client = createXClient(ctx.config);
151
+ if (!client) {
152
+ res.status(400).json({ success: false, error: 'X API credentials not configured' });
153
+ return;
154
+ }
155
+ const postedTweets = [];
156
+ let previousTweetId = replyTo;
157
+ for (let i = 0; i < normalized.length; i++) {
158
+ const { text, mediaIds } = normalized[i];
159
+ const body = {};
160
+ if (text)
161
+ body.text = text;
162
+ if (previousTweetId) {
163
+ body.reply = { inReplyToTweetId: previousTweetId };
164
+ }
165
+ if (mediaIds && mediaIds.length > 0) {
166
+ body.media = { media_ids: mediaIds };
167
+ }
168
+ const result = await client.posts.create(body);
169
+ const tweetId = result?.data?.id;
170
+ if (!tweetId) {
171
+ res.status(500).json({
172
+ success: false,
173
+ postedTweets,
174
+ failedAt: i,
175
+ error: `Tweet ${i + 1} posted but no ID returned`,
176
+ });
177
+ return;
178
+ }
179
+ postedTweets.push({ index: i, tweetId, text });
180
+ previousTweetId = tweetId;
181
+ }
182
+ // Build thread URL from first tweet
183
+ const firstTweetId = postedTweets[0]?.tweetId;
184
+ const threadUrl = firstTweetId ? `https://x.com/i/status/${firstTweetId}` : undefined;
185
+ console.log(`[X Plugin] Thread posted: ${postedTweets.length} tweets, ${threadUrl}`);
186
+ res.json({ success: true, postedTweets, threadUrl });
187
+ }
188
+ catch (err) {
189
+ console.error('[X Plugin] Post thread failed:', err.message);
190
+ res.status(500).json({ success: false, error: err.message });
191
+ }
192
+ });
193
+ // POST /api/x/upload-media — upload a local /_images/ file for tweet attachment
194
+ ctx.app.post('/api/x/upload-media', async (req, res) => {
195
+ try {
196
+ const { src } = req.body;
197
+ if (!src || typeof src !== 'string') {
198
+ res.status(400).json({ success: false, error: 'src is required' });
199
+ return;
200
+ }
201
+ // Security: only allow /_images/ paths, no traversal
202
+ if (!/^\/_images\/[^/\\]+$/.test(src)) {
203
+ res.status(400).json({ success: false, error: 'Invalid image path — must be /_images/<filename>' });
204
+ return;
205
+ }
206
+ const filename = src.replace('/_images/', '');
207
+ const filePath = join(ctx.dataDir, '_images', filename);
208
+ if (!existsSync(filePath)) {
209
+ res.status(404).json({ success: false, error: `Image not found: ${filename}` });
210
+ return;
211
+ }
212
+ const ext = extname(filename).toLowerCase();
213
+ const mimeMap = {
214
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
215
+ '.png': 'image/png', '.webp': 'image/webp',
216
+ '.gif': 'image/jpeg', '.bmp': 'image/bmp',
217
+ '.tiff': 'image/tiff', '.tif': 'image/tiff',
218
+ };
219
+ const mediaType = mimeMap[ext] || 'image/jpeg';
220
+ const client = createXClient(ctx.config);
221
+ if (!client) {
222
+ res.status(400).json({ success: false, error: 'X API credentials not configured' });
223
+ return;
224
+ }
225
+ let fileBuffer = readFileSync(filePath);
226
+ let uploadType = mediaType;
227
+ const origSize = fileBuffer.length;
228
+ // Compress large images or PNGs to JPEG to stay under X API limits
229
+ if (fileBuffer.length > 3 * 1024 * 1024 || ext === '.png') {
230
+ fileBuffer = Buffer.from(await sharp(fileBuffer).jpeg({ quality: 85 }).toBuffer());
231
+ uploadType = 'image/jpeg';
232
+ console.log(`[X Plugin] Compressed ${filename}: ${(origSize / 1024 / 1024).toFixed(2)}MB → ${(fileBuffer.length / 1024 / 1024).toFixed(2)}MB`);
233
+ }
234
+ console.log(`[X Plugin] Uploading ${filename}: ${(fileBuffer.length / 1024 / 1024).toFixed(2)}MB, type: ${uploadType}`);
235
+ const mediaBase64 = fileBuffer.toString('base64');
236
+ const uploadResult = await client.media.upload({
237
+ body: { media: mediaBase64, mediaCategory: 'tweet_image', mediaType: uploadType },
238
+ });
239
+ const mediaId = uploadResult?.data?.id
240
+ || uploadResult?.media_id_string;
241
+ if (!mediaId) {
242
+ res.status(500).json({ success: false, error: 'Upload succeeded but no media ID returned' });
243
+ return;
244
+ }
245
+ console.log(`[X Plugin] Media uploaded: ${filename} → ${mediaId}`);
246
+ res.json({ success: true, mediaId });
247
+ }
248
+ catch (err) {
249
+ console.error('[X Plugin] Media upload failed:', err.message);
250
+ if (err.response) {
251
+ try {
252
+ const body = await err.response.text();
253
+ console.error('[X Plugin] X API response:', err.response.status, body);
254
+ }
255
+ catch { /* ignore */ }
256
+ }
257
+ if (err.data)
258
+ console.error('[X Plugin] Error data:', JSON.stringify(err.data));
259
+ console.error('[X Plugin] Full error:', JSON.stringify(err, Object.getOwnPropertyNames(err)));
260
+ res.status(500).json({ success: false, error: err.message });
261
+ }
262
+ });
263
+ // POST /api/x/post-article — publish the active document as a native X
264
+ // Article. Two-step X flow: draft, then publish. Reads the active server
265
+ // doc (same source the managed/publish path reads) and converts it to X's
266
+ // DraftJS content_state via the shared converter.
267
+ ctx.app.post('/api/x/post-article', async (_req, res) => {
268
+ try {
269
+ const oauth1 = createOAuth1(ctx.config);
270
+ if (!oauth1) {
271
+ res.status(400).json({ success: false, error: 'X API credentials not configured' });
272
+ return;
273
+ }
274
+ const bridge = await getServerBridge();
275
+ const doc = bridge.getDocument();
276
+ const title = (bridge.getTitle() || '').trim();
277
+ const metadata = bridge.getMetadata() || {};
278
+ if (!title || title === 'Untitled') {
279
+ res.status(400).json({ success: false, error: 'Article needs a title before posting.' });
280
+ return;
281
+ }
282
+ const contentState = bridge.tiptapToDraftjs(doc);
283
+ if (!contentState.blocks.some((b) => (b.text || '').trim().length > 0)) {
284
+ res.status(400).json({ success: false, error: 'Article body is empty.' });
285
+ return;
286
+ }
287
+ // Optional cover image — upload to X and attach as cover_media.
288
+ let coverMedia;
289
+ const coverSrc = metadata?.articleContext?.coverImage;
290
+ if (coverSrc && typeof coverSrc === 'string' && /^\/_images\/[^/\\]+$/.test(coverSrc)) {
291
+ const client = createXClient(ctx.config);
292
+ if (client) {
293
+ const mediaId = await uploadCoverMedia(client, ctx.dataDir, coverSrc);
294
+ if (mediaId)
295
+ coverMedia = { media_category: 'TWEET_IMAGE', media_id: mediaId };
296
+ }
297
+ }
298
+ // Step 1 — create the draft.
299
+ const draftBody = { title, content_state: contentState };
300
+ if (coverMedia)
301
+ draftBody.cover_media = coverMedia;
302
+ const draft = await xApiFetch(oauth1, 'POST', '/articles/draft', draftBody);
303
+ if (!draft.ok) {
304
+ const detail = draft.data?.detail || draft.data?.title || JSON.stringify(draft.data);
305
+ console.error('[X Plugin] Article draft failed:', draft.status, detail);
306
+ res.status(draft.status || 500).json({ success: false, error: `Draft failed: ${detail}` });
307
+ return;
308
+ }
309
+ const articleId = draft.data?.data?.id;
310
+ if (!articleId) {
311
+ res.status(500).json({ success: false, error: 'Draft created but no article id returned.' });
312
+ return;
313
+ }
314
+ // Step 2 — publish the draft (makes it public). Surface any error
315
+ // verbatim — a draft is private until this succeeds.
316
+ const published = await xApiFetch(oauth1, 'POST', `/articles/${articleId}/publish`);
317
+ if (!published.ok) {
318
+ const detail = published.data?.detail || published.data?.title || JSON.stringify(published.data);
319
+ console.error('[X Plugin] Article publish failed:', published.status, detail);
320
+ res.status(published.status || 500).json({ success: false, articleId, error: `Publish failed: ${detail}` });
321
+ return;
322
+ }
323
+ const postId = published.data?.data?.post_id;
324
+ const articleUrl = postId ? `https://x.com/i/status/${postId}` : undefined;
325
+ console.log(`[X Plugin] Article published: ${articleId} -> ${articleUrl}`);
326
+ res.json({ success: true, articleId, postId, articleUrl });
327
+ }
328
+ catch (err) {
329
+ const detail = err.data ? JSON.stringify(err.data) : err.message;
330
+ console.error('[X Plugin] Post article failed:', detail);
331
+ res.status(500).json({ success: false, error: detail });
332
+ }
333
+ });
334
+ },
335
+ };
336
+ /** Upload an article cover image to X, returning its media_id. Mirrors the
337
+ * /api/x/upload-media compression rules (>3MB or PNG -> JPEG). Returns null on
338
+ * any failure — the article still posts, just without a cover. */
339
+ async function uploadCoverMedia(client, dataDir, src) {
340
+ try {
341
+ const filename = src.replace('/_images/', '');
342
+ const filePath = join(dataDir, '_images', filename);
343
+ if (!existsSync(filePath))
344
+ return null;
345
+ const ext = extname(filename).toLowerCase();
346
+ const mimeMap = {
347
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
348
+ '.png': 'image/png', '.webp': 'image/webp',
349
+ '.gif': 'image/jpeg', '.bmp': 'image/bmp',
350
+ '.tiff': 'image/tiff', '.tif': 'image/tiff',
351
+ };
352
+ let fileBuffer = readFileSync(filePath);
353
+ let uploadType = mimeMap[ext] || 'image/jpeg';
354
+ if (fileBuffer.length > 3 * 1024 * 1024 || ext === '.png') {
355
+ fileBuffer = Buffer.from(await sharp(fileBuffer).jpeg({ quality: 85 }).toBuffer());
356
+ uploadType = 'image/jpeg';
357
+ }
358
+ const uploadResult = await client.media.upload({
359
+ body: { media: fileBuffer.toString('base64'), mediaCategory: 'tweet_image', mediaType: uploadType },
360
+ });
361
+ return uploadResult?.data?.id || uploadResult?.media_id_string || null;
362
+ }
363
+ catch (err) {
364
+ console.error('[X Plugin] Cover upload failed:', err.message);
365
+ return null;
366
+ }
367
+ }
368
+ export default plugin;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Thin runtime bridge to the OpenWriter server modules this plugin needs for
3
+ * article publishing — the active document, its title/metadata, the data dir,
4
+ * and the shared TipTap -> DraftJS converter.
5
+ *
6
+ * The converter (server/tiptap-draftjs.ts) is shared with plugins/publish so
7
+ * both posting paths produce identical X content_state. We import the compiled
8
+ * server module at runtime rather than vendoring it, resolving both the npm
9
+ * package layout and the monorepo layout — the same dual-path trick the publish
10
+ * plugin's helpers use.
11
+ */
12
+ export interface ServerBridge {
13
+ getDocument: () => any;
14
+ getTitle: () => string;
15
+ getMetadata: () => Record<string, any>;
16
+ getDataDir: () => string;
17
+ tiptapToDraftjs: (doc: any) => {
18
+ blocks: any[];
19
+ entities: any[];
20
+ };
21
+ }
22
+ export declare function getServerBridge(): Promise<ServerBridge>;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Thin runtime bridge to the OpenWriter server modules this plugin needs for
3
+ * article publishing — the active document, its title/metadata, the data dir,
4
+ * and the shared TipTap -> DraftJS converter.
5
+ *
6
+ * The converter (server/tiptap-draftjs.ts) is shared with plugins/publish so
7
+ * both posting paths produce identical X content_state. We import the compiled
8
+ * server module at runtime rather than vendoring it, resolving both the npm
9
+ * package layout and the monorepo layout — the same dual-path trick the publish
10
+ * plugin's helpers use.
11
+ */
12
+ // npm package: dist/plugins/x-api/dist/server-bridge.js -> dist/server/
13
+ // monorepo: plugins/x-api/dist/server-bridge.js -> packages/openwriter/dist/server/
14
+ const npmBase = new URL('../../../server/', import.meta.url).href;
15
+ const monoBase = new URL('../../../packages/openwriter/dist/server/', import.meta.url).href;
16
+ let cached = null;
17
+ async function tryImport(base) {
18
+ const [state, helpers, draftjs] = await Promise.all([
19
+ import(base + 'state.js'),
20
+ import(base + 'helpers.js'),
21
+ import(base + 'tiptap-draftjs.js'),
22
+ ]);
23
+ return { state, helpers, draftjs };
24
+ }
25
+ export async function getServerBridge() {
26
+ if (cached)
27
+ return cached;
28
+ let state, helpers, draftjs;
29
+ try {
30
+ ({ state, helpers, draftjs } = await tryImport(npmBase));
31
+ }
32
+ catch {
33
+ ({ state, helpers, draftjs } = await tryImport(monoBase));
34
+ }
35
+ cached = {
36
+ getDocument: state.getDocument,
37
+ getTitle: state.getTitle,
38
+ getMetadata: state.getMetadata,
39
+ getDataDir: helpers.getDataDir,
40
+ tiptapToDraftjs: draftjs.tiptapToDraftjs,
41
+ };
42
+ return cached;
43
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@openwriter/plugin-x-api",
3
+ "version": "0.1.0",
4
+ "description": "Post tweets from OpenWriter",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "dev": "tsc --watch"
10
+ },
11
+ "dependencies": {
12
+ "@xdevplatform/xdk": "^0.4.0",
13
+ "twitter-text": "^3.1.0"
14
+ },
15
+ "devDependencies": {
16
+ "@types/express": "^5.0.0",
17
+ "typescript": "^5.6.0"
18
+ },
19
+ "openwriter": {
20
+ "displayName": "X / Twitter",
21
+ "category": "social-media"
22
+ },
23
+ "files": [
24
+ "dist/",
25
+ "package.json"
26
+ ]
27
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Blog publishing routes — push blog content to GitHub via platform connection.
3
+ * Builds clean YAML frontmatter + markdown body, collects images, posts to platform.
4
+ */
5
+ import { Router } from 'express';
6
+ import { readFileSync, existsSync } from 'fs';
7
+ import { join } from 'path';
8
+ import matter from 'gray-matter';
9
+ import { getDocument, getTitle, getMetadata, save, cancelDebouncedSave } from './state.js';
10
+ import { tiptapToMarkdown } from './markdown.js';
11
+ import { platformFetch, isAuthenticated } from './connections.js';
12
+ import { getDataDir } from './helpers.js';
13
+ export function createBlogRouter() {
14
+ const router = Router();
15
+ // Find active GitHub connection for blog publishing
16
+ router.get('/api/blog/connection', async (_req, res) => {
17
+ try {
18
+ if (!isAuthenticated()) {
19
+ res.json({ connection: null });
20
+ return;
21
+ }
22
+ const upstream = await platformFetch('/connections/unified');
23
+ if (!upstream.ok) {
24
+ res.json({ connection: null });
25
+ return;
26
+ }
27
+ const data = await upstream.json();
28
+ const ghConn = data.connections?.find((c) => c.provider === 'github' && c.status === 'active');
29
+ res.json({ connection: ghConn || null });
30
+ }
31
+ catch {
32
+ res.json({ connection: null });
33
+ }
34
+ });
35
+ // Publish blog post to GitHub via platform connection
36
+ router.post('/api/blog/publish', async (req, res) => {
37
+ try {
38
+ if (!isAuthenticated()) {
39
+ res.status(401).json({ error: 'Not authenticated' });
40
+ return;
41
+ }
42
+ // Flush current doc to disk
43
+ cancelDebouncedSave();
44
+ save();
45
+ const doc = getDocument();
46
+ const title = getTitle();
47
+ const metadata = getMetadata();
48
+ const blogCtx = metadata.blogContext;
49
+ if (!blogCtx?.active) {
50
+ res.status(400).json({ error: 'Not a blog document' });
51
+ return;
52
+ }
53
+ // Get clean markdown body (strip OpenWriter JSON frontmatter)
54
+ const fullMd = tiptapToMarkdown(doc, title, metadata);
55
+ const parsed = matter(fullMd);
56
+ let markdownBody = parsed.content.trim();
57
+ // Build clean blog YAML frontmatter — only include fields the target schema expects
58
+ const fm = {
59
+ title,
60
+ description: blogCtx.description || '',
61
+ date: blogCtx.date || new Date().toISOString().split('T')[0],
62
+ };
63
+ // Optional fields — only include if explicitly set
64
+ if (blogCtx.author)
65
+ fm.author = blogCtx.author;
66
+ if (blogCtx.layout)
67
+ fm.layout = blogCtx.layout;
68
+ if (blogCtx.category)
69
+ fm.category = blogCtx.category;
70
+ if (blogCtx.tags?.length)
71
+ fm.tags = blogCtx.tags;
72
+ if (blogCtx.draft)
73
+ fm.draft = true;
74
+ // Find GitHub connection
75
+ const connectionId = req.body.connectionId;
76
+ const connRes = await platformFetch('/connections/unified');
77
+ if (!connRes.ok) {
78
+ res.status(500).json({ error: 'Failed to fetch connections' });
79
+ return;
80
+ }
81
+ const connData = await connRes.json();
82
+ const ghConn = connectionId
83
+ ? connData.connections?.find((c) => c.id === connectionId)
84
+ : connData.connections?.find((c) => c.provider === 'github' && c.status === 'active');
85
+ if (!ghConn) {
86
+ res.status(400).json({ error: 'No GitHub connection found. Connect a GitHub repo first.' });
87
+ return;
88
+ }
89
+ // Fetch connection config to derive image web path
90
+ let imageWebPrefix = '/images';
91
+ try {
92
+ const cfgRes = await platformFetch(`/connections/${ghConn.id}/config`);
93
+ if (cfgRes.ok) {
94
+ const cfg = await cfgRes.json();
95
+ const imgDir = cfg.config?.imageDir || cfg.imageDir;
96
+ if (imgDir) {
97
+ imageWebPrefix = '/' + imgDir.replace(/^public\/?/, '');
98
+ }
99
+ }
100
+ }
101
+ catch { /* fall back to /images */ }
102
+ // Cover image — read as base64 for GitHub commit
103
+ let imageBase64;
104
+ let imageFilename;
105
+ if (blogCtx.coverImage) {
106
+ const imgPath = blogCtx.coverImage.replace(/^\/_images\//, '');
107
+ const fullImgPath = join(getDataDir(), '_images', imgPath);
108
+ if (existsSync(fullImgPath)) {
109
+ imageBase64 = readFileSync(fullImgPath).toString('base64');
110
+ imageFilename = imgPath;
111
+ fm.image = `${imageWebPrefix}/${imgPath}`;
112
+ }
113
+ }
114
+ // Collect inline images and rewrite paths in markdown
115
+ const inlineImages = [];
116
+ const imgRegex = /!\[([^\]]*)\]\(\/_images\/([^)]+)\)/g;
117
+ markdownBody = markdownBody.replace(imgRegex, (_match, alt, imgFile) => {
118
+ const fullImgPath = join(getDataDir(), '_images', imgFile);
119
+ if (existsSync(fullImgPath)) {
120
+ inlineImages.push({ filename: imgFile, base64: readFileSync(fullImgPath).toString('base64') });
121
+ }
122
+ return `![${alt}](${imageWebPrefix}/${imgFile})`;
123
+ });
124
+ // Assemble full markdown with YAML frontmatter
125
+ const yamlLines = Object.entries(fm).map(([k, v]) => {
126
+ if (Array.isArray(v))
127
+ return `${k}:\n${v.map(i => ` - ${JSON.stringify(i)}`).join('\n')}`;
128
+ if (typeof v === 'boolean')
129
+ return `${k}: ${v}`;
130
+ return `${k}: ${JSON.stringify(v)}`;
131
+ });
132
+ const fullMarkdown = `---\n${yamlLines.join('\n')}\n---\n\n${markdownBody}`;
133
+ // Build target filename from slug or title
134
+ const slug = blogCtx.slug || title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
135
+ const filename = `${slug}.md`;
136
+ // Post to platform — matches worker's expected payload:
137
+ // { markdown, filename, imageBase64?, imageFilename?, commitMessage? }
138
+ const upstream = await platformFetch(`/connections/${ghConn.id}/post`, {
139
+ method: 'POST',
140
+ body: JSON.stringify({
141
+ markdown: fullMarkdown,
142
+ filename,
143
+ ...(imageBase64 && imageFilename ? { imageBase64, imageFilename } : {}),
144
+ ...(inlineImages.length ? { images: inlineImages } : {}),
145
+ commitMessage: `Add blog post: ${title}`,
146
+ }),
147
+ });
148
+ const result = await upstream.json();
149
+ if (!upstream.ok) {
150
+ res.status(upstream.status).json(result);
151
+ return;
152
+ }
153
+ res.json({ success: true, ...result });
154
+ }
155
+ catch (err) {
156
+ res.status(500).json({ error: err.message });
157
+ }
158
+ });
159
+ return router;
160
+ }