openwriter 0.38.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.
Files changed (48) hide show
  1. package/dist/client/assets/crimson-pro-latin-wght-italic-CkPs-Rv6.woff2 +0 -0
  2. package/dist/client/assets/crimson-pro-latin-wght-normal-C4R2PX-h.woff2 +0 -0
  3. package/dist/client/assets/dm-sans-latin-wght-italic-Cz4n9dED.woff2 +0 -0
  4. package/dist/client/assets/dm-sans-latin-wght-normal-Xz1IZZA0.woff2 +0 -0
  5. package/dist/client/assets/dm-serif-display-latin-400-italic-DpcbibHm.woff2 +0 -0
  6. package/dist/client/assets/dm-serif-display-latin-400-normal-C5_t9oOD.woff2 +0 -0
  7. package/dist/client/assets/ibm-plex-mono-latin-400-italic-BqAiT5Ww.woff2 +0 -0
  8. package/dist/client/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  9. package/dist/client/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  10. package/dist/client/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  11. package/dist/client/assets/ibm-plex-sans-latin-400-italic-CZTNEAuW.woff2 +0 -0
  12. package/dist/client/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  13. package/dist/client/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
  14. package/dist/client/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  15. package/dist/client/assets/index-DiJrXBgV.css +1 -0
  16. package/dist/client/assets/index-vmEPerKn.js +215 -0
  17. package/dist/client/assets/inter-latin-wght-italic-DpCbqKDY.woff2 +0 -0
  18. package/dist/client/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  19. package/dist/client/assets/libre-baskerville-latin-400-italic-Dx5Rrf3o.woff2 +0 -0
  20. package/dist/client/assets/libre-baskerville-latin-400-normal-C42RasBZ.woff2 +0 -0
  21. package/dist/client/assets/libre-baskerville-latin-700-normal-CLGq6Yj4.woff2 +0 -0
  22. package/dist/client/assets/literata-latin-wght-italic-Bm_GJfSc.woff2 +0 -0
  23. package/dist/client/assets/literata-latin-wght-normal-DLxlUchJ.woff2 +0 -0
  24. package/dist/client/assets/newsreader-latin-wght-italic-Bxi8ein9.woff2 +0 -0
  25. package/dist/client/assets/newsreader-latin-wght-normal-CCVVNp6i.woff2 +0 -0
  26. package/dist/client/assets/playfair-display-latin-wght-italic-DmbndNpe.woff2 +0 -0
  27. package/dist/client/assets/playfair-display-latin-wght-normal-BOwq7MWX.woff2 +0 -0
  28. package/dist/client/assets/source-serif-4-latin-wght-italic-D2yaqPoE.woff2 +0 -0
  29. package/dist/client/assets/source-serif-4-latin-wght-normal-D9elroTD.woff2 +0 -0
  30. package/dist/client/assets/space-grotesk-latin-wght-normal-BhU9QXUp.woff2 +0 -0
  31. package/dist/client/assets/space-mono-latin-400-italic-YylcN9Ay.woff2 +0 -0
  32. package/dist/client/assets/space-mono-latin-400-normal-Rg4St2Dn.woff2 +0 -0
  33. package/dist/client/assets/space-mono-latin-700-normal-mWgeinG7.woff2 +0 -0
  34. package/dist/client/index.html +5 -5
  35. package/dist/plugins/github/dist/blog-tools.js +8 -2
  36. package/dist/plugins/github/dist/git-sync.d.ts +11 -1
  37. package/dist/plugins/github/dist/git-sync.js +73 -14
  38. package/dist/plugins/github/dist/index.js +15 -5
  39. package/dist/plugins/x-api/dist/index.js +130 -2
  40. package/dist/plugins/x-api/dist/server-bridge.d.ts +22 -0
  41. package/dist/plugins/x-api/dist/server-bridge.js +43 -0
  42. package/dist/server/connection-routes.js +66 -0
  43. package/dist/server/tiptap-draftjs.js +296 -0
  44. package/dist/server/ws.js +8 -0
  45. package/package.json +14 -1
  46. package/skill/docs/footnotes.md +1 -1
  47. package/dist/client/assets/index-2miZWC8D.css +0 -1
  48. package/dist/client/assets/index-C_Zb7mUx.js +0 -215
@@ -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/dist/server/ws.js CHANGED
@@ -70,6 +70,14 @@ function buildDocumentSwitchedPayload(document, title, filename, metadata) {
70
70
  docId,
71
71
  metadata,
72
72
  pendingMetadata,
73
+ // Carry the server's current docVersion so the browser adopts it as its
74
+ // autosave baseline. A normal switch resets docVersion to 0 server-side
75
+ // (so this is 0 and behaves as before), but an auto-title rename arrives
76
+ // via this same path WITHOUT a reset — the server sits at N+1 after the
77
+ // bump. Without syncing the version the browser stays at 0 and every
78
+ // subsequent edit gets BLOCKED by isVersionCurrent, silently dropping
79
+ // text typed during the rename. Mirrors the document-reloaded path.
80
+ version: getDocVersion(),
73
81
  };
74
82
  }
75
83
  export function setupWebSocket(server) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.38.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",
@@ -38,6 +38,19 @@
38
38
  "lint": "eslint src server bin --ext .ts,.tsx"
39
39
  },
40
40
  "dependencies": {
41
+ "@fontsource-variable/crimson-pro": "^5.2.8",
42
+ "@fontsource-variable/dm-sans": "^5.2.8",
43
+ "@fontsource-variable/inter": "^5.2.8",
44
+ "@fontsource-variable/literata": "^5.2.8",
45
+ "@fontsource-variable/newsreader": "^5.2.10",
46
+ "@fontsource-variable/playfair-display": "^5.2.8",
47
+ "@fontsource-variable/source-serif-4": "^5.2.9",
48
+ "@fontsource-variable/space-grotesk": "^5.2.10",
49
+ "@fontsource/dm-serif-display": "^5.2.8",
50
+ "@fontsource/ibm-plex-mono": "^5.2.7",
51
+ "@fontsource/ibm-plex-sans": "^5.2.8",
52
+ "@fontsource/libre-baskerville": "^5.2.10",
53
+ "@fontsource/space-mono": "^5.2.9",
41
54
  "@google/genai": "^1.42.0",
42
55
  "@modelcontextprotocol/sdk": "^1.12.1",
43
56
  "@tiptap/core": "^3.0.0",
@@ -17,7 +17,7 @@ Two parts:
17
17
  Labels can be numeric or mnemonic:
18
18
 
19
19
  ```markdown
20
- Humans run fixed action patterns[^1] too.
20
+ The body repairs itself during deep sleep[^1] too.
21
21
 
22
22
  Per Sapolsky[^sapolsky2017], stress responses follow a pattern.
23
23