@knpkv/atlassian-common 0.2.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 (121) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/dist/Brand.d.ts +118 -0
  5. package/dist/Brand.d.ts.map +1 -0
  6. package/dist/Brand.js +107 -0
  7. package/dist/Brand.js.map +1 -0
  8. package/dist/Hash.d.ts +53 -0
  9. package/dist/Hash.d.ts.map +1 -0
  10. package/dist/Hash.js +63 -0
  11. package/dist/Hash.js.map +1 -0
  12. package/dist/SerializeError.d.ts +48 -0
  13. package/dist/SerializeError.d.ts.map +1 -0
  14. package/dist/SerializeError.js +42 -0
  15. package/dist/SerializeError.js.map +1 -0
  16. package/dist/ast/BlockNode.d.ts +429 -0
  17. package/dist/ast/BlockNode.d.ts.map +1 -0
  18. package/dist/ast/BlockNode.js +279 -0
  19. package/dist/ast/BlockNode.js.map +1 -0
  20. package/dist/ast/Document.d.ts +255 -0
  21. package/dist/ast/Document.d.ts.map +1 -0
  22. package/dist/ast/Document.js +80 -0
  23. package/dist/ast/Document.js.map +1 -0
  24. package/dist/ast/InlineNode.d.ts +483 -0
  25. package/dist/ast/InlineNode.d.ts.map +1 -0
  26. package/dist/ast/InlineNode.js +268 -0
  27. package/dist/ast/InlineNode.js.map +1 -0
  28. package/dist/ast/MacroNode.d.ts +214 -0
  29. package/dist/ast/MacroNode.d.ts.map +1 -0
  30. package/dist/ast/MacroNode.js +110 -0
  31. package/dist/ast/MacroNode.js.map +1 -0
  32. package/dist/ast/index.d.ts +10 -0
  33. package/dist/ast/index.d.ts.map +1 -0
  34. package/dist/ast/index.js +14 -0
  35. package/dist/ast/index.js.map +1 -0
  36. package/dist/auth/OAuthEndpoints.d.ts +95 -0
  37. package/dist/auth/OAuthEndpoints.d.ts.map +1 -0
  38. package/dist/auth/OAuthEndpoints.js +112 -0
  39. package/dist/auth/OAuthEndpoints.js.map +1 -0
  40. package/dist/auth/OAuthErrors.d.ts +76 -0
  41. package/dist/auth/OAuthErrors.d.ts.map +1 -0
  42. package/dist/auth/OAuthErrors.js +85 -0
  43. package/dist/auth/OAuthErrors.js.map +1 -0
  44. package/dist/auth/OAuthOperations.d.ts +87 -0
  45. package/dist/auth/OAuthOperations.d.ts.map +1 -0
  46. package/dist/auth/OAuthOperations.js +169 -0
  47. package/dist/auth/OAuthOperations.js.map +1 -0
  48. package/dist/auth/OAuthResponseSchemas.d.ts +65 -0
  49. package/dist/auth/OAuthResponseSchemas.d.ts.map +1 -0
  50. package/dist/auth/OAuthResponseSchemas.js +47 -0
  51. package/dist/auth/OAuthResponseSchemas.js.map +1 -0
  52. package/dist/auth/index.d.ts +11 -0
  53. package/dist/auth/index.d.ts.map +1 -0
  54. package/dist/auth/index.js +16 -0
  55. package/dist/auth/index.js.map +1 -0
  56. package/dist/auth/uuid.d.ts +14 -0
  57. package/dist/auth/uuid.d.ts.map +1 -0
  58. package/dist/auth/uuid.js +14 -0
  59. package/dist/auth/uuid.js.map +1 -0
  60. package/dist/config/ConfigPaths.d.ts +95 -0
  61. package/dist/config/ConfigPaths.d.ts.map +1 -0
  62. package/dist/config/ConfigPaths.js +102 -0
  63. package/dist/config/ConfigPaths.js.map +1 -0
  64. package/dist/config/OAuthSchemas.d.ts +144 -0
  65. package/dist/config/OAuthSchemas.d.ts.map +1 -0
  66. package/dist/config/OAuthSchemas.js +107 -0
  67. package/dist/config/OAuthSchemas.js.map +1 -0
  68. package/dist/config/TokenStorage.d.ts +96 -0
  69. package/dist/config/TokenStorage.d.ts.map +1 -0
  70. package/dist/config/TokenStorage.js +128 -0
  71. package/dist/config/TokenStorage.js.map +1 -0
  72. package/dist/config/index.d.ts +9 -0
  73. package/dist/config/index.d.ts.map +1 -0
  74. package/dist/config/index.js +12 -0
  75. package/dist/config/index.js.map +1 -0
  76. package/dist/index.d.ts +13 -0
  77. package/dist/index.d.ts.map +1 -0
  78. package/dist/index.js +20 -0
  79. package/dist/index.js.map +1 -0
  80. package/dist/parsers/index.d.ts +7 -0
  81. package/dist/parsers/index.d.ts.map +1 -0
  82. package/dist/parsers/index.js +8 -0
  83. package/dist/parsers/index.js.map +1 -0
  84. package/dist/serializers/MarkdownSerializer.d.ts +63 -0
  85. package/dist/serializers/MarkdownSerializer.d.ts.map +1 -0
  86. package/dist/serializers/MarkdownSerializer.js +367 -0
  87. package/dist/serializers/MarkdownSerializer.js.map +1 -0
  88. package/dist/serializers/index.d.ts +7 -0
  89. package/dist/serializers/index.d.ts.map +1 -0
  90. package/dist/serializers/index.js +7 -0
  91. package/dist/serializers/index.js.map +1 -0
  92. package/package.json +86 -0
  93. package/src/Brand.ts +142 -0
  94. package/src/Hash.ts +68 -0
  95. package/src/SerializeError.ts +50 -0
  96. package/src/ast/BlockNode.ts +386 -0
  97. package/src/ast/Document.ts +101 -0
  98. package/src/ast/InlineNode.ts +328 -0
  99. package/src/ast/MacroNode.ts +172 -0
  100. package/src/ast/index.ts +87 -0
  101. package/src/auth/OAuthEndpoints.ts +142 -0
  102. package/src/auth/OAuthErrors.ts +101 -0
  103. package/src/auth/OAuthOperations.ts +276 -0
  104. package/src/auth/OAuthResponseSchemas.ts +70 -0
  105. package/src/auth/index.ts +47 -0
  106. package/src/auth/uuid.ts +14 -0
  107. package/src/config/ConfigPaths.ts +179 -0
  108. package/src/config/OAuthSchemas.ts +146 -0
  109. package/src/config/TokenStorage.ts +228 -0
  110. package/src/config/index.ts +43 -0
  111. package/src/index.ts +34 -0
  112. package/src/parsers/index.ts +8 -0
  113. package/src/serializers/MarkdownSerializer.ts +498 -0
  114. package/src/serializers/index.ts +7 -0
  115. package/test/Brand.test.ts +90 -0
  116. package/test/MarkdownSerializer.test.ts +82 -0
  117. package/test/OAuthEndpoints.test.ts +109 -0
  118. package/test/OAuthOperations.test.ts +315 -0
  119. package/tsconfig.json +11 -0
  120. package/tsconfig.tsbuildinfo +1 -0
  121. package/vitest.config.ts +12 -0
@@ -0,0 +1,498 @@
1
+ /**
2
+ * AST-to-Markdown serializer with roundtrip comment encoding for non-Markdown features.
3
+ *
4
+ * **Mental model**
5
+ *
6
+ * - **Visitor over DocumentNode union**: {@link serializeToMarkdown} walks the AST, dispatching
7
+ * on `_tag` to emit Markdown. Effect-based for composability with the rest of the pipeline.
8
+ * - **Roundtrip via HTML comments**: Confluence-specific data (panel types, task status,
9
+ * image alignment) is encoded in `<!-- ... -->` comments with base64/URL encoding so
10
+ * a future parser can reconstruct the original.
11
+ *
12
+ * **Common tasks**
13
+ *
14
+ * - Serialize a document: {@link serializeToMarkdown}
15
+ * - Serialize inline nodes only: {@link serializeInlineNodes}
16
+ *
17
+ * **Gotchas**
18
+ *
19
+ * - Comment-encoded data is fragile if users hand-edit the Markdown — edits inside
20
+ * `<!-- -->` blocks will break roundtripping.
21
+ *
22
+ * @module
23
+ */
24
+ import * as Effect from "effect/Effect"
25
+ import type { Document, DocumentNode } from "../ast/Document.js"
26
+ import type { InlineNode } from "../ast/InlineNode.js"
27
+ import type { SerializeError } from "../SerializeError.js"
28
+
29
+ /**
30
+ * Options for Markdown serialization.
31
+ *
32
+ * @category Serializers
33
+ */
34
+ export interface SerializeOptions {
35
+ /** Include raw source for lossless roundtrip. Default: false */
36
+ readonly includeRawSource?: boolean
37
+ /** Custom comment prefix for roundtrip encoding. Default: "src" */
38
+ readonly commentPrefix?: string
39
+ }
40
+
41
+ /**
42
+ * Serialize Document AST to GitHub Flavored Markdown.
43
+ *
44
+ * @example
45
+ * ```typescript
46
+ * import { serializeToMarkdown, makeDocument, Heading, Text } from "@knpkv/atlassian-common"
47
+ * import { Effect } from "effect"
48
+ *
49
+ * Effect.gen(function* () {
50
+ * const doc = makeDocument([
51
+ * new Heading({ level: 1, children: [new Text({ value: "Title" })] })
52
+ * ])
53
+ * const md = yield* serializeToMarkdown(doc)
54
+ * console.log(md) // # Title
55
+ * })
56
+ * ```
57
+ *
58
+ * @category Serializers
59
+ */
60
+ export const serializeToMarkdown = (
61
+ doc: Document,
62
+ options: SerializeOptions = {}
63
+ ): Effect.Effect<string, SerializeError> =>
64
+ Effect.gen(function*() {
65
+ const parts: Array<string> = []
66
+ for (const node of doc.children) {
67
+ const serialized = yield* serializeDocumentNode(node, options)
68
+ parts.push(serialized)
69
+ }
70
+
71
+ const content = parts.join("\n\n")
72
+
73
+ // Embed rawSource in comment for roundtrip preservation
74
+ if (options.includeRawSource && doc.rawSource !== undefined) {
75
+ const prefix = options.commentPrefix ?? "src"
76
+ const encoded = Buffer.from(doc.rawSource, "utf-8").toString("base64")
77
+ return `${content}\n\n<!--${prefix}:raw:${encoded}-->`
78
+ }
79
+
80
+ return content
81
+ })
82
+
83
+ /**
84
+ * Serialize a document node to Markdown.
85
+ */
86
+ const serializeDocumentNode = (
87
+ node: DocumentNode,
88
+ options: SerializeOptions
89
+ ): Effect.Effect<string, SerializeError> =>
90
+ Effect.gen(function*() {
91
+ switch (node._tag) {
92
+ // Block nodes
93
+ case "Heading":
94
+ return yield* serializeHeading(node)
95
+ case "Paragraph":
96
+ return yield* serializeParagraph(node)
97
+ case "CodeBlock":
98
+ return serializeCodeBlock(node)
99
+ case "ThematicBreak":
100
+ return "---"
101
+ case "Image":
102
+ return serializeImage(node, options)
103
+ case "Table":
104
+ return yield* serializeTable(node)
105
+ case "List":
106
+ return yield* serializeList(node, options)
107
+ case "BlockQuote":
108
+ return yield* serializeBlockQuote(node, options)
109
+ case "UnsupportedBlock":
110
+ return node.rawMarkdown ?? node.rawHtml ?? node.rawAdf ?? ""
111
+
112
+ // Macro nodes
113
+ case "InfoPanel":
114
+ return yield* serializeInfoPanel(node, options)
115
+ case "ExpandMacro":
116
+ return yield* serializeExpandMacro(node, options)
117
+ case "TocMacro":
118
+ return "[[toc]]"
119
+ case "CodeMacro":
120
+ return serializeCodeMacro(node)
121
+ case "StatusMacro":
122
+ return `**[${node.text}]**`
123
+ case "TaskList":
124
+ return yield* serializeTaskList(node.children, options)
125
+
126
+ default:
127
+ return ""
128
+ }
129
+ })
130
+
131
+ /**
132
+ * Serialize heading.
133
+ */
134
+ const serializeHeading = (
135
+ node: { level: 1 | 2 | 3 | 4 | 5 | 6; children: ReadonlyArray<InlineNode> }
136
+ ): Effect.Effect<string, SerializeError> =>
137
+ Effect.gen(function*() {
138
+ const prefix = "#".repeat(node.level)
139
+ const content = yield* serializeInlineNodes(node.children)
140
+ return `${prefix} ${content}`
141
+ })
142
+
143
+ /**
144
+ * Serialize paragraph.
145
+ */
146
+ const serializeParagraph = (
147
+ node: {
148
+ children: ReadonlyArray<InlineNode>
149
+ alignment?: "left" | "center" | "right" | undefined
150
+ indent?: number | undefined
151
+ }
152
+ ): Effect.Effect<string, SerializeError> =>
153
+ Effect.gen(function*() {
154
+ const content = yield* serializeInlineNodes(node.children)
155
+ // If has alignment or indent, wrap in HTML for roundtrip
156
+ if (node.alignment || node.indent) {
157
+ const styles: Array<string> = []
158
+ if (node.alignment) styles.push(`text-align: ${node.alignment};`)
159
+ if (node.indent) styles.push(`margin-left: ${node.indent}px;`)
160
+ return `<p style="${styles.join(" ")}">${content}</p>`
161
+ }
162
+ return content
163
+ })
164
+
165
+ /**
166
+ * Serialize code block.
167
+ */
168
+ const serializeCodeBlock = (node: { code: string; language?: string | undefined }): string => {
169
+ const lang = node.language ?? ""
170
+ return `\`\`\`${lang}\n${node.code}\n\`\`\``
171
+ }
172
+
173
+ /**
174
+ * Serialize image.
175
+ */
176
+ const serializeImage = (
177
+ node: {
178
+ src?: string | undefined
179
+ attachment?: { filename: string; version?: number | undefined } | undefined
180
+ alt?: string | undefined
181
+ title?: string | undefined
182
+ align?: string | undefined
183
+ width?: number | undefined
184
+ },
185
+ options: SerializeOptions
186
+ ): string => {
187
+ const prefix = options.commentPrefix ?? "src"
188
+
189
+ // If image has attachment or align/width, use comment encoding for roundtrip
190
+ if (node.attachment || node.align || node.width) {
191
+ const parts: Array<string> = []
192
+ if (node.attachment) {
193
+ parts.push(`f=${encodeURIComponent(node.attachment.filename)}`)
194
+ if (node.attachment.version) parts.push(`v=${node.attachment.version}`)
195
+ }
196
+ if (node.src) parts.push(`s=${encodeURIComponent(node.src)}`)
197
+ if (node.alt) parts.push(`a=${encodeURIComponent(node.alt)}`)
198
+ if (node.title) parts.push(`t=${encodeURIComponent(node.title)}`)
199
+ if (node.align) parts.push(`al=${node.align}`)
200
+ if (node.width) parts.push(`w=${node.width}`)
201
+ return `<!--${prefix}:image:${parts.join("|")}-->`
202
+ }
203
+
204
+ // Simple external image - use markdown syntax
205
+ const alt = node.alt ?? ""
206
+ const title = node.title ? ` "${node.title}"` : ""
207
+ const src = node.src ?? ""
208
+ return `![${alt}](${src}${title})`
209
+ }
210
+
211
+ /**
212
+ * Serialize table.
213
+ */
214
+ const serializeTable = (
215
+ node: {
216
+ header?: { cells: ReadonlyArray<{ children: ReadonlyArray<InlineNode> }> } | undefined
217
+ rows: ReadonlyArray<{ cells: ReadonlyArray<{ children: ReadonlyArray<InlineNode> }> }>
218
+ }
219
+ ): Effect.Effect<string, SerializeError> =>
220
+ Effect.gen(function*() {
221
+ const lines: Array<string> = []
222
+
223
+ // Header
224
+ if (node.header) {
225
+ const headerCells: Array<string> = []
226
+ for (const cell of node.header.cells) {
227
+ headerCells.push(yield* serializeInlineNodes(cell.children))
228
+ }
229
+ lines.push(`| ${headerCells.join(" | ")} |`)
230
+ lines.push(`| ${headerCells.map(() => "---").join(" | ")} |`)
231
+ }
232
+
233
+ // Body rows
234
+ for (const row of node.rows) {
235
+ const cells: Array<string> = []
236
+ for (const cell of row.cells) {
237
+ cells.push(yield* serializeInlineNodes(cell.children))
238
+ }
239
+ lines.push(`| ${cells.join(" | ")} |`)
240
+ }
241
+
242
+ return lines.join("\n")
243
+ })
244
+
245
+ // Simple block type for list items - use unknown for flexibility
246
+ type SimpleBlock = unknown
247
+
248
+ // List item type
249
+ type ListItemType = {
250
+ readonly _tag: "ListItem"
251
+ readonly checked?: boolean | undefined
252
+ readonly children: ReadonlyArray<unknown>
253
+ }
254
+
255
+ /**
256
+ * Serialize list.
257
+ */
258
+ const serializeList = (
259
+ node: { ordered: boolean; start?: number | undefined; children: ReadonlyArray<unknown> },
260
+ options: SerializeOptions
261
+ ): Effect.Effect<string, SerializeError> =>
262
+ Effect.gen(function*() {
263
+ const lines: Array<string> = []
264
+ let counter = node.start ?? 1
265
+
266
+ for (const item of node.children as ReadonlyArray<ListItemType>) {
267
+ const prefix = node.ordered ? `${counter}.` : "-"
268
+ const checkbox = item.checked !== undefined ? (item.checked ? "[x] " : "[ ] ") : ""
269
+
270
+ // Serialize item content
271
+ const itemParts: Array<string> = []
272
+ for (const child of item.children) {
273
+ const serialized = yield* serializeSimpleBlock(child, options)
274
+ itemParts.push(serialized)
275
+ }
276
+
277
+ const content = itemParts.join("\n")
278
+ const indentedContent = content
279
+ .split("\n")
280
+ .map((line, i) => (i === 0 ? `${prefix} ${checkbox}${line}` : ` ${line}`))
281
+ .join("\n")
282
+
283
+ lines.push(indentedContent)
284
+ counter++
285
+ }
286
+
287
+ return lines.join("\n")
288
+ })
289
+
290
+ /**
291
+ * Serialize simple block (for nested content).
292
+ */
293
+ const serializeSimpleBlock = (
294
+ node: SimpleBlock,
295
+ options: SerializeOptions
296
+ ): Effect.Effect<string, SerializeError> =>
297
+ Effect.gen(function*() {
298
+ const tagged = node as { readonly _tag: string }
299
+ switch (tagged._tag) {
300
+ case "Heading":
301
+ return yield* serializeHeading(
302
+ node as unknown as { level: 1 | 2 | 3 | 4 | 5 | 6; children: ReadonlyArray<InlineNode> }
303
+ )
304
+ case "Paragraph":
305
+ return yield* serializeParagraph(node as unknown as { children: ReadonlyArray<InlineNode> })
306
+ case "CodeBlock":
307
+ return serializeCodeBlock(node as unknown as { code: string; language?: string | undefined })
308
+ case "ThematicBreak":
309
+ return "---"
310
+ case "Image":
311
+ return serializeImage(
312
+ node as unknown as { src: string; alt?: string | undefined; title?: string | undefined },
313
+ options
314
+ )
315
+ case "Table":
316
+ return yield* serializeTable(
317
+ node as unknown as {
318
+ header?: { cells: ReadonlyArray<{ children: ReadonlyArray<InlineNode> }> } | undefined
319
+ rows: ReadonlyArray<{ cells: ReadonlyArray<{ children: ReadonlyArray<InlineNode> }> }>
320
+ }
321
+ )
322
+ case "UnsupportedBlock": {
323
+ const unsupported = node as unknown as { rawMarkdown?: string; rawHtml?: string; rawAdf?: string }
324
+ return unsupported.rawMarkdown ?? unsupported.rawHtml ?? unsupported.rawAdf ?? ""
325
+ }
326
+ default:
327
+ return ""
328
+ }
329
+ })
330
+
331
+ /**
332
+ * Serialize block quote.
333
+ */
334
+ const serializeBlockQuote = (
335
+ node: { children: ReadonlyArray<unknown> },
336
+ options: SerializeOptions
337
+ ): Effect.Effect<string, SerializeError> =>
338
+ Effect.gen(function*() {
339
+ const lines: Array<string> = []
340
+ for (const child of node.children) {
341
+ const serialized = yield* serializeSimpleBlock(child, options)
342
+ const quoted = serialized.split("\n").map((line) => `> ${line}`).join("\n")
343
+ lines.push(quoted)
344
+ }
345
+ return lines.join("\n>\n")
346
+ })
347
+
348
+ /**
349
+ * Serialize info panel to container syntax.
350
+ */
351
+ const serializeInfoPanel = (
352
+ node: { panelType: string; title?: string | undefined; children: ReadonlyArray<unknown> },
353
+ options: SerializeOptions
354
+ ): Effect.Effect<string, SerializeError> =>
355
+ Effect.gen(function*() {
356
+ const type = node.panelType
357
+ const title = node.title ? ` ${node.title}` : ""
358
+ const lines: Array<string> = [`:::${type}${title}`]
359
+
360
+ for (const child of node.children) {
361
+ const serialized = yield* serializeSimpleBlock(child, options)
362
+ lines.push(serialized)
363
+ }
364
+
365
+ lines.push(":::")
366
+ return lines.join("\n")
367
+ })
368
+
369
+ /**
370
+ * Serialize expand macro.
371
+ */
372
+ const serializeExpandMacro = (
373
+ node: { title?: string | undefined; children: ReadonlyArray<unknown> },
374
+ options: SerializeOptions
375
+ ): Effect.Effect<string, SerializeError> =>
376
+ Effect.gen(function*() {
377
+ const prefix = options.commentPrefix ?? "src"
378
+ const title = node.title ?? ""
379
+ const contentParts: Array<string> = []
380
+
381
+ for (const child of node.children) {
382
+ const serialized = yield* serializeSimpleBlock(child, options)
383
+ contentParts.push(serialized)
384
+ }
385
+
386
+ const content = contentParts.join("\n")
387
+ return `<!--${prefix}:expand:${encodeURIComponent(title)}:${encodeURIComponent(content)}-->`
388
+ })
389
+
390
+ /**
391
+ * Serialize code macro (similar to code block but may have title).
392
+ */
393
+ const serializeCodeMacro = (
394
+ node: { language?: string | undefined; title?: string | undefined; code: string }
395
+ ): string => {
396
+ const lang = node.language ?? ""
397
+ const title = node.title ? ` title="${node.title}"` : ""
398
+ return `\`\`\`${lang}${title}\n${node.code}\n\`\`\``
399
+ }
400
+
401
+ /**
402
+ * Serialize task list.
403
+ */
404
+ const serializeTaskList = (
405
+ children: ReadonlyArray<{
406
+ _tag: "TaskItem"
407
+ id: string
408
+ uuid: string
409
+ status: "incomplete" | "complete"
410
+ body: ReadonlyArray<InlineNode>
411
+ }>,
412
+ options: SerializeOptions
413
+ ): Effect.Effect<string, SerializeError> =>
414
+ Effect.gen(function*() {
415
+ const prefix = options.commentPrefix ?? "src"
416
+ const items: Array<string> = []
417
+ for (const item of children) {
418
+ const body = yield* serializeInlineNodes(item.body)
419
+ items.push(`${item.id}|${item.uuid}|${item.status}|${encodeURIComponent(body)}`)
420
+ }
421
+ return `<!--${prefix}:tasklist:${items.join(";")}-->`
422
+ })
423
+
424
+ /**
425
+ * Serialize inline nodes to Markdown.
426
+ */
427
+ export const serializeInlineNodes = (
428
+ nodes: ReadonlyArray<InlineNode>
429
+ ): Effect.Effect<string, SerializeError> =>
430
+ Effect.gen(function*() {
431
+ const parts: Array<string> = []
432
+ for (const node of nodes) {
433
+ parts.push(yield* serializeInlineNode(node))
434
+ }
435
+ return parts.join("")
436
+ })
437
+
438
+ /**
439
+ * Serialize inline node to Markdown.
440
+ */
441
+ const serializeInlineNode = (node: InlineNode): Effect.Effect<string, SerializeError> =>
442
+ Effect.gen(function*() {
443
+ switch (node._tag) {
444
+ case "Text":
445
+ return node.value
446
+ case "Strong": {
447
+ const content = yield* serializeInlineNodes(node.children)
448
+ return `**${content}**`
449
+ }
450
+ case "Emphasis": {
451
+ const content = yield* serializeInlineNodes(node.children)
452
+ return `*${content}*`
453
+ }
454
+ case "Underline": {
455
+ const content = yield* serializeInlineNodes(node.children)
456
+ return `<u>${content}</u>`
457
+ }
458
+ case "Strikethrough": {
459
+ const content = yield* serializeInlineNodes(node.children)
460
+ return `~~${content}~~`
461
+ }
462
+ case "Subscript": {
463
+ const content = yield* serializeInlineNodes(node.children)
464
+ return `<sub>${content}</sub>`
465
+ }
466
+ case "Superscript": {
467
+ const content = yield* serializeInlineNodes(node.children)
468
+ return `<sup>${content}</sup>`
469
+ }
470
+ case "InlineCode":
471
+ return `\`${node.value}\``
472
+ case "Link": {
473
+ const content = yield* serializeInlineNodes(node.children)
474
+ const title = node.title ? ` "${node.title}"` : ""
475
+ return `[${content}](${node.href}${title})`
476
+ }
477
+ case "LineBreak":
478
+ return " \n"
479
+ case "Emoticon":
480
+ return node.fallback
481
+ case "UserMention":
482
+ return `@${node.accountId}`
483
+ case "DateTime":
484
+ return node.datetime
485
+ case "ColoredText": {
486
+ const content = yield* serializeInlineNodes(node.children)
487
+ return `<span style="color: ${node.color};">${content}</span>`
488
+ }
489
+ case "Highlight": {
490
+ const content = yield* serializeInlineNodes(node.children)
491
+ return `<span style="background-color: ${node.backgroundColor};">${content}</span>`
492
+ }
493
+ case "UnsupportedInline":
494
+ return node.raw
495
+ default:
496
+ return ""
497
+ }
498
+ })
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Barrel export for AST serializers.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ export { serializeInlineNodes, type SerializeOptions, serializeToMarkdown } from "./MarkdownSerializer.js"
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { ContentHash, IssueKey, PageId, ProjectKey, SpaceKey } from "../src/Brand.js"
3
+
4
+ describe("Brand", () => {
5
+ describe("IssueKey", () => {
6
+ // Happy path: "PROJ-123" is the canonical Jira issue key format
7
+ it("accepts valid issue keys", () => {
8
+ expect(IssueKey("PROJ-123")).toBe("PROJ-123")
9
+ })
10
+
11
+ // Jira allows alphanumeric project prefixes like "ABC123-456"
12
+ it("accepts multi-char project keys", () => {
13
+ expect(IssueKey("ABC123-456")).toBe("ABC123-456")
14
+ })
15
+
16
+ // Jira keys are always uppercase — lowercase would fail API lookups
17
+ it("rejects lowercase", () => {
18
+ expect(() => IssueKey("proj-123")).toThrow()
19
+ })
20
+
21
+ // "PROJ-" without a number is an incomplete key — would break URL construction
22
+ it("rejects missing number", () => {
23
+ expect(() => IssueKey("PROJ-")).toThrow()
24
+ })
25
+ })
26
+
27
+ describe("ProjectKey", () => {
28
+ // Jira project keys are uppercase alpha strings
29
+ it("accepts valid project keys", () => {
30
+ expect(ProjectKey("PROJ")).toBe("PROJ")
31
+ })
32
+
33
+ // Alphanumeric suffixes allowed (e.g. "PROJ123")
34
+ it("accepts alphanumeric", () => {
35
+ expect(ProjectKey("PROJ123")).toBe("PROJ123")
36
+ })
37
+
38
+ // Jira enforces uppercase project keys
39
+ it("rejects lowercase", () => {
40
+ expect(() => ProjectKey("proj")).toThrow()
41
+ })
42
+
43
+ // Jira project keys must start with a letter
44
+ it("rejects starting with number", () => {
45
+ expect(() => ProjectKey("123PROJ")).toThrow()
46
+ })
47
+ })
48
+
49
+ describe("SpaceKey", () => {
50
+ // Confluence space keys follow same uppercase convention
51
+ it("accepts valid space keys", () => {
52
+ expect(SpaceKey("DEV")).toBe("DEV")
53
+ })
54
+
55
+ // Lowercase would fail Confluence API lookups
56
+ it("rejects lowercase", () => {
57
+ expect(() => SpaceKey("dev")).toThrow()
58
+ })
59
+ })
60
+
61
+ describe("PageId", () => {
62
+ // Confluence page IDs are numeric strings — must be non-empty
63
+ it("accepts non-empty strings", () => {
64
+ expect(PageId("12345")).toBe("12345")
65
+ })
66
+
67
+ // Empty string would produce invalid API URLs
68
+ it("rejects empty string", () => {
69
+ expect(() => PageId("")).toThrow()
70
+ })
71
+ })
72
+
73
+ describe("ContentHash", () => {
74
+ // SHA-256 hashes are 64 lowercase hex chars — used for content change detection
75
+ it("accepts valid SHA256 hash", () => {
76
+ const hash = "a".repeat(64)
77
+ expect(ContentHash(hash)).toBe(hash)
78
+ })
79
+
80
+ // Short strings aren't valid SHA-256 hashes
81
+ it("rejects short hash", () => {
82
+ expect(() => ContentHash("abc123")).toThrow()
83
+ })
84
+
85
+ // SHA-256 hex output is lowercase — uppercase would break comparison
86
+ it("rejects uppercase", () => {
87
+ expect(() => ContentHash("A".repeat(64))).toThrow()
88
+ })
89
+ })
90
+ })
@@ -0,0 +1,82 @@
1
+ import { describe, it } from "@effect/vitest"
2
+ import { Effect } from "effect"
3
+ import { expect } from "vitest"
4
+ import { Emphasis, Heading, makeDocument, Paragraph, Strong, Text } from "../src/ast/index.js"
5
+ import { serializeToMarkdown } from "../src/serializers/MarkdownSerializer.js"
6
+
7
+ describe("MarkdownSerializer", () => {
8
+ describe("serializeToMarkdown", () => {
9
+ // Headings map to ATX-style markdown (# Title) — core block-level rendering
10
+ it.effect("serializes heading", () =>
11
+ Effect.gen(function*() {
12
+ const doc = makeDocument([
13
+ new Heading({ level: 1, children: [new Text({ value: "Title" })] })
14
+ ])
15
+ const result = yield* serializeToMarkdown(doc)
16
+ expect(result).toBe("# Title")
17
+ }))
18
+
19
+ // Paragraphs render as plain text — the simplest block element
20
+ it.effect("serializes paragraph", () =>
21
+ Effect.gen(function*() {
22
+ const doc = makeDocument([
23
+ new Paragraph({ children: [new Text({ value: "Hello world" })] })
24
+ ])
25
+ const result = yield* serializeToMarkdown(doc)
26
+ expect(result).toBe("Hello world")
27
+ }))
28
+
29
+ // Strong text wraps in ** — verifies inline formatting within paragraph flow
30
+ it.effect("serializes strong text", () =>
31
+ Effect.gen(function*() {
32
+ const doc = makeDocument([
33
+ new Paragraph({
34
+ children: [
35
+ new Text({ value: "This is " }),
36
+ new Strong({ children: [new Text({ value: "bold" })] }),
37
+ new Text({ value: " text" })
38
+ ]
39
+ })
40
+ ])
41
+ const result = yield* serializeToMarkdown(doc)
42
+ expect(result).toBe("This is **bold** text")
43
+ }))
44
+
45
+ // Emphasis wraps in * — distinct from strong (**) to verify correct delimiter
46
+ it.effect("serializes emphasis text", () =>
47
+ Effect.gen(function*() {
48
+ const doc = makeDocument([
49
+ new Paragraph({
50
+ children: [
51
+ new Emphasis({ children: [new Text({ value: "italic" })] })
52
+ ]
53
+ })
54
+ ])
55
+ const result = yield* serializeToMarkdown(doc)
56
+ expect(result).toBe("*italic*")
57
+ }))
58
+
59
+ // Multiple blocks separated by blank lines — standard markdown block spacing
60
+ it.effect("serializes multiple blocks with blank line separator", () =>
61
+ Effect.gen(function*() {
62
+ const doc = makeDocument([
63
+ new Heading({ level: 1, children: [new Text({ value: "Title" })] }),
64
+ new Paragraph({ children: [new Text({ value: "Content" })] })
65
+ ])
66
+ const result = yield* serializeToMarkdown(doc)
67
+ expect(result).toBe("# Title\n\nContent")
68
+ }))
69
+
70
+ // Raw source embedding allows round-trip debugging — HTML comment with base64-encoded original
71
+ it.effect("includes raw source when option enabled", () =>
72
+ Effect.gen(function*() {
73
+ const doc = makeDocument(
74
+ [new Paragraph({ children: [new Text({ value: "Test" })] })],
75
+ "<p>Test</p>"
76
+ )
77
+ const result = yield* serializeToMarkdown(doc, { includeRawSource: true })
78
+ expect(result).toContain("<!--src:raw:")
79
+ expect(result).toContain("Test")
80
+ }))
81
+ })
82
+ })