@andypai/agent-kanban 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andypai/agent-kanban",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Agent-friendly kanban board CLI. Manage tasks via bash commands, parse structured JSON output.",
|
|
5
5
|
"homepage": "https://github.com/abpai/agent-kanban#readme",
|
|
6
6
|
"repository": {
|
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
adfToPlainText,
|
|
4
|
+
plainTextToAdf,
|
|
5
|
+
type AdfBulletListNode,
|
|
6
|
+
type AdfCodeBlockNode,
|
|
7
|
+
type AdfDocument,
|
|
8
|
+
type AdfInlineNode,
|
|
9
|
+
type AdfParagraphNode,
|
|
10
|
+
} from '../providers/jira-adf'
|
|
11
|
+
|
|
12
|
+
function firstParagraphContent(doc: AdfDocument): AdfInlineNode[] {
|
|
13
|
+
const paragraph = doc.content[0] as AdfParagraphNode
|
|
14
|
+
expect(paragraph.type).toBe('paragraph')
|
|
15
|
+
return paragraph.content ?? []
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function firstCodeBlock(doc: AdfDocument): AdfCodeBlockNode {
|
|
19
|
+
const code = doc.content[0] as AdfCodeBlockNode
|
|
20
|
+
expect(code.type).toBe('codeBlock')
|
|
21
|
+
return code
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function firstBulletList(doc: AdfDocument): AdfBulletListNode {
|
|
25
|
+
const list = doc.content[0] as AdfBulletListNode
|
|
26
|
+
expect(list.type).toBe('bulletList')
|
|
27
|
+
return list
|
|
28
|
+
}
|
|
3
29
|
|
|
4
30
|
describe('plainTextToAdf / adfToPlainText', () => {
|
|
5
31
|
test('empty doc round-trip', () => {
|
|
@@ -154,7 +180,7 @@ describe('plainTextToAdf / adfToPlainText', () => {
|
|
|
154
180
|
expect(adfToPlainText(doc)).toBe('before\n\nafter')
|
|
155
181
|
})
|
|
156
182
|
|
|
157
|
-
test('
|
|
183
|
+
test('strong + link marks survive on read as markdown', () => {
|
|
158
184
|
const doc: AdfDocument = {
|
|
159
185
|
version: 1,
|
|
160
186
|
type: 'doc',
|
|
@@ -163,12 +189,240 @@ describe('plainTextToAdf / adfToPlainText', () => {
|
|
|
163
189
|
type: 'paragraph',
|
|
164
190
|
content: [
|
|
165
191
|
{ type: 'text', text: 'bold', marks: [{ type: 'strong' }] },
|
|
166
|
-
{ type: 'text', text: '
|
|
192
|
+
{ type: 'text', text: ' ' },
|
|
193
|
+
{
|
|
194
|
+
type: 'text',
|
|
195
|
+
text: 'click',
|
|
196
|
+
marks: [{ type: 'link', attrs: { href: 'https://example.com' } }],
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
}
|
|
202
|
+
expect(adfToPlainText(doc)).toBe('**bold** [click](https://example.com)')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test('write path emits **bold** as a strong mark', () => {
|
|
206
|
+
const input = 'hello **world**'
|
|
207
|
+
const doc = plainTextToAdf(input)
|
|
208
|
+
expect(firstParagraphContent(doc)).toEqual([
|
|
209
|
+
{ type: 'text', text: 'hello ' },
|
|
210
|
+
{ type: 'text', text: 'world', marks: [{ type: 'strong' }] },
|
|
211
|
+
])
|
|
212
|
+
expect(adfToPlainText(doc)).toBe(input)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('write path emits [label](https://...) as a link mark', () => {
|
|
216
|
+
const input = 'see [docs](https://example.com/x)'
|
|
217
|
+
const doc = plainTextToAdf(input)
|
|
218
|
+
expect(firstParagraphContent(doc)).toEqual([
|
|
219
|
+
{ type: 'text', text: 'see ' },
|
|
220
|
+
{
|
|
221
|
+
type: 'text',
|
|
222
|
+
text: 'docs',
|
|
223
|
+
marks: [{ type: 'link', attrs: { href: 'https://example.com/x' } }],
|
|
224
|
+
},
|
|
225
|
+
])
|
|
226
|
+
expect(adfToPlainText(doc)).toBe(input)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test('write path emits side-by-side bold and link as adjacent text nodes', () => {
|
|
230
|
+
const input = '**PR opened** — [repo#1](https://github.com/o/r/pull/1)'
|
|
231
|
+
const doc = plainTextToAdf(input)
|
|
232
|
+
expect(firstParagraphContent(doc)).toEqual([
|
|
233
|
+
{ type: 'text', text: 'PR opened', marks: [{ type: 'strong' }] },
|
|
234
|
+
{ type: 'text', text: ' — ' },
|
|
235
|
+
{
|
|
236
|
+
type: 'text',
|
|
237
|
+
text: 'repo#1',
|
|
238
|
+
marks: [{ type: 'link', attrs: { href: 'https://github.com/o/r/pull/1' } }],
|
|
239
|
+
},
|
|
240
|
+
])
|
|
241
|
+
expect(adfToPlainText(doc)).toBe(input)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test('write path bolds bullet item field-name prefix', () => {
|
|
245
|
+
const input = '- **Marker:** drift:HUMAN REVIEW:516652'
|
|
246
|
+
const doc = plainTextToAdf(input)
|
|
247
|
+
const list = firstBulletList(doc)
|
|
248
|
+
const itemParagraph = list.content[0]!.content[0] as AdfParagraphNode
|
|
249
|
+
const itemContent = itemParagraph.content ?? []
|
|
250
|
+
expect(itemContent).toEqual([
|
|
251
|
+
{ type: 'text', text: 'Marker:', marks: [{ type: 'strong' }] },
|
|
252
|
+
{ type: 'text', text: ' drift:HUMAN REVIEW:516652' },
|
|
253
|
+
])
|
|
254
|
+
expect(adfToPlainText(doc)).toBe(input)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
test('inline tokenizer does NOT run inside fenced code blocks', () => {
|
|
258
|
+
const input = '```ts\nconst s = "**not bold** [x](https://e.com)"\n```'
|
|
259
|
+
const doc = plainTextToAdf(input)
|
|
260
|
+
const code = firstCodeBlock(doc)
|
|
261
|
+
const codeContent = code.content ?? []
|
|
262
|
+
expect(codeContent).toHaveLength(1)
|
|
263
|
+
expect(codeContent[0]?.text).toBe('const s = "**not bold** [x](https://e.com)"')
|
|
264
|
+
expect(codeContent[0]?.marks).toBeUndefined()
|
|
265
|
+
expect(adfToPlainText(doc)).toBe(input)
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
test('read: code block text marks stay literal', () => {
|
|
269
|
+
const doc: AdfDocument = {
|
|
270
|
+
version: 1,
|
|
271
|
+
type: 'doc',
|
|
272
|
+
content: [
|
|
273
|
+
{
|
|
274
|
+
type: 'codeBlock',
|
|
275
|
+
content: [
|
|
276
|
+
{ type: 'text', text: '**not bold**', marks: [{ type: 'strong' }] },
|
|
277
|
+
{
|
|
278
|
+
type: 'text',
|
|
279
|
+
text: '\nhttps://example.com',
|
|
280
|
+
marks: [{ type: 'link', attrs: { href: 'https://example.com' } }],
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
}
|
|
286
|
+
expect(adfToPlainText(doc)).toBe('```\n**not bold**\nhttps://example.com\n```')
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test('non-http link target is left as literal text (no link mark)', () => {
|
|
290
|
+
const input = 'see [docs](mailto:dev@example.com)'
|
|
291
|
+
const doc = plainTextToAdf(input)
|
|
292
|
+
expect(firstParagraphContent(doc)).toEqual([{ type: 'text', text: input }])
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
test('read: paragraph containing inlineCard emits the URL inline', () => {
|
|
296
|
+
const doc: AdfDocument = {
|
|
297
|
+
version: 1,
|
|
298
|
+
type: 'doc',
|
|
299
|
+
content: [
|
|
300
|
+
{
|
|
301
|
+
type: 'paragraph',
|
|
302
|
+
content: [
|
|
303
|
+
{ type: 'text', text: 'Repo: ' },
|
|
304
|
+
{ type: 'inlineCard', attrs: { url: 'https://github.com/abpai/garage-band' } },
|
|
305
|
+
],
|
|
306
|
+
},
|
|
307
|
+
],
|
|
308
|
+
}
|
|
309
|
+
expect(adfToPlainText(doc)).toBe('Repo: https://github.com/abpai/garage-band')
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
test('read: split strong field label keeps colon inside markdown', () => {
|
|
313
|
+
const doc: AdfDocument = {
|
|
314
|
+
version: 1,
|
|
315
|
+
type: 'doc',
|
|
316
|
+
content: [
|
|
317
|
+
{
|
|
318
|
+
type: 'paragraph',
|
|
319
|
+
content: [
|
|
320
|
+
{ type: 'text', text: 'Repo', marks: [{ type: 'strong' }] },
|
|
321
|
+
{ type: 'text', text: ': ' },
|
|
322
|
+
{ type: 'inlineCard', attrs: { url: 'https://github.com/abpai/garage-band' } },
|
|
323
|
+
],
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
}
|
|
327
|
+
expect(adfToPlainText(doc)).toBe('**Repo:** https://github.com/abpai/garage-band')
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
test('read: blockCard renders as a standalone block with the URL', () => {
|
|
331
|
+
const doc: AdfDocument = {
|
|
332
|
+
version: 1,
|
|
333
|
+
type: 'doc',
|
|
334
|
+
content: [
|
|
335
|
+
{ type: 'paragraph', content: [{ type: 'text', text: 'before' }] },
|
|
336
|
+
{ type: 'blockCard', attrs: { url: 'https://github.com/abpai/garage-band' } },
|
|
337
|
+
{ type: 'paragraph', content: [{ type: 'text', text: 'after' }] },
|
|
338
|
+
],
|
|
339
|
+
}
|
|
340
|
+
expect(adfToPlainText(doc)).toBe('before\n\nhttps://github.com/abpai/garage-band\n\nafter')
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
test('read: embedCard also emits its URL', () => {
|
|
344
|
+
const doc: AdfDocument = {
|
|
345
|
+
version: 1,
|
|
346
|
+
type: 'doc',
|
|
347
|
+
content: [{ type: 'embedCard', attrs: { url: 'https://example.com/embed' } }],
|
|
348
|
+
}
|
|
349
|
+
expect(adfToPlainText(doc)).toBe('https://example.com/embed')
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
test('read: DXS-12-shaped description preserves smart-link Repo URL', () => {
|
|
353
|
+
// Captured shape from a real Jira description where a user pasted a URL
|
|
354
|
+
// and Jira auto-converted it to an inlineCard. Before the fix,
|
|
355
|
+
// adfToPlainText returned "Repo: " with the URL silently dropped.
|
|
356
|
+
const doc: AdfDocument = {
|
|
357
|
+
version: 1,
|
|
358
|
+
type: 'doc',
|
|
359
|
+
content: [
|
|
360
|
+
{
|
|
361
|
+
type: 'paragraph',
|
|
362
|
+
content: [
|
|
363
|
+
{ type: 'text', text: 'Repo', marks: [{ type: 'strong' }] },
|
|
364
|
+
{ type: 'text', text: ': ' },
|
|
365
|
+
{ type: 'inlineCard', attrs: { url: 'https://github.com/abpai/garage-band' } },
|
|
366
|
+
],
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
type: 'paragraph',
|
|
370
|
+
content: [{ type: 'text', text: 'Please make one minimal change.' }],
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
type: 'heading',
|
|
374
|
+
attrs: { level: 3 },
|
|
375
|
+
content: [{ type: 'text', text: 'Acceptance criteria:' }],
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
type: 'bulletList',
|
|
379
|
+
content: [
|
|
380
|
+
{
|
|
381
|
+
type: 'listItem',
|
|
382
|
+
content: [
|
|
383
|
+
{
|
|
384
|
+
type: 'paragraph',
|
|
385
|
+
content: [{ type: 'text', text: 'Change only SMOKE_TEST_TASK.md.' }],
|
|
386
|
+
},
|
|
387
|
+
],
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
type: 'listItem',
|
|
391
|
+
content: [
|
|
392
|
+
{
|
|
393
|
+
type: 'paragraph',
|
|
394
|
+
content: [{ type: 'text', text: 'Increment current_count by exactly one.' }],
|
|
395
|
+
},
|
|
396
|
+
],
|
|
397
|
+
},
|
|
398
|
+
],
|
|
399
|
+
},
|
|
400
|
+
],
|
|
401
|
+
}
|
|
402
|
+
const out = adfToPlainText(doc)
|
|
403
|
+
expect(out).toContain('Repo:')
|
|
404
|
+
expect(out).toContain('https://github.com/abpai/garage-band')
|
|
405
|
+
// Repo line carries the URL — agents grepping for the URL recover it.
|
|
406
|
+
const repoLine = out.split('\n').find((l) => l.includes('Repo:'))
|
|
407
|
+
expect(repoLine).toBe('**Repo:** https://github.com/abpai/garage-band')
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
test('read: hardBreak between text runs emits newline', () => {
|
|
411
|
+
const doc: AdfDocument = {
|
|
412
|
+
version: 1,
|
|
413
|
+
type: 'doc',
|
|
414
|
+
content: [
|
|
415
|
+
{
|
|
416
|
+
type: 'paragraph',
|
|
417
|
+
content: [
|
|
418
|
+
{ type: 'text', text: 'first line' },
|
|
419
|
+
{ type: 'hardBreak' },
|
|
420
|
+
{ type: 'text', text: 'second line' },
|
|
167
421
|
],
|
|
168
422
|
},
|
|
169
423
|
],
|
|
170
424
|
}
|
|
171
|
-
expect(adfToPlainText(doc)).toBe('
|
|
425
|
+
expect(adfToPlainText(doc)).toBe('first line\nsecond line')
|
|
172
426
|
})
|
|
173
427
|
|
|
174
428
|
test('bullet item containing digits-dot substring is still a bullet', () => {
|
|
@@ -67,13 +67,14 @@ function makeIssue(opts: {
|
|
|
67
67
|
updated?: string
|
|
68
68
|
summary?: string
|
|
69
69
|
assignee?: { accountId: string; displayName: string } | null
|
|
70
|
+
description?: unknown
|
|
70
71
|
}): Record<string, unknown> {
|
|
71
72
|
return {
|
|
72
73
|
id: opts.id,
|
|
73
74
|
key: opts.key,
|
|
74
75
|
fields: {
|
|
75
76
|
summary: opts.summary ?? opts.key,
|
|
76
|
-
description: null,
|
|
77
|
+
description: opts.description ?? null,
|
|
77
78
|
status: { id: opts.statusId, name: 'Status ' + opts.statusId },
|
|
78
79
|
issuetype: { id: '10000', name: 'Bug' },
|
|
79
80
|
priority: { id: '2', name: 'High' },
|
|
@@ -429,6 +430,51 @@ describe('JiraProvider read path', () => {
|
|
|
429
430
|
expect(byPrefixed.externalRef).toBe('ENG-1')
|
|
430
431
|
})
|
|
431
432
|
|
|
433
|
+
test('description ADF with an inlineCard smart-link preserves the URL through getTask', async () => {
|
|
434
|
+
// Regression for the DXS-12 shape: a user pastes
|
|
435
|
+
// `Repo: https://github.com/abpai/garage-band` in the description, Jira
|
|
436
|
+
// auto-converts the URL to an inlineCard, and the previous
|
|
437
|
+
// adfToPlainText silently dropped attrs.url.
|
|
438
|
+
const description = {
|
|
439
|
+
version: 1,
|
|
440
|
+
type: 'doc',
|
|
441
|
+
content: [
|
|
442
|
+
{
|
|
443
|
+
type: 'paragraph',
|
|
444
|
+
content: [
|
|
445
|
+
{ type: 'text', text: 'Repo', marks: [{ type: 'strong' }] },
|
|
446
|
+
{ type: 'text', text: ': ' },
|
|
447
|
+
{ type: 'inlineCard', attrs: { url: 'https://github.com/abpai/garage-band' } },
|
|
448
|
+
],
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
type: 'paragraph',
|
|
452
|
+
content: [{ type: 'text', text: 'Increment SMOKE_TEST_TASK.md by 1.' }],
|
|
453
|
+
},
|
|
454
|
+
],
|
|
455
|
+
}
|
|
456
|
+
const searchHandler: StubHandler = () =>
|
|
457
|
+
jsonResponse({
|
|
458
|
+
startAt: 0,
|
|
459
|
+
maxResults: 100,
|
|
460
|
+
total: 1,
|
|
461
|
+
issues: [
|
|
462
|
+
makeIssue({
|
|
463
|
+
id: '20001',
|
|
464
|
+
key: 'ENG-12',
|
|
465
|
+
statusId: '10001',
|
|
466
|
+
description,
|
|
467
|
+
}),
|
|
468
|
+
],
|
|
469
|
+
})
|
|
470
|
+
const { provider } = makeProvider(standardRoutes({ searchHandler }))
|
|
471
|
+
await provider.getBoard()
|
|
472
|
+
const task = await provider.getTask('ENG-12')
|
|
473
|
+
expect(task.description).toContain('Repo:')
|
|
474
|
+
expect(task.description).toContain('https://github.com/abpai/garage-band')
|
|
475
|
+
expect(task.description.split('\n')[0]).toBe('**Repo:** https://github.com/abpai/garage-band')
|
|
476
|
+
})
|
|
477
|
+
|
|
432
478
|
test('getBootstrap returns provider=jira and the adapted BoardConfig', async () => {
|
|
433
479
|
const { provider } = makeProvider(standardRoutes({}))
|
|
434
480
|
const result = await provider.getBootstrap()
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
//
|
|
3
3
|
// This module is intentionally dependency-free: no imports from the rest of the
|
|
4
4
|
// provider stack, no bun:sqlite, no fetch. It models only the subset of ADF
|
|
5
|
-
// that agent-kanban
|
|
5
|
+
// that agent-kanban writes, plus a few Jira nodes it must preserve on read:
|
|
6
6
|
//
|
|
7
|
-
// doc > { paragraph | bulletList | orderedList | codeBlock | heading }
|
|
8
|
-
// paragraph / heading / codeBlock > text(inline)
|
|
7
|
+
// doc > { paragraph | bulletList | orderedList | codeBlock | heading | card }
|
|
8
|
+
// paragraph / heading / codeBlock > text(inline) | inlineCard | hardBreak
|
|
9
9
|
// bulletList / orderedList > listItem > paragraph > text
|
|
10
10
|
//
|
|
11
|
-
//
|
|
12
|
-
// never emitted on the write path.
|
|
11
|
+
// Other unknown node types are tolerated on the read path (skipped silently)
|
|
12
|
+
// and never emitted on the write path.
|
|
13
13
|
|
|
14
14
|
export interface AdfDocument {
|
|
15
15
|
version: 1
|
|
@@ -17,10 +17,15 @@ export interface AdfDocument {
|
|
|
17
17
|
content: AdfBlockNode[]
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
export interface AdfMark {
|
|
21
|
+
type: string
|
|
22
|
+
attrs?: Record<string, unknown>
|
|
23
|
+
}
|
|
24
|
+
|
|
20
25
|
export interface AdfTextNode {
|
|
21
26
|
type: 'text'
|
|
22
27
|
text: string
|
|
23
|
-
marks?:
|
|
28
|
+
marks?: AdfMark[]
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
export interface AdfUnknownInlineNode {
|
|
@@ -92,7 +97,7 @@ function paragraphFromText(text: string): AdfParagraphNode {
|
|
|
92
97
|
}
|
|
93
98
|
return {
|
|
94
99
|
type: 'paragraph',
|
|
95
|
-
content:
|
|
100
|
+
content: tokenizeInline(text),
|
|
96
101
|
}
|
|
97
102
|
}
|
|
98
103
|
|
|
@@ -103,6 +108,39 @@ function listItemFromText(text: string): AdfListItemNode {
|
|
|
103
108
|
}
|
|
104
109
|
}
|
|
105
110
|
|
|
111
|
+
const INLINE_MARK = /\*\*([^*\n]+)\*\*|\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)/g
|
|
112
|
+
|
|
113
|
+
function tokenizeInline(text: string): AdfTextNode[] {
|
|
114
|
+
const out: AdfTextNode[] = []
|
|
115
|
+
INLINE_MARK.lastIndex = 0
|
|
116
|
+
let cursor = 0
|
|
117
|
+
for (const match of text.matchAll(INLINE_MARK)) {
|
|
118
|
+
const start = match.index ?? 0
|
|
119
|
+
if (start > cursor) {
|
|
120
|
+
out.push({ type: 'text', text: text.slice(cursor, start) })
|
|
121
|
+
}
|
|
122
|
+
const boldText = match[1]
|
|
123
|
+
if (boldText !== undefined) {
|
|
124
|
+
out.push({
|
|
125
|
+
type: 'text',
|
|
126
|
+
text: boldText,
|
|
127
|
+
marks: [{ type: 'strong' }],
|
|
128
|
+
})
|
|
129
|
+
} else {
|
|
130
|
+
out.push({
|
|
131
|
+
type: 'text',
|
|
132
|
+
text: match[2]!,
|
|
133
|
+
marks: [{ type: 'link', attrs: { href: match[3]! } }],
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
cursor = start + match[0].length
|
|
137
|
+
}
|
|
138
|
+
if (cursor < text.length) {
|
|
139
|
+
out.push({ type: 'text', text: text.slice(cursor) })
|
|
140
|
+
}
|
|
141
|
+
return out
|
|
142
|
+
}
|
|
143
|
+
|
|
106
144
|
export function plainTextToAdf(text: string): AdfDocument {
|
|
107
145
|
if (text.length === 0) {
|
|
108
146
|
return { version: 1, type: 'doc', content: [] }
|
|
@@ -211,18 +249,79 @@ export function plainTextToAdf(text: string): AdfDocument {
|
|
|
211
249
|
return { version: 1, type: 'doc', content: blocks }
|
|
212
250
|
}
|
|
213
251
|
|
|
214
|
-
function inlineText(
|
|
252
|
+
function inlineText(
|
|
253
|
+
nodes: AdfInlineNode[] | undefined,
|
|
254
|
+
opts: { renderMarks?: boolean } = {},
|
|
255
|
+
): string {
|
|
215
256
|
if (!nodes) return ''
|
|
257
|
+
const renderMarks = opts.renderMarks ?? true
|
|
216
258
|
let out = ''
|
|
217
|
-
for (
|
|
259
|
+
for (let i = 0; i < nodes.length; i += 1) {
|
|
260
|
+
const node = nodes[i]!
|
|
218
261
|
if (node.type === 'text') {
|
|
219
|
-
|
|
262
|
+
const textNode = node as AdfTextNode
|
|
263
|
+
if (!renderMarks) {
|
|
264
|
+
out += textNode.text
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
const nextNode = nodes[i + 1]
|
|
268
|
+
const labelColon = readPlainTextLeadingColon(nextNode)
|
|
269
|
+
if (labelColon && hasMark(textNode, 'strong')) {
|
|
270
|
+
out += renderTextNode({ ...textNode, text: `${textNode.text}:` })
|
|
271
|
+
out += labelColon.remainder
|
|
272
|
+
i += 1
|
|
273
|
+
continue
|
|
274
|
+
}
|
|
275
|
+
out += renderTextNode(textNode)
|
|
276
|
+
continue
|
|
220
277
|
}
|
|
221
|
-
|
|
278
|
+
if (node.type === 'inlineCard') {
|
|
279
|
+
const url = readCardUrl(node)
|
|
280
|
+
if (url) out += url
|
|
281
|
+
continue
|
|
282
|
+
}
|
|
283
|
+
if (node.type === 'hardBreak') {
|
|
284
|
+
out += '\n'
|
|
285
|
+
continue
|
|
286
|
+
}
|
|
287
|
+
// Other unknown inline nodes (mentions, emoji, etc.) are skipped.
|
|
288
|
+
}
|
|
289
|
+
return out
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function hasMark(node: AdfTextNode, type: string): boolean {
|
|
293
|
+
return node.marks?.some((m) => m.type === type) ?? false
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function readPlainTextLeadingColon(node: AdfInlineNode | undefined): { remainder: string } | null {
|
|
297
|
+
if (!node || node.type !== 'text') return null
|
|
298
|
+
const textNode = node as AdfTextNode
|
|
299
|
+
if (textNode.marks && textNode.marks.length > 0) return null
|
|
300
|
+
if (!textNode.text.startsWith(':')) return null
|
|
301
|
+
return { remainder: textNode.text.slice(1) }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function renderTextNode(node: AdfTextNode): string {
|
|
305
|
+
if (!node.marks || node.marks.length === 0) return node.text
|
|
306
|
+
const link = node.marks.find((m) => m.type === 'link')
|
|
307
|
+
let out = node.text
|
|
308
|
+
if (link) {
|
|
309
|
+
const href = link.attrs?.['href']
|
|
310
|
+
if (typeof href === 'string' && href.length > 0) out = `[${out}](${href})`
|
|
311
|
+
}
|
|
312
|
+
if (hasMark(node, 'strong')) {
|
|
313
|
+
out = `**${out}**`
|
|
222
314
|
}
|
|
223
315
|
return out
|
|
224
316
|
}
|
|
225
317
|
|
|
318
|
+
function readCardUrl(node: AdfInlineNode | AdfBlockNode): string | null {
|
|
319
|
+
const attrs = (node as { attrs?: Record<string, unknown> }).attrs
|
|
320
|
+
if (!attrs) return null
|
|
321
|
+
const url = attrs['url']
|
|
322
|
+
return typeof url === 'string' && url.length > 0 ? url : null
|
|
323
|
+
}
|
|
324
|
+
|
|
226
325
|
function listItemInnerText(item: AdfListItemNode): string {
|
|
227
326
|
// Each list item wraps a paragraph (or nested blocks). We flatten to the
|
|
228
327
|
// first paragraph's inline text, which is all the write path produces.
|
|
@@ -252,12 +351,17 @@ function renderBlock(node: AdfBlockNode): string | null {
|
|
|
252
351
|
case 'codeBlock': {
|
|
253
352
|
const code = node as AdfCodeBlockNode
|
|
254
353
|
const language = code.attrs?.language ?? ''
|
|
255
|
-
const body = inlineText(code.content)
|
|
354
|
+
const body = inlineText(code.content, { renderMarks: false })
|
|
256
355
|
const fence = language.length > 0 ? `\`\`\`${language}` : '```'
|
|
257
356
|
return `${fence}\n${body}\n\`\`\``
|
|
258
357
|
}
|
|
259
358
|
case 'heading':
|
|
260
359
|
return inlineText((node as AdfHeadingNode).content)
|
|
360
|
+
case 'blockCard':
|
|
361
|
+
case 'embedCard': {
|
|
362
|
+
const url = readCardUrl(node)
|
|
363
|
+
return url ?? null
|
|
364
|
+
}
|
|
261
365
|
default:
|
|
262
366
|
// Unknown block node — skip entirely, never throw.
|
|
263
367
|
return null
|