openwriter 0.39.0 → 0.40.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-DiJrXBgV.css +1 -0
- package/dist/client/assets/{index-DKnYQz7a.js → index-vmEPerKn.js} +53 -53
- package/dist/client/index.html +2 -2
- package/dist/plugins/x-api/dist/index.js +130 -2
- package/dist/plugins/x-api/dist/server-bridge.d.ts +22 -0
- package/dist/plugins/x-api/dist/server-bridge.js +43 -0
- package/dist/server/connection-routes.js +66 -0
- package/dist/server/tiptap-draftjs.js +296 -0
- package/package.json +1 -1
- package/dist/client/assets/index-D57aFMR_.css +0 -1
package/dist/client/index.html
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
<!-- Fonts are self-hosted (bundled via @fontsource, imported in main.tsx →
|
|
11
11
|
themes/vendored-fonts.css). No external font CDN: a local-first editor
|
|
12
12
|
must work offline and behind content blockers. -->
|
|
13
|
-
<script type="module" crossorigin src="/assets/index-
|
|
14
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
13
|
+
<script type="module" crossorigin src="/assets/index-vmEPerKn.js"></script>
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DiJrXBgV.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
17
17
|
<div id="root"></div>
|
|
@@ -8,23 +8,48 @@ import { join, extname } from 'path';
|
|
|
8
8
|
import { readFileSync, existsSync } from 'fs';
|
|
9
9
|
import sharp from 'sharp';
|
|
10
10
|
import twitter from 'twitter-text';
|
|
11
|
+
import { getServerBridge } from './server-bridge.js';
|
|
11
12
|
const { parseTweet } = twitter;
|
|
12
|
-
|
|
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) {
|
|
13
19
|
const apiKey = config['api-key'] || process.env.X_API_KEY || '';
|
|
14
20
|
const apiSecret = config['api-secret'] || process.env.X_API_SECRET || '';
|
|
15
21
|
const accessToken = config['access-token'] || process.env.X_ACCESS_TOKEN || '';
|
|
16
22
|
const accessTokenSecret = config['access-token-secret'] || process.env.X_ACCESS_TOKEN_SECRET || '';
|
|
17
23
|
if (!apiKey || !apiSecret || !accessToken || !accessTokenSecret)
|
|
18
24
|
return null;
|
|
19
|
-
|
|
25
|
+
return new OAuth1({
|
|
20
26
|
apiKey,
|
|
21
27
|
apiSecret,
|
|
22
28
|
callback: 'oob',
|
|
23
29
|
accessToken,
|
|
24
30
|
accessTokenSecret,
|
|
25
31
|
});
|
|
32
|
+
}
|
|
33
|
+
function createXClient(config) {
|
|
34
|
+
const oauth1 = createOAuth1(config);
|
|
35
|
+
if (!oauth1)
|
|
36
|
+
return null;
|
|
26
37
|
return new Client({ oauth1 });
|
|
27
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
|
+
}
|
|
28
53
|
const plugin = {
|
|
29
54
|
name: '@openwriter/plugin-x-api',
|
|
30
55
|
version: '0.1.0',
|
|
@@ -235,6 +260,109 @@ const plugin = {
|
|
|
235
260
|
res.status(500).json({ success: false, error: err.message });
|
|
236
261
|
}
|
|
237
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
|
+
});
|
|
238
334
|
},
|
|
239
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
|
+
}
|
|
240
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
|
+
}
|
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
* Local app no longer stores connections.
|
|
4
4
|
*/
|
|
5
5
|
import { Router } from 'express';
|
|
6
|
+
import { readFileSync, existsSync } from 'fs';
|
|
7
|
+
import { join, extname } from 'path';
|
|
6
8
|
import { platformFetch, isAuthenticated } from './connections.js';
|
|
9
|
+
import { getDocument, getTitle, getMetadata } from './state.js';
|
|
10
|
+
import { getDataDir } from './helpers.js';
|
|
11
|
+
import { tiptapToDraftjs } from './tiptap-draftjs.js';
|
|
7
12
|
export function createConnectionRouter() {
|
|
8
13
|
const router = Router();
|
|
9
14
|
// Unified connections list (OAuth + newsletter domains)
|
|
@@ -376,5 +381,66 @@ export function createConnectionRouter() {
|
|
|
376
381
|
res.status(500).json({ success: false, error: err.message });
|
|
377
382
|
}
|
|
378
383
|
});
|
|
384
|
+
// POST /api/x/post-article — publish the active doc as a native X Article.
|
|
385
|
+
// Platform connection first (managed path), then fall through to the x-api
|
|
386
|
+
// plugin (direct OAuth1). Mirrors the /api/x/post routing. The article body
|
|
387
|
+
// is read from the active server doc and converted to X content_state here;
|
|
388
|
+
// the cover image (articleContext.coverImage) is uploaded via the platform.
|
|
389
|
+
router.post('/api/x/post-article', async (req, res, next) => {
|
|
390
|
+
const conn = await getFirstXConnection();
|
|
391
|
+
if (!conn) {
|
|
392
|
+
next();
|
|
393
|
+
return;
|
|
394
|
+
} // No platform connection → direct plugin path
|
|
395
|
+
try {
|
|
396
|
+
const title = (getTitle() || '').trim();
|
|
397
|
+
if (!title || title === 'Untitled') {
|
|
398
|
+
res.status(400).json({ success: false, error: 'Article needs a title before posting.' });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const contentState = tiptapToDraftjs(getDocument());
|
|
402
|
+
if (!contentState.blocks.some((b) => (b.text || '').trim().length > 0)) {
|
|
403
|
+
res.status(400).json({ success: false, error: 'Article body is empty.' });
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
// Optional cover — upload through the platform, attach by media id.
|
|
407
|
+
let coverMediaId;
|
|
408
|
+
const coverSrc = getMetadata()?.articleContext?.coverImage;
|
|
409
|
+
if (coverSrc && typeof coverSrc === 'string' && /^\/_images\/[^/\\]+$/.test(coverSrc)) {
|
|
410
|
+
const filename = coverSrc.replace('/_images/', '');
|
|
411
|
+
const filePath = join(getDataDir(), '_images', filename);
|
|
412
|
+
if (existsSync(filePath)) {
|
|
413
|
+
const ext = extname(filename).toLowerCase();
|
|
414
|
+
const mimeMap = {
|
|
415
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
416
|
+
'.png': 'image/png', '.webp': 'image/webp',
|
|
417
|
+
'.gif': 'image/gif', '.bmp': 'image/bmp',
|
|
418
|
+
};
|
|
419
|
+
const uploadRes = await platformFetch(`/connections/${conn.id}/upload-media`, {
|
|
420
|
+
method: 'POST',
|
|
421
|
+
body: JSON.stringify({ media_base64: readFileSync(filePath).toString('base64'), media_type: mimeMap[ext] || 'image/jpeg' }),
|
|
422
|
+
});
|
|
423
|
+
if (uploadRes.ok) {
|
|
424
|
+
const data = await uploadRes.json();
|
|
425
|
+
if (data.mediaId)
|
|
426
|
+
coverMediaId = data.mediaId;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const upstream = await platformFetch(`/connections/${conn.id}/post-article`, {
|
|
431
|
+
method: 'POST',
|
|
432
|
+
body: JSON.stringify({ title, content_state: contentState, cover_media_id: coverMediaId }),
|
|
433
|
+
});
|
|
434
|
+
const data = await upstream.json();
|
|
435
|
+
if (!upstream.ok || !data.success) {
|
|
436
|
+
res.status(upstream.status || 500).json({ success: false, error: data.error || 'Article publish failed' });
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
res.json({ success: true, articleId: data.articleId, postId: data.postId, articleUrl: data.post_url });
|
|
440
|
+
}
|
|
441
|
+
catch (err) {
|
|
442
|
+
res.status(500).json({ success: false, error: err.message });
|
|
443
|
+
}
|
|
444
|
+
});
|
|
379
445
|
return router;
|
|
380
446
|
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TipTap / ProseMirror JSON -> X Articles DraftJS `content_state`.
|
|
3
|
+
*
|
|
4
|
+
* X's `POST /2/articles/draft` takes the article body as a DraftJS raw content
|
|
5
|
+
* state, in X's snake_case field naming:
|
|
6
|
+
*
|
|
7
|
+
* {
|
|
8
|
+
* blocks: [
|
|
9
|
+
* { key, text, type, depth?, inline_style_ranges?, entity_ranges? }
|
|
10
|
+
* ],
|
|
11
|
+
* entities: [ { key, value: { type, mutability, data } } ]
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* (Minimal valid body: `{"blocks":[{"text":"hi","type":"unstyled"}],"entities":[]}`.)
|
|
15
|
+
*
|
|
16
|
+
* This is the SINGLE shared converter imported by both posting paths — the
|
|
17
|
+
* direct plugin (plugins/x-api) and the managed plugin (plugins/publish) — so
|
|
18
|
+
* the OpenWriter-doc → X conversion lives in exactly one place. It is a pure
|
|
19
|
+
* function with no runtime deps, which keeps it trivially unit-testable and
|
|
20
|
+
* importable from either plugin's compiled context.
|
|
21
|
+
*
|
|
22
|
+
* v1 fidelity:
|
|
23
|
+
* - blocks: heading (h1–h6), paragraph, bullet/ordered/task lists (nested
|
|
24
|
+
* via `depth`), blockquote, code-block.
|
|
25
|
+
* - inline: bold, italic, underline, strikethrough, inline code, links.
|
|
26
|
+
* - tables: degrade to pipe-joined text rows (no DraftJS table primitive).
|
|
27
|
+
* - cover: handled out of band via `cover_media` on the draft call — NOT
|
|
28
|
+
* here. Body images degrade to their alt text (an inline-image
|
|
29
|
+
* atomic-block shape isn't confirmed against X's schema yet).
|
|
30
|
+
* - rules: horizontalRule degrades to a blank separator block.
|
|
31
|
+
* - marks X's DraftJS editor doesn't model (highlight, sub/superscript)
|
|
32
|
+
* drop to plain text — the text always survives.
|
|
33
|
+
*/
|
|
34
|
+
/** TipTap mark type -> X DraftJS inline style. X's article schema accepts only
|
|
35
|
+
* [bold, italic, strikethrough] (lowercase). Marks absent here (underline,
|
|
36
|
+
* inline code, highlight, sub/superscript) drop silently — the underlying text
|
|
37
|
+
* is unaffected. Verified against X's live validation. */
|
|
38
|
+
const MARK_STYLE = {
|
|
39
|
+
bold: 'bold',
|
|
40
|
+
italic: 'italic',
|
|
41
|
+
strike: 'strikethrough',
|
|
42
|
+
};
|
|
43
|
+
const HEADER_TYPE = ['', 'header-one', 'header-two', 'header-three', 'header-four', 'header-five', 'header-six'];
|
|
44
|
+
/** Accumulates blocks + entities and hands out unique keys. */
|
|
45
|
+
class BuildContext {
|
|
46
|
+
blocks = [];
|
|
47
|
+
entities = [];
|
|
48
|
+
blockN = 0;
|
|
49
|
+
entityN = 0;
|
|
50
|
+
nextBlockKey() {
|
|
51
|
+
return 'b' + (this.blockN++).toString(36);
|
|
52
|
+
}
|
|
53
|
+
addEntity(type, mutability, data) {
|
|
54
|
+
const n = this.entityN++;
|
|
55
|
+
// entities[].key is a string; the returned integer is what entity_ranges use.
|
|
56
|
+
this.entities.push({ key: String(n), value: { type, mutability, data } });
|
|
57
|
+
return n;
|
|
58
|
+
}
|
|
59
|
+
pushText(text, type, depth = 0) {
|
|
60
|
+
const block = { key: this.nextBlockKey(), text, type };
|
|
61
|
+
if (depth)
|
|
62
|
+
block.depth = depth;
|
|
63
|
+
this.blocks.push(block);
|
|
64
|
+
}
|
|
65
|
+
pushInline(inline, type, depth = 0) {
|
|
66
|
+
const block = { key: this.nextBlockKey(), text: inline.text, type };
|
|
67
|
+
if (depth)
|
|
68
|
+
block.depth = depth;
|
|
69
|
+
if (inline.styles.length)
|
|
70
|
+
block.inline_style_ranges = mergeStyleRanges(inline.styles);
|
|
71
|
+
if (inline.entityRanges.length)
|
|
72
|
+
block.entity_ranges = inline.entityRanges;
|
|
73
|
+
this.blocks.push(block);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Convert a TipTap document (`{ type: 'doc', content: [...] }`) to X's DraftJS
|
|
77
|
+
* content_state. Always returns at least one block — an empty doc yields a
|
|
78
|
+
* single empty unstyled block, since X rejects a draft with no blocks. */
|
|
79
|
+
export function tiptapToDraftjs(doc) {
|
|
80
|
+
const ctx = new BuildContext();
|
|
81
|
+
walkBlocks(doc?.content || [], 0, ctx);
|
|
82
|
+
if (ctx.blocks.length === 0)
|
|
83
|
+
ctx.pushText('', 'unstyled');
|
|
84
|
+
return { blocks: ctx.blocks, entities: ctx.entities };
|
|
85
|
+
}
|
|
86
|
+
function walkBlocks(nodes, depth, ctx) {
|
|
87
|
+
for (const node of nodes || []) {
|
|
88
|
+
switch (node?.type) {
|
|
89
|
+
case 'heading': {
|
|
90
|
+
const level = Math.min(Math.max(Number(node.attrs?.level) || 1, 1), 6);
|
|
91
|
+
ctx.pushInline(buildInline(node.content, ctx), HEADER_TYPE[level], depth);
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
case 'paragraph':
|
|
95
|
+
ctx.pushInline(buildInline(node.content, ctx), 'unstyled', depth);
|
|
96
|
+
break;
|
|
97
|
+
case 'bulletList':
|
|
98
|
+
walkList(node.content, 'unordered-list-item', depth, ctx);
|
|
99
|
+
break;
|
|
100
|
+
case 'orderedList':
|
|
101
|
+
walkList(node.content, 'ordered-list-item', depth, ctx);
|
|
102
|
+
break;
|
|
103
|
+
case 'taskList':
|
|
104
|
+
walkTaskList(node.content, depth, ctx);
|
|
105
|
+
break;
|
|
106
|
+
case 'blockquote':
|
|
107
|
+
walkBlockquote(node.content, depth, ctx);
|
|
108
|
+
break;
|
|
109
|
+
case 'codeBlock': {
|
|
110
|
+
// DraftJS models a code block as one code-block per line; this is the
|
|
111
|
+
// faithful raw shape and round-trips cleanly in X's editor.
|
|
112
|
+
const lines = plainText(node.content).split('\n');
|
|
113
|
+
for (const line of lines)
|
|
114
|
+
ctx.pushText(line, 'code-block', depth);
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
case 'horizontalRule':
|
|
118
|
+
// No confirmed DraftJS divider primitive in X's article schema — emit a
|
|
119
|
+
// blank separator block rather than risk a rejected payload.
|
|
120
|
+
ctx.pushText('', 'unstyled', depth);
|
|
121
|
+
break;
|
|
122
|
+
case 'image':
|
|
123
|
+
// Inline body images need an uploaded media_id + an atomic-block entity
|
|
124
|
+
// shape not yet confirmed against X's schema. Preserve the alt text so
|
|
125
|
+
// context isn't silently lost; the hero/cover image is handled by
|
|
126
|
+
// cover_media on the draft call, not here.
|
|
127
|
+
if (node.attrs?.alt)
|
|
128
|
+
ctx.pushText(String(node.attrs.alt), 'unstyled', depth);
|
|
129
|
+
break;
|
|
130
|
+
case 'table':
|
|
131
|
+
walkTable(node, depth, ctx);
|
|
132
|
+
break;
|
|
133
|
+
case 'footnoteSection':
|
|
134
|
+
walkFootnoteSection(node.content, depth, ctx);
|
|
135
|
+
break;
|
|
136
|
+
default:
|
|
137
|
+
// Unknown container — descend so nested content isn't dropped.
|
|
138
|
+
if (node?.content)
|
|
139
|
+
walkBlocks(node.content, depth, ctx);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Walk a bullet/ordered list. Each listItem's first paragraph becomes one
|
|
144
|
+
* list-item block; nested lists recurse at depth+1 (DraftJS list nesting). */
|
|
145
|
+
function walkList(items, blockType, depth, ctx) {
|
|
146
|
+
for (const item of items || []) {
|
|
147
|
+
for (const child of item.content || []) {
|
|
148
|
+
if (child.type === 'bulletList')
|
|
149
|
+
walkList(child.content, 'unordered-list-item', depth + 1, ctx);
|
|
150
|
+
else if (child.type === 'orderedList')
|
|
151
|
+
walkList(child.content, 'ordered-list-item', depth + 1, ctx);
|
|
152
|
+
else if (child.type === 'paragraph')
|
|
153
|
+
ctx.pushInline(buildInline(child.content, ctx), blockType, depth);
|
|
154
|
+
else
|
|
155
|
+
walkBlocks([child], depth, ctx);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** X's DraftJS has no task-list type; degrade each task item to a bullet with a
|
|
160
|
+
* leading checkbox glyph (☑ / ☐) so the checked state survives visually. */
|
|
161
|
+
function walkTaskList(items, depth, ctx) {
|
|
162
|
+
for (const item of items || []) {
|
|
163
|
+
const prefix = item.attrs?.checked ? '☑ ' : '☐ ';
|
|
164
|
+
let prefixed = false;
|
|
165
|
+
for (const child of item.content || []) {
|
|
166
|
+
if (child.type === 'taskList')
|
|
167
|
+
walkTaskList(child.content, depth + 1, ctx);
|
|
168
|
+
else if (child.type === 'paragraph') {
|
|
169
|
+
const inline = buildInline(child.content, ctx);
|
|
170
|
+
if (!prefixed) {
|
|
171
|
+
shiftRanges(inline, prefix.length);
|
|
172
|
+
inline.text = prefix + inline.text;
|
|
173
|
+
prefixed = true;
|
|
174
|
+
}
|
|
175
|
+
ctx.pushInline(inline, 'unordered-list-item', depth);
|
|
176
|
+
}
|
|
177
|
+
else
|
|
178
|
+
walkBlocks([child], depth, ctx);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** Each paragraph inside a blockquote becomes its own `blockquote` block. */
|
|
183
|
+
function walkBlockquote(nodes, depth, ctx) {
|
|
184
|
+
for (const child of nodes || []) {
|
|
185
|
+
if (child.type === 'paragraph')
|
|
186
|
+
ctx.pushInline(buildInline(child.content, ctx), 'blockquote', depth);
|
|
187
|
+
else
|
|
188
|
+
walkBlocks([child], depth, ctx);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/** Degrade a table to one unstyled text block per row, cells pipe-joined. No
|
|
192
|
+
* data is lost; the grid structure flattens to readable text. */
|
|
193
|
+
function walkTable(node, depth, ctx) {
|
|
194
|
+
for (const row of node.content || []) {
|
|
195
|
+
const cells = (row.content || []).map((cell) => (cell.content || []).map((p) => plainInline(p.content)).join(' ').trim());
|
|
196
|
+
ctx.pushText(cells.join(' | '), 'unstyled', depth);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** Footnote definitions render as plain blocks, first paragraph prefixed with
|
|
200
|
+
* its label, so the reference text isn't orphaned. */
|
|
201
|
+
function walkFootnoteSection(definitions, depth, ctx) {
|
|
202
|
+
for (const def of definitions || []) {
|
|
203
|
+
if (def.type !== 'footnoteDefinition')
|
|
204
|
+
continue;
|
|
205
|
+
const label = def.attrs?.label || '';
|
|
206
|
+
const paragraphs = (def.content || []).filter((c) => c.type === 'paragraph');
|
|
207
|
+
paragraphs.forEach((p, i) => {
|
|
208
|
+
const inline = buildInline(p.content, ctx);
|
|
209
|
+
if (i === 0) {
|
|
210
|
+
const prefix = `[${label}] `;
|
|
211
|
+
shiftRanges(inline, prefix.length);
|
|
212
|
+
inline.text = prefix + inline.text;
|
|
213
|
+
}
|
|
214
|
+
ctx.pushInline(inline, 'unstyled', depth);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/** Walk a node's inline content into { text, inline-style ranges, entity ranges },
|
|
219
|
+
* registering link entities into the build context as it goes. */
|
|
220
|
+
function buildInline(nodes, ctx) {
|
|
221
|
+
let text = '';
|
|
222
|
+
const styles = [];
|
|
223
|
+
const entityRanges = [];
|
|
224
|
+
for (const node of nodes || []) {
|
|
225
|
+
if (node.type === 'hardBreak') {
|
|
226
|
+
text += '\n';
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (node.type === 'footnoteReference') {
|
|
230
|
+
text += `[${node.attrs?.label || ''}]`;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (node.type !== 'text') {
|
|
234
|
+
if (node.text)
|
|
235
|
+
text += node.text;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const piece = node.text || '';
|
|
239
|
+
const offset = text.length;
|
|
240
|
+
text += piece;
|
|
241
|
+
const length = piece.length;
|
|
242
|
+
if (length === 0)
|
|
243
|
+
continue;
|
|
244
|
+
for (const mark of node.marks || []) {
|
|
245
|
+
const style = MARK_STYLE[mark.type];
|
|
246
|
+
if (style)
|
|
247
|
+
styles.push({ offset, length, style });
|
|
248
|
+
if (mark.type === 'link') {
|
|
249
|
+
const href = mark.attrs?.href || '';
|
|
250
|
+
if (href) {
|
|
251
|
+
const key = ctx.addEntity('link', 'mutable', { url: href });
|
|
252
|
+
entityRanges.push({ offset, length, key });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return { text, styles, entityRanges };
|
|
258
|
+
}
|
|
259
|
+
/** Shift every range offset right by `n` (used when a text prefix — checkbox
|
|
260
|
+
* glyph, footnote label — is prepended after inline ranges were computed). */
|
|
261
|
+
function shiftRanges(inline, n) {
|
|
262
|
+
for (const s of inline.styles)
|
|
263
|
+
s.offset += n;
|
|
264
|
+
for (const e of inline.entityRanges)
|
|
265
|
+
e.offset += n;
|
|
266
|
+
}
|
|
267
|
+
/** Merge adjacent ranges with the same style (`...a..**b**..` split across
|
|
268
|
+
* text nodes) into one, keeping the output minimal and stable. */
|
|
269
|
+
function mergeStyleRanges(ranges) {
|
|
270
|
+
const sorted = [...ranges].sort((a, b) => a.style.localeCompare(b.style) || a.offset - b.offset);
|
|
271
|
+
const out = [];
|
|
272
|
+
for (const r of sorted) {
|
|
273
|
+
const last = out[out.length - 1];
|
|
274
|
+
if (last && last.style === r.style && last.offset + last.length === r.offset) {
|
|
275
|
+
last.length += r.length;
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
out.push({ ...r });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
/** Plain text of a node's inline content, marks ignored. */
|
|
284
|
+
function plainInline(nodes) {
|
|
285
|
+
if (!nodes)
|
|
286
|
+
return '';
|
|
287
|
+
return nodes
|
|
288
|
+
.map((n) => (n.type === 'hardBreak' ? '\n' : n.text || ''))
|
|
289
|
+
.join('');
|
|
290
|
+
}
|
|
291
|
+
/** Plain text of block content (e.g. a code block's lines). */
|
|
292
|
+
function plainText(nodes) {
|
|
293
|
+
if (!nodes)
|
|
294
|
+
return '';
|
|
295
|
+
return nodes.map((n) => n.text || '').join('');
|
|
296
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
4
4
|
"description": "The open-source writing surface for AI agents. Markdown-native editor with pending change review — your agent writes, you accept or reject.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|