@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.
- package/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/Brand.d.ts +118 -0
- package/dist/Brand.d.ts.map +1 -0
- package/dist/Brand.js +107 -0
- package/dist/Brand.js.map +1 -0
- package/dist/Hash.d.ts +53 -0
- package/dist/Hash.d.ts.map +1 -0
- package/dist/Hash.js +63 -0
- package/dist/Hash.js.map +1 -0
- package/dist/SerializeError.d.ts +48 -0
- package/dist/SerializeError.d.ts.map +1 -0
- package/dist/SerializeError.js +42 -0
- package/dist/SerializeError.js.map +1 -0
- package/dist/ast/BlockNode.d.ts +429 -0
- package/dist/ast/BlockNode.d.ts.map +1 -0
- package/dist/ast/BlockNode.js +279 -0
- package/dist/ast/BlockNode.js.map +1 -0
- package/dist/ast/Document.d.ts +255 -0
- package/dist/ast/Document.d.ts.map +1 -0
- package/dist/ast/Document.js +80 -0
- package/dist/ast/Document.js.map +1 -0
- package/dist/ast/InlineNode.d.ts +483 -0
- package/dist/ast/InlineNode.d.ts.map +1 -0
- package/dist/ast/InlineNode.js +268 -0
- package/dist/ast/InlineNode.js.map +1 -0
- package/dist/ast/MacroNode.d.ts +214 -0
- package/dist/ast/MacroNode.d.ts.map +1 -0
- package/dist/ast/MacroNode.js +110 -0
- package/dist/ast/MacroNode.js.map +1 -0
- package/dist/ast/index.d.ts +10 -0
- package/dist/ast/index.d.ts.map +1 -0
- package/dist/ast/index.js +14 -0
- package/dist/ast/index.js.map +1 -0
- package/dist/auth/OAuthEndpoints.d.ts +95 -0
- package/dist/auth/OAuthEndpoints.d.ts.map +1 -0
- package/dist/auth/OAuthEndpoints.js +112 -0
- package/dist/auth/OAuthEndpoints.js.map +1 -0
- package/dist/auth/OAuthErrors.d.ts +76 -0
- package/dist/auth/OAuthErrors.d.ts.map +1 -0
- package/dist/auth/OAuthErrors.js +85 -0
- package/dist/auth/OAuthErrors.js.map +1 -0
- package/dist/auth/OAuthOperations.d.ts +87 -0
- package/dist/auth/OAuthOperations.d.ts.map +1 -0
- package/dist/auth/OAuthOperations.js +169 -0
- package/dist/auth/OAuthOperations.js.map +1 -0
- package/dist/auth/OAuthResponseSchemas.d.ts +65 -0
- package/dist/auth/OAuthResponseSchemas.d.ts.map +1 -0
- package/dist/auth/OAuthResponseSchemas.js +47 -0
- package/dist/auth/OAuthResponseSchemas.js.map +1 -0
- package/dist/auth/index.d.ts +11 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +16 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/uuid.d.ts +14 -0
- package/dist/auth/uuid.d.ts.map +1 -0
- package/dist/auth/uuid.js +14 -0
- package/dist/auth/uuid.js.map +1 -0
- package/dist/config/ConfigPaths.d.ts +95 -0
- package/dist/config/ConfigPaths.d.ts.map +1 -0
- package/dist/config/ConfigPaths.js +102 -0
- package/dist/config/ConfigPaths.js.map +1 -0
- package/dist/config/OAuthSchemas.d.ts +144 -0
- package/dist/config/OAuthSchemas.d.ts.map +1 -0
- package/dist/config/OAuthSchemas.js +107 -0
- package/dist/config/OAuthSchemas.js.map +1 -0
- package/dist/config/TokenStorage.d.ts +96 -0
- package/dist/config/TokenStorage.d.ts.map +1 -0
- package/dist/config/TokenStorage.js +128 -0
- package/dist/config/TokenStorage.js.map +1 -0
- package/dist/config/index.d.ts +9 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +12 -0
- package/dist/config/index.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/parsers/index.d.ts +7 -0
- package/dist/parsers/index.d.ts.map +1 -0
- package/dist/parsers/index.js +8 -0
- package/dist/parsers/index.js.map +1 -0
- package/dist/serializers/MarkdownSerializer.d.ts +63 -0
- package/dist/serializers/MarkdownSerializer.d.ts.map +1 -0
- package/dist/serializers/MarkdownSerializer.js +367 -0
- package/dist/serializers/MarkdownSerializer.js.map +1 -0
- package/dist/serializers/index.d.ts +7 -0
- package/dist/serializers/index.d.ts.map +1 -0
- package/dist/serializers/index.js +7 -0
- package/dist/serializers/index.js.map +1 -0
- package/package.json +86 -0
- package/src/Brand.ts +142 -0
- package/src/Hash.ts +68 -0
- package/src/SerializeError.ts +50 -0
- package/src/ast/BlockNode.ts +386 -0
- package/src/ast/Document.ts +101 -0
- package/src/ast/InlineNode.ts +328 -0
- package/src/ast/MacroNode.ts +172 -0
- package/src/ast/index.ts +87 -0
- package/src/auth/OAuthEndpoints.ts +142 -0
- package/src/auth/OAuthErrors.ts +101 -0
- package/src/auth/OAuthOperations.ts +276 -0
- package/src/auth/OAuthResponseSchemas.ts +70 -0
- package/src/auth/index.ts +47 -0
- package/src/auth/uuid.ts +14 -0
- package/src/config/ConfigPaths.ts +179 -0
- package/src/config/OAuthSchemas.ts +146 -0
- package/src/config/TokenStorage.ts +228 -0
- package/src/config/index.ts +43 -0
- package/src/index.ts +34 -0
- package/src/parsers/index.ts +8 -0
- package/src/serializers/MarkdownSerializer.ts +498 -0
- package/src/serializers/index.ts +7 -0
- package/test/Brand.test.ts +90 -0
- package/test/MarkdownSerializer.test.ts +82 -0
- package/test/OAuthEndpoints.test.ts +109 -0
- package/test/OAuthOperations.test.ts +315 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
- 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 ``
|
|
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,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
|
+
})
|