openwriter 0.15.0 → 0.17.0
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/dist/client/assets/index-0ttVnjRp.css +1 -0
- package/dist/client/assets/{index-B5MXw2pg.js → index-BZ7LCzrR.js} +64 -64
- package/dist/client/index.html +2 -2
- package/dist/plugins/authors-voice/dist/index.d.ts +41 -0
- package/dist/plugins/authors-voice/dist/index.js +206 -0
- package/dist/plugins/authors-voice/package.json +23 -0
- package/dist/plugins/image-gen/dist/index.d.ts +35 -0
- package/dist/plugins/image-gen/dist/index.js +141 -0
- package/dist/plugins/image-gen/package.json +26 -0
- package/dist/plugins/publish/dist/helpers.d.ts +66 -0
- package/dist/plugins/publish/dist/helpers.js +199 -0
- package/dist/plugins/publish/dist/index.d.ts +3 -0
- package/dist/plugins/publish/dist/index.js +1130 -0
- package/dist/plugins/publish/dist/newsletter-tools.d.ts +2 -0
- package/dist/plugins/publish/dist/newsletter-tools.js +394 -0
- package/dist/plugins/publish/package.json +31 -0
- package/dist/plugins/x-api/dist/index.d.ts +27 -0
- package/dist/plugins/x-api/dist/index.js +240 -0
- package/dist/plugins/x-api/package.json +27 -0
- package/dist/server/compact.js +28 -2
- package/dist/server/documents.js +234 -3
- package/dist/server/enrichment.js +125 -0
- package/dist/server/export-routes.js +2 -0
- package/dist/server/install-skill.js +15 -0
- package/dist/server/markdown-parse.js +153 -14
- package/dist/server/markdown-serialize.js +100 -17
- package/dist/server/mcp.js +291 -25
- package/dist/server/node-blocks.js +41 -1
- package/dist/server/node-fingerprint.js +347 -73
- package/dist/server/node-matcher.js +19 -44
- package/dist/server/pending-overlay.js +21 -4
- package/dist/server/state.js +225 -41
- package/dist/server/workspaces.js +27 -5
- package/dist/server/ws.js +10 -0
- package/package.json +2 -1
- package/skill/SKILL.md +38 -7
- package/skill/agents/openwriter-enrichment-minion.md +177 -0
- package/skill/docs/enrichment.md +179 -0
- package/skill/docs/footnotes.md +178 -0
- package/dist/client/assets/index-B3iORmCT.css +0 -1
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { getServerModules, documentToEmail, extractLocalImages, publishFetch, } from './helpers.js';
|
|
3
|
+
export function newsletterTools(config) {
|
|
4
|
+
return [
|
|
5
|
+
{
|
|
6
|
+
name: 'send_newsletter',
|
|
7
|
+
description: 'Send the current document as a newsletter. Sends to all subscribers, or to a single test address if test_email is provided. Supports subscriber selection via subscriber_ids or exclude_issue_id.',
|
|
8
|
+
inputSchema: {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
subject: {
|
|
12
|
+
type: 'string',
|
|
13
|
+
description: 'Email subject line. Defaults to the document title if not provided.',
|
|
14
|
+
},
|
|
15
|
+
format: {
|
|
16
|
+
type: 'string',
|
|
17
|
+
enum: ['html', 'plaintext'],
|
|
18
|
+
description: 'Email format: "html" (rich formatted) or "plaintext" (plain text only). Defaults to "html".',
|
|
19
|
+
},
|
|
20
|
+
test_email: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
description: 'If provided, sends a test email to this address instead of all subscribers.',
|
|
23
|
+
},
|
|
24
|
+
subscriber_ids: {
|
|
25
|
+
type: 'array',
|
|
26
|
+
items: { type: 'string' },
|
|
27
|
+
description: 'Send only to these subscriber IDs (use list_subscribers to find IDs).',
|
|
28
|
+
},
|
|
29
|
+
exclude_issue_id: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
description: 'Send to subscribers who did NOT receive this issue (use list_newsletter_issues to find issue IDs). For "send to remaining" after a partial send.',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
handler: async (params) => {
|
|
36
|
+
const { html, text, subject: docTitle, json } = await documentToEmail();
|
|
37
|
+
const subject = params.subject || docTitle;
|
|
38
|
+
const format = params.format || 'html';
|
|
39
|
+
const testEmail = params.test_email;
|
|
40
|
+
let subscriberIds = params.subscriber_ids;
|
|
41
|
+
if (typeof subscriberIds === 'string') {
|
|
42
|
+
try {
|
|
43
|
+
subscriberIds = JSON.parse(subscriberIds);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
subscriberIds = undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const excludeIssueId = params.exclude_issue_id;
|
|
50
|
+
const server = await getServerModules();
|
|
51
|
+
const metadata = server.getMetadata();
|
|
52
|
+
const previewText = metadata?.newsletterContext?.previewText || undefined;
|
|
53
|
+
if (!text.trim() && !html.trim()) {
|
|
54
|
+
return { error: 'Newsletter body is empty. Write some content in the editor before sending.' };
|
|
55
|
+
}
|
|
56
|
+
const images = format === 'html' && html ? await extractLocalImages(html) : [];
|
|
57
|
+
const docId = server.getDocId();
|
|
58
|
+
const res = await publishFetch(config, '/newsletter/issues/send', {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
body: JSON.stringify({
|
|
61
|
+
subject,
|
|
62
|
+
content_html: format === 'html' ? html : null,
|
|
63
|
+
content_text: text,
|
|
64
|
+
content_json: json,
|
|
65
|
+
format,
|
|
66
|
+
test_email: testEmail,
|
|
67
|
+
preview_text: previewText,
|
|
68
|
+
images,
|
|
69
|
+
document_id: docId || undefined,
|
|
70
|
+
subscriber_ids: subscriberIds,
|
|
71
|
+
exclude_issue_id: excludeIssueId,
|
|
72
|
+
}),
|
|
73
|
+
});
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
const err = await res.json().catch(() => ({}));
|
|
76
|
+
return { error: `Send failed: ${err.error || res.statusText}` };
|
|
77
|
+
}
|
|
78
|
+
const data = await res.json();
|
|
79
|
+
if (data.test) {
|
|
80
|
+
return {
|
|
81
|
+
success: true,
|
|
82
|
+
test: true,
|
|
83
|
+
sent_to: data.sent_to,
|
|
84
|
+
message: `Test email sent to ${data.sent_to}.`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
success: true,
|
|
89
|
+
sent: data.sent,
|
|
90
|
+
failed: data.failed,
|
|
91
|
+
issueId: data.issueId,
|
|
92
|
+
message: `Newsletter sent to ${data.sent} subscribers.${data.failed && data.failed > 0 ? ` ${data.failed} failed.` : ''}`,
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'list_subscribers',
|
|
98
|
+
description: 'List newsletter subscribers for the active profile.',
|
|
99
|
+
inputSchema: {
|
|
100
|
+
type: 'object',
|
|
101
|
+
properties: {
|
|
102
|
+
limit: { type: 'number', description: 'Max subscribers to return (default 100)' },
|
|
103
|
+
offset: { type: 'number', description: 'Pagination offset (default 0)' },
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
handler: async (params) => {
|
|
107
|
+
const limit = params.limit || 100;
|
|
108
|
+
const offset = params.offset || 0;
|
|
109
|
+
const res = await publishFetch(config, `/newsletter/subscribers?limit=${limit}&offset=${offset}`, {});
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
const err = await res.json().catch(() => ({}));
|
|
112
|
+
return { error: `Failed to list subscribers: ${err.error || res.statusText}` };
|
|
113
|
+
}
|
|
114
|
+
const data = await res.json();
|
|
115
|
+
const countRes = await publishFetch(config, '/newsletter/subscribers/count', {});
|
|
116
|
+
const countData = countRes.ok
|
|
117
|
+
? await countRes.json()
|
|
118
|
+
: { count: data.subscribers.length };
|
|
119
|
+
return {
|
|
120
|
+
count: countData.count,
|
|
121
|
+
subscribers: data.subscribers.map((s) => ({
|
|
122
|
+
id: s.id,
|
|
123
|
+
email: s.email,
|
|
124
|
+
name: s.name,
|
|
125
|
+
subscribedAt: s.subscribed_at,
|
|
126
|
+
})),
|
|
127
|
+
};
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: 'add_subscriber',
|
|
132
|
+
description: 'Add a subscriber to the newsletter for the active profile.',
|
|
133
|
+
inputSchema: {
|
|
134
|
+
type: 'object',
|
|
135
|
+
properties: {
|
|
136
|
+
email: { type: 'string', description: 'Subscriber email address' },
|
|
137
|
+
name: { type: 'string', description: 'Subscriber name (optional)' },
|
|
138
|
+
},
|
|
139
|
+
required: ['email'],
|
|
140
|
+
},
|
|
141
|
+
handler: async (params) => {
|
|
142
|
+
const res = await publishFetch(config, '/newsletter/subscribers', {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
body: JSON.stringify({
|
|
145
|
+
email: params.email,
|
|
146
|
+
name: params.name || undefined,
|
|
147
|
+
}),
|
|
148
|
+
});
|
|
149
|
+
if (!res.ok) {
|
|
150
|
+
const err = await res.json().catch(() => ({}));
|
|
151
|
+
return { error: `Failed to add subscriber: ${err.error || res.statusText}` };
|
|
152
|
+
}
|
|
153
|
+
const data = await res.json();
|
|
154
|
+
return {
|
|
155
|
+
success: true,
|
|
156
|
+
subscriber: data.subscriber,
|
|
157
|
+
message: `Added ${data.subscriber.email} to newsletter.`,
|
|
158
|
+
};
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: 'import_subscribers',
|
|
163
|
+
description: 'Bulk import subscribers from a CSV file or pasted CSV text. Supports common export formats from ConvertKit, Mailchimp, Substack, Beehiiv, etc. Auto-detects column names (email/Email Address/email_address, name/first_name+last_name). Skips invalid emails and unsubscribed rows.',
|
|
164
|
+
inputSchema: {
|
|
165
|
+
type: 'object',
|
|
166
|
+
properties: {
|
|
167
|
+
file: {
|
|
168
|
+
type: 'string',
|
|
169
|
+
description: 'Absolute path to a CSV file (e.g. "C:/Users/me/Downloads/subscribers.csv")',
|
|
170
|
+
},
|
|
171
|
+
csv_text: {
|
|
172
|
+
type: 'string',
|
|
173
|
+
description: 'Raw CSV text pasted directly (header row + data rows)',
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
handler: async (params) => {
|
|
178
|
+
const filePath = params.file;
|
|
179
|
+
const csvText = params.csv_text;
|
|
180
|
+
if (!filePath && !csvText) {
|
|
181
|
+
return { error: 'Provide either file (path to CSV) or csv_text (raw CSV content)' };
|
|
182
|
+
}
|
|
183
|
+
let raw;
|
|
184
|
+
if (filePath) {
|
|
185
|
+
try {
|
|
186
|
+
raw = readFileSync(filePath, 'utf-8');
|
|
187
|
+
}
|
|
188
|
+
catch (e) {
|
|
189
|
+
return { error: `Failed to read file: ${e.message}` };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
raw = csvText;
|
|
194
|
+
}
|
|
195
|
+
// Parse CSV
|
|
196
|
+
const lines = raw.split(/\r?\n/).filter((l) => l.trim());
|
|
197
|
+
if (lines.length < 2) {
|
|
198
|
+
return { error: 'CSV must have a header row and at least one data row' };
|
|
199
|
+
}
|
|
200
|
+
const parseCsvLine = (line) => {
|
|
201
|
+
const fields = [];
|
|
202
|
+
let current = '';
|
|
203
|
+
let inQuotes = false;
|
|
204
|
+
for (let i = 0; i < line.length; i++) {
|
|
205
|
+
const ch = line[i];
|
|
206
|
+
if (ch === '"') {
|
|
207
|
+
if (inQuotes && line[i + 1] === '"') {
|
|
208
|
+
current += '"';
|
|
209
|
+
i++;
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
inQuotes = !inQuotes;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
else if (ch === ',' && !inQuotes) {
|
|
216
|
+
fields.push(current.trim());
|
|
217
|
+
current = '';
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
current += ch;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
fields.push(current.trim());
|
|
224
|
+
return fields;
|
|
225
|
+
};
|
|
226
|
+
const headers = parseCsvLine(lines[0]).map((h) => h.toLowerCase().replace(/['"]/g, '').trim());
|
|
227
|
+
const emailAliases = ['email', 'email_address', 'email address', 'subscriber_email', 'e-mail', 'contact email'];
|
|
228
|
+
const nameAliases = ['name', 'full_name', 'full name', 'subscriber_name'];
|
|
229
|
+
const firstNameAliases = ['first_name', 'first name', 'firstname', 'first'];
|
|
230
|
+
const lastNameAliases = ['last_name', 'last name', 'lastname', 'last'];
|
|
231
|
+
const statusAliases = ['status', 'state', 'subscription_status'];
|
|
232
|
+
const findCol = (aliases) => headers.findIndex((h) => aliases.includes(h));
|
|
233
|
+
const emailIdx = findCol(emailAliases);
|
|
234
|
+
const nameIdx = findCol(nameAliases);
|
|
235
|
+
const firstIdx = findCol(firstNameAliases);
|
|
236
|
+
const lastIdx = findCol(lastNameAliases);
|
|
237
|
+
const statusIdx = findCol(statusAliases);
|
|
238
|
+
if (emailIdx === -1) {
|
|
239
|
+
return { error: `Could not find email column. Found headers: ${headers.join(', ')}` };
|
|
240
|
+
}
|
|
241
|
+
const skipStatuses = new Set(['unsubscribed', 'cancelled', 'canceled', 'inactive', 'bounced', 'complained']);
|
|
242
|
+
const subscribers = [];
|
|
243
|
+
let skippedStatus = 0;
|
|
244
|
+
for (let i = 1; i < lines.length; i++) {
|
|
245
|
+
const fields = parseCsvLine(lines[i]);
|
|
246
|
+
const email = (fields[emailIdx] || '').replace(/['"]/g, '').trim();
|
|
247
|
+
if (!email)
|
|
248
|
+
continue;
|
|
249
|
+
if (statusIdx !== -1) {
|
|
250
|
+
const status = (fields[statusIdx] || '').replace(/['"]/g, '').trim().toLowerCase();
|
|
251
|
+
if (skipStatuses.has(status)) {
|
|
252
|
+
skippedStatus++;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
let name;
|
|
257
|
+
if (nameIdx !== -1) {
|
|
258
|
+
name = (fields[nameIdx] || '').replace(/['"]/g, '').trim() || undefined;
|
|
259
|
+
}
|
|
260
|
+
else if (firstIdx !== -1) {
|
|
261
|
+
const first = (fields[firstIdx] || '').replace(/['"]/g, '').trim();
|
|
262
|
+
const last = lastIdx !== -1 ? (fields[lastIdx] || '').replace(/['"]/g, '').trim() : '';
|
|
263
|
+
name = [first, last].filter(Boolean).join(' ') || undefined;
|
|
264
|
+
}
|
|
265
|
+
subscribers.push({ email, ...(name ? { name } : {}) });
|
|
266
|
+
}
|
|
267
|
+
if (subscribers.length === 0) {
|
|
268
|
+
return { error: `No valid subscribers found. ${skippedStatus} skipped (unsubscribed/inactive).` };
|
|
269
|
+
}
|
|
270
|
+
const res = await publishFetch(config, '/newsletter/subscribers/import', {
|
|
271
|
+
method: 'POST',
|
|
272
|
+
body: JSON.stringify({ subscribers }),
|
|
273
|
+
});
|
|
274
|
+
if (!res.ok) {
|
|
275
|
+
const err = await res.json().catch(() => ({}));
|
|
276
|
+
return { error: `Import failed: ${err.error || res.statusText}` };
|
|
277
|
+
}
|
|
278
|
+
const data = (await res.json());
|
|
279
|
+
return {
|
|
280
|
+
success: true,
|
|
281
|
+
imported: data.imported,
|
|
282
|
+
skippedInvalid: data.skipped,
|
|
283
|
+
skippedStatus,
|
|
284
|
+
total: subscribers.length + skippedStatus,
|
|
285
|
+
message: `Imported ${data.imported} subscribers.${skippedStatus ? ` ${skippedStatus} skipped (unsubscribed/inactive).` : ''}${data.skipped ? ` ${data.skipped} skipped (invalid email).` : ''}`,
|
|
286
|
+
};
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
name: 'resend_to_unopened',
|
|
291
|
+
description: 'Resend a newsletter issue to subscribers who didn\'t open it. Uses a new subject line but the same email body. Can only be done once per issue. Best practice: wait 48-72 hours after original send.',
|
|
292
|
+
inputSchema: {
|
|
293
|
+
type: 'object',
|
|
294
|
+
properties: {
|
|
295
|
+
issue_id: { type: 'string', description: 'Newsletter issue ID to resend (from list_newsletter_issues)' },
|
|
296
|
+
subject: { type: 'string', description: 'New subject line for the resend (tip: try a different angle)' },
|
|
297
|
+
},
|
|
298
|
+
required: ['issue_id', 'subject'],
|
|
299
|
+
},
|
|
300
|
+
handler: async (params) => {
|
|
301
|
+
const issueId = params.issue_id;
|
|
302
|
+
const subject = params.subject;
|
|
303
|
+
const res = await publishFetch(config, `/newsletter/issues/${issueId}/resend`, {
|
|
304
|
+
method: 'POST',
|
|
305
|
+
body: JSON.stringify({ subject }),
|
|
306
|
+
});
|
|
307
|
+
if (!res.ok) {
|
|
308
|
+
const err = await res.json().catch(() => ({}));
|
|
309
|
+
return { error: `Resend failed: ${err.error || res.statusText}` };
|
|
310
|
+
}
|
|
311
|
+
const data = await res.json();
|
|
312
|
+
return {
|
|
313
|
+
success: true,
|
|
314
|
+
sent: data.sent,
|
|
315
|
+
failed: data.failed,
|
|
316
|
+
non_openers: data.non_openers,
|
|
317
|
+
message: `Resent to ${data.sent} non-openers with new subject: "${subject}".${data.failed > 0 ? ` ${data.failed} failed.` : ''}`,
|
|
318
|
+
};
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
name: 'list_newsletter_issues',
|
|
323
|
+
description: 'List past newsletter sends with open/click stats. Returns issue IDs needed for get_newsletter_analytics and send_newsletter exclude_issue_id.',
|
|
324
|
+
inputSchema: {
|
|
325
|
+
type: 'object',
|
|
326
|
+
properties: {
|
|
327
|
+
limit: { type: 'number', description: 'Max issues to return (default 20, max 200)' },
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
handler: async (params) => {
|
|
331
|
+
const limit = Math.min(params.limit || 20, 200);
|
|
332
|
+
const res = await publishFetch(config, `/newsletter/issues?limit=${limit}`);
|
|
333
|
+
if (!res.ok) {
|
|
334
|
+
const err = await res.json().catch(() => ({}));
|
|
335
|
+
return { error: `Failed to list issues: ${err.error || res.statusText}` };
|
|
336
|
+
}
|
|
337
|
+
const data = await res.json();
|
|
338
|
+
return {
|
|
339
|
+
issues: data.issues.map((i) => ({
|
|
340
|
+
id: i.id,
|
|
341
|
+
subject: i.subject,
|
|
342
|
+
status: i.status,
|
|
343
|
+
sent_at: i.sent_at,
|
|
344
|
+
recipient_count: i.recipient_count,
|
|
345
|
+
unique_opens: i.unique_opens,
|
|
346
|
+
unique_clicks: i.unique_clicks,
|
|
347
|
+
})),
|
|
348
|
+
};
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
name: 'get_newsletter_analytics',
|
|
353
|
+
description: 'Get detailed analytics for a specific newsletter issue. Shows delivery stats, per-subscriber event breakdown, and recipient list.',
|
|
354
|
+
inputSchema: {
|
|
355
|
+
type: 'object',
|
|
356
|
+
properties: {
|
|
357
|
+
issue_id: { type: 'string', description: 'Newsletter issue ID (from list_newsletter_issues)' },
|
|
358
|
+
},
|
|
359
|
+
required: ['issue_id'],
|
|
360
|
+
},
|
|
361
|
+
handler: async (params) => {
|
|
362
|
+
const issueId = params.issue_id;
|
|
363
|
+
const res = await publishFetch(config, `/newsletter/issues/${issueId}/analytics`);
|
|
364
|
+
if (!res.ok) {
|
|
365
|
+
const err = await res.json().catch(() => ({}));
|
|
366
|
+
return { error: `Failed to get analytics: ${err.error || res.statusText}` };
|
|
367
|
+
}
|
|
368
|
+
return await res.json();
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
name: 'get_subscribe_embed',
|
|
373
|
+
description: 'Get the public subscribe URL and embed code for the active profile. Returns an HTML form snippet (works without JS) and a JS fetch snippet. Use this to help users set up newsletter signup forms on their websites.',
|
|
374
|
+
inputSchema: { type: 'object', properties: {} },
|
|
375
|
+
handler: async () => {
|
|
376
|
+
const res = await publishFetch(config, '/newsletter/subscribers/embed-info');
|
|
377
|
+
if (!res.ok) {
|
|
378
|
+
const err = await res.json().catch(() => ({}));
|
|
379
|
+
return { error: `Failed: ${err.error || res.statusText}` };
|
|
380
|
+
}
|
|
381
|
+
const data = await res.json();
|
|
382
|
+
return {
|
|
383
|
+
...data,
|
|
384
|
+
instructions: [
|
|
385
|
+
'HTML Form: Paste embed_html into any page. Replace YOUR_THANK_YOU_PAGE_URL. Works without JavaScript.',
|
|
386
|
+
'JS Fetch: Use embed_js for AJAX submissions (no page reload). Returns { success: true } on success.',
|
|
387
|
+
'hp_field is a honeypot — keep it hidden and empty.',
|
|
388
|
+
'Set source to identify each form placement (e.g. "homepage", "footer", "blog-sidebar").',
|
|
389
|
+
].join('\n'),
|
|
390
|
+
};
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
];
|
|
394
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openwriter/plugin-publish",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenWriter Publish — newsletter, custom domains, publishing",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"dev": "tsc --watch"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"markdown-it": "^14.1.0",
|
|
13
|
+
"markdown-it-ins": "^4.0.0",
|
|
14
|
+
"markdown-it-mark": "^4.0.0",
|
|
15
|
+
"markdown-it-sub": "^2.0.0",
|
|
16
|
+
"markdown-it-sup": "^2.0.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/express": "^5.0.0",
|
|
20
|
+
"@types/markdown-it": "^14.1.2",
|
|
21
|
+
"typescript": "^5.6.0"
|
|
22
|
+
},
|
|
23
|
+
"openwriter": {
|
|
24
|
+
"displayName": "Publish",
|
|
25
|
+
"category": "publishing"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist/",
|
|
29
|
+
"package.json"
|
|
30
|
+
]
|
|
31
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
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 type { Express } from 'express';
|
|
7
|
+
interface PluginConfigField {
|
|
8
|
+
type: 'string' | 'number' | 'boolean';
|
|
9
|
+
required?: boolean;
|
|
10
|
+
env?: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
}
|
|
13
|
+
interface PluginRouteContext {
|
|
14
|
+
app: Express;
|
|
15
|
+
config: Record<string, string>;
|
|
16
|
+
dataDir: string;
|
|
17
|
+
}
|
|
18
|
+
interface OpenWriterPlugin {
|
|
19
|
+
name: string;
|
|
20
|
+
version: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
category?: 'writing' | 'social-media' | 'image-generation';
|
|
23
|
+
configSchema?: Record<string, PluginConfigField>;
|
|
24
|
+
registerRoutes?(ctx: PluginRouteContext): void | Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
declare const plugin: OpenWriterPlugin;
|
|
27
|
+
export default plugin;
|