@knpkv/confluence-to-markdown 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 (74) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +62 -0
  3. package/dist/Brand.d.ts +77 -0
  4. package/dist/Brand.d.ts.map +1 -0
  5. package/dist/Brand.js +44 -0
  6. package/dist/Brand.js.map +1 -0
  7. package/dist/ConfluenceClient.d.ts +140 -0
  8. package/dist/ConfluenceClient.d.ts.map +1 -0
  9. package/dist/ConfluenceClient.js +195 -0
  10. package/dist/ConfluenceClient.js.map +1 -0
  11. package/dist/ConfluenceConfig.d.ts +83 -0
  12. package/dist/ConfluenceConfig.d.ts.map +1 -0
  13. package/dist/ConfluenceConfig.js +122 -0
  14. package/dist/ConfluenceConfig.js.map +1 -0
  15. package/dist/ConfluenceError.d.ts +178 -0
  16. package/dist/ConfluenceError.d.ts.map +1 -0
  17. package/dist/ConfluenceError.js +131 -0
  18. package/dist/ConfluenceError.js.map +1 -0
  19. package/dist/LocalFileSystem.d.ts +85 -0
  20. package/dist/LocalFileSystem.d.ts.map +1 -0
  21. package/dist/LocalFileSystem.js +101 -0
  22. package/dist/LocalFileSystem.js.map +1 -0
  23. package/dist/MarkdownConverter.d.ts +50 -0
  24. package/dist/MarkdownConverter.d.ts.map +1 -0
  25. package/dist/MarkdownConverter.js +151 -0
  26. package/dist/MarkdownConverter.js.map +1 -0
  27. package/dist/Schemas.d.ts +225 -0
  28. package/dist/Schemas.d.ts.map +1 -0
  29. package/dist/Schemas.js +164 -0
  30. package/dist/Schemas.js.map +1 -0
  31. package/dist/SyncEngine.d.ts +132 -0
  32. package/dist/SyncEngine.d.ts.map +1 -0
  33. package/dist/SyncEngine.js +267 -0
  34. package/dist/SyncEngine.js.map +1 -0
  35. package/dist/bin.d.ts +3 -0
  36. package/dist/bin.d.ts.map +1 -0
  37. package/dist/bin.js +163 -0
  38. package/dist/bin.js.map +1 -0
  39. package/dist/index.d.ts +16 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +16 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/internal/frontmatter.d.ts +38 -0
  44. package/dist/internal/frontmatter.d.ts.map +1 -0
  45. package/dist/internal/frontmatter.js +69 -0
  46. package/dist/internal/frontmatter.js.map +1 -0
  47. package/dist/internal/hashUtils.d.ts +11 -0
  48. package/dist/internal/hashUtils.d.ts.map +1 -0
  49. package/dist/internal/hashUtils.js +17 -0
  50. package/dist/internal/hashUtils.js.map +1 -0
  51. package/dist/internal/pathUtils.d.ts +41 -0
  52. package/dist/internal/pathUtils.d.ts.map +1 -0
  53. package/dist/internal/pathUtils.js +69 -0
  54. package/dist/internal/pathUtils.js.map +1 -0
  55. package/package.json +113 -0
  56. package/src/Brand.ts +104 -0
  57. package/src/ConfluenceClient.ts +387 -0
  58. package/src/ConfluenceConfig.ts +184 -0
  59. package/src/ConfluenceError.ts +193 -0
  60. package/src/LocalFileSystem.ts +225 -0
  61. package/src/MarkdownConverter.ts +187 -0
  62. package/src/Schemas.ts +235 -0
  63. package/src/SyncEngine.ts +429 -0
  64. package/src/bin.ts +269 -0
  65. package/src/index.ts +35 -0
  66. package/src/internal/frontmatter.ts +98 -0
  67. package/src/internal/hashUtils.ts +19 -0
  68. package/src/internal/pathUtils.ts +77 -0
  69. package/test/Brand.test.ts +72 -0
  70. package/test/MarkdownConverter.test.ts +108 -0
  71. package/test/Schemas.test.ts +99 -0
  72. package/tsconfig.json +32 -0
  73. package/vitest.config.integration.ts +12 -0
  74. package/vitest.config.ts +13 -0
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Error types for Confluence operations.
3
+ *
4
+ * @module
5
+ */
6
+ import * as Data from "effect/Data"
7
+
8
+ /**
9
+ * Error thrown when .confluence.json is not found.
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * import { Effect } from "effect"
14
+ * import { ConfigNotFoundError } from "@knpkv/confluence-to-markdown/ConfluenceError"
15
+ *
16
+ * Effect.gen(function* () {
17
+ * // ... operation that needs config
18
+ * }).pipe(
19
+ * Effect.catchTag("ConfigNotFoundError", (error) =>
20
+ * Effect.sync(() => console.error(`Config not found at: ${error.path}`))
21
+ * )
22
+ * )
23
+ * ```
24
+ *
25
+ * @category Errors
26
+ */
27
+ export class ConfigNotFoundError extends Data.TaggedError("ConfigNotFoundError")<{
28
+ readonly path: string
29
+ }> {}
30
+
31
+ /**
32
+ * Error thrown when .confluence.json parsing fails.
33
+ *
34
+ * @category Errors
35
+ */
36
+ export class ConfigParseError extends Data.TaggedError("ConfigParseError")<{
37
+ readonly path: string
38
+ readonly cause: unknown
39
+ }> {}
40
+
41
+ /**
42
+ * Error thrown when configuration validation fails.
43
+ *
44
+ * @category Errors
45
+ */
46
+ export class ConfigError extends Data.TaggedError("ConfigError")<{
47
+ readonly message: string
48
+ }> {}
49
+
50
+ /**
51
+ * Error thrown when authentication is missing.
52
+ *
53
+ * @example
54
+ * ```typescript
55
+ * import { Effect } from "effect"
56
+ * import { AuthMissingError } from "@knpkv/confluence-to-markdown/ConfluenceError"
57
+ *
58
+ * Effect.gen(function* () {
59
+ * // ... operation that needs auth
60
+ * }).pipe(
61
+ * Effect.catchTag("AuthMissingError", () =>
62
+ * Effect.sync(() => console.error("Set CONFLUENCE_API_KEY or run: confluence auth login"))
63
+ * )
64
+ * )
65
+ * ```
66
+ *
67
+ * @category Errors
68
+ */
69
+ export class AuthMissingError extends Data.TaggedError("AuthMissingError")<{
70
+ readonly message: string
71
+ }> {
72
+ constructor() {
73
+ super({ message: "CONFLUENCE_API_KEY env var or OAuth2 credentials required" })
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Error thrown when Confluence API request fails.
79
+ *
80
+ * @category Errors
81
+ */
82
+ export class ApiError extends Data.TaggedError("ApiError")<{
83
+ readonly status: number
84
+ readonly message: string
85
+ readonly endpoint: string
86
+ readonly pageId?: string
87
+ }> {}
88
+
89
+ /**
90
+ * Error thrown when rate limit is exceeded.
91
+ *
92
+ * @category Errors
93
+ */
94
+ export class RateLimitError extends Data.TaggedError("RateLimitError")<{
95
+ readonly retryAfter?: number
96
+ }> {}
97
+
98
+ /**
99
+ * Error thrown when HTML/Markdown conversion fails.
100
+ *
101
+ * @category Errors
102
+ */
103
+ export class ConversionError extends Data.TaggedError("ConversionError")<{
104
+ readonly direction: "htmlToMarkdown" | "markdownToHtml"
105
+ readonly cause: unknown
106
+ }> {}
107
+
108
+ /**
109
+ * Error thrown when sync conflict is detected.
110
+ *
111
+ * @category Errors
112
+ */
113
+ export class ConflictError extends Data.TaggedError("ConflictError")<{
114
+ readonly pageId: string
115
+ readonly localVersion: number
116
+ readonly remoteVersion: number
117
+ readonly path: string
118
+ }> {}
119
+
120
+ /**
121
+ * Error thrown when file system operation fails.
122
+ *
123
+ * @category Errors
124
+ */
125
+ export class FileSystemError extends Data.TaggedError("FileSystemError")<{
126
+ readonly operation: "read" | "write" | "delete" | "mkdir" | "rename"
127
+ readonly path: string
128
+ readonly cause: unknown
129
+ }> {}
130
+
131
+ /**
132
+ * Error thrown when OAuth2 flow fails.
133
+ *
134
+ * @category Errors
135
+ */
136
+ export class OAuthError extends Data.TaggedError("OAuthError")<{
137
+ readonly step: "authorize" | "token" | "refresh"
138
+ readonly cause: unknown
139
+ }> {}
140
+
141
+ /**
142
+ * Error thrown when front-matter parsing fails.
143
+ *
144
+ * @category Errors
145
+ */
146
+ export class FrontMatterError extends Data.TaggedError("FrontMatterError")<{
147
+ readonly path: string
148
+ readonly cause: unknown
149
+ }> {}
150
+
151
+ /**
152
+ * Union of all Confluence errors.
153
+ *
154
+ * @category Errors
155
+ */
156
+ export type ConfluenceError =
157
+ | ConfigNotFoundError
158
+ | ConfigParseError
159
+ | ConfigError
160
+ | AuthMissingError
161
+ | ApiError
162
+ | RateLimitError
163
+ | ConversionError
164
+ | ConflictError
165
+ | FileSystemError
166
+ | OAuthError
167
+ | FrontMatterError
168
+
169
+ /**
170
+ * Type guard to check if error is a ConfluenceError.
171
+ *
172
+ * @param error - The error to check
173
+ * @returns True if error is a ConfluenceError
174
+ *
175
+ * @category Utilities
176
+ */
177
+ export const isConfluenceError = (error: unknown): error is ConfluenceError =>
178
+ typeof error === "object" &&
179
+ error !== null &&
180
+ "_tag" in error &&
181
+ [
182
+ "ConfigNotFoundError",
183
+ "ConfigParseError",
184
+ "ConfigError",
185
+ "AuthMissingError",
186
+ "ApiError",
187
+ "RateLimitError",
188
+ "ConversionError",
189
+ "ConflictError",
190
+ "FileSystemError",
191
+ "OAuthError",
192
+ "FrontMatterError"
193
+ ].includes((error as { _tag: string })._tag)
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Local file system operations for markdown files.
3
+ *
4
+ * @module
5
+ */
6
+ import * as FileSystem from "@effect/platform/FileSystem"
7
+ import * as Path from "@effect/platform/Path"
8
+ import * as Context from "effect/Context"
9
+ import * as Effect from "effect/Effect"
10
+ import * as Layer from "effect/Layer"
11
+ import type { ContentHash } from "./Brand.js"
12
+ import type { FrontMatterError } from "./ConfluenceError.js"
13
+ import { FileSystemError } from "./ConfluenceError.js"
14
+ import type { ParsedMarkdown } from "./internal/frontmatter.js"
15
+ import { parseMarkdown, serializeMarkdown } from "./internal/frontmatter.js"
16
+ import { computeHash } from "./internal/hashUtils.js"
17
+ import { pageToDir, pageToPath } from "./internal/pathUtils.js"
18
+ import type { PageFrontMatter } from "./Schemas.js"
19
+
20
+ /**
21
+ * Local markdown file representation.
22
+ */
23
+ export interface LocalFile {
24
+ readonly path: string
25
+ readonly frontMatter: PageFrontMatter | null
26
+ readonly content: string
27
+ readonly contentHash: ContentHash
28
+ readonly isNew: boolean
29
+ }
30
+
31
+ /**
32
+ * Local file system service for markdown operations.
33
+ *
34
+ * @example
35
+ * ```typescript
36
+ * import { LocalFileSystem } from "@knpkv/confluence-to-markdown/LocalFileSystem"
37
+ * import { Effect } from "effect"
38
+ *
39
+ * const program = Effect.gen(function* () {
40
+ * const fs = yield* LocalFileSystem
41
+ * const files = yield* fs.listMarkdownFiles(".docs/confluence")
42
+ * console.log(files)
43
+ * })
44
+ * ```
45
+ *
46
+ * @category FileSystem
47
+ */
48
+ export class LocalFileSystem extends Context.Tag(
49
+ "@knpkv/confluence-to-markdown/LocalFileSystem"
50
+ )<
51
+ LocalFileSystem,
52
+ {
53
+ /**
54
+ * Read a markdown file with front-matter.
55
+ */
56
+ readonly readMarkdownFile: (filePath: string) => Effect.Effect<LocalFile, FileSystemError | FrontMatterError>
57
+
58
+ /**
59
+ * Write a markdown file with front-matter.
60
+ */
61
+ readonly writeMarkdownFile: (
62
+ filePath: string,
63
+ frontMatter: PageFrontMatter,
64
+ content: string
65
+ ) => Effect.Effect<void, FileSystemError>
66
+
67
+ /**
68
+ * List all markdown files in a directory recursively.
69
+ */
70
+ readonly listMarkdownFiles: (dirPath: string) => Effect.Effect<ReadonlyArray<string>, FileSystemError>
71
+
72
+ /**
73
+ * Ensure a directory exists.
74
+ */
75
+ readonly ensureDir: (dirPath: string) => Effect.Effect<void, FileSystemError>
76
+
77
+ /**
78
+ * Delete a file.
79
+ */
80
+ readonly deleteFile: (filePath: string) => Effect.Effect<void, FileSystemError>
81
+
82
+ /**
83
+ * Check if a file exists.
84
+ */
85
+ readonly exists: (filePath: string) => Effect.Effect<boolean, FileSystemError>
86
+
87
+ /**
88
+ * Get the file path for a page.
89
+ */
90
+ readonly getPagePath: (
91
+ title: string,
92
+ hasChildren: boolean,
93
+ parentPath: string
94
+ ) => string
95
+
96
+ /**
97
+ * Get the directory path for a page's children.
98
+ */
99
+ readonly getPageDir: (title: string, parentPath: string) => string
100
+ }
101
+ >() {}
102
+
103
+ /**
104
+ * Layer that provides LocalFileSystem.
105
+ *
106
+ * @category Layers
107
+ */
108
+ export const layer: Layer.Layer<LocalFileSystem, never, FileSystem.FileSystem | Path.Path> = Layer.effect(
109
+ LocalFileSystem,
110
+ Effect.gen(function*() {
111
+ const fs = yield* FileSystem.FileSystem
112
+ const pathService = yield* Path.Path
113
+
114
+ const readMarkdownFile = (
115
+ filePath: string
116
+ ): Effect.Effect<LocalFile, FileSystemError | FrontMatterError> =>
117
+ Effect.gen(function*() {
118
+ const content = yield* fs.readFileString(filePath).pipe(
119
+ Effect.mapError((cause) => new FileSystemError({ operation: "read", path: filePath, cause }))
120
+ )
121
+
122
+ const parsed: ParsedMarkdown = yield* parseMarkdown(filePath, content)
123
+ const contentHash = computeHash(parsed.content)
124
+
125
+ return {
126
+ path: filePath,
127
+ frontMatter: parsed.frontMatter && "pageId" in parsed.frontMatter
128
+ ? parsed.frontMatter as PageFrontMatter
129
+ : null,
130
+ content: parsed.content,
131
+ contentHash,
132
+ isNew: parsed.isNew
133
+ }
134
+ })
135
+
136
+ const writeMarkdownFile = (
137
+ filePath: string,
138
+ frontMatter: PageFrontMatter,
139
+ content: string
140
+ ): Effect.Effect<void, FileSystemError> =>
141
+ Effect.gen(function*() {
142
+ const dir = pathService.dirname(filePath)
143
+ yield* fs.makeDirectory(dir, { recursive: true }).pipe(
144
+ Effect.catchAll(() => Effect.void)
145
+ )
146
+
147
+ const serialized = serializeMarkdown(frontMatter, content)
148
+
149
+ // Atomic write: write to temp file, then rename
150
+ const tempPath = `${filePath}.tmp.${Date.now()}`
151
+ yield* fs.writeFileString(tempPath, serialized).pipe(
152
+ Effect.mapError((cause) => new FileSystemError({ operation: "write", path: filePath, cause }))
153
+ )
154
+ yield* fs.rename(tempPath, filePath).pipe(
155
+ Effect.mapError((cause) => new FileSystemError({ operation: "rename", path: filePath, cause }))
156
+ )
157
+ })
158
+
159
+ const listMarkdownFiles = (
160
+ dirPath: string
161
+ ): Effect.Effect<ReadonlyArray<string>, FileSystemError> =>
162
+ Effect.gen(function*() {
163
+ const exists = yield* fs.exists(dirPath).pipe(
164
+ Effect.mapError((cause) => new FileSystemError({ operation: "read", path: dirPath, cause }))
165
+ )
166
+
167
+ if (!exists) {
168
+ return []
169
+ }
170
+
171
+ const files: Array<string> = []
172
+
173
+ const walkDir = (dir: string): Effect.Effect<void, FileSystemError> =>
174
+ Effect.gen(function*() {
175
+ const entries = yield* fs.readDirectory(dir).pipe(
176
+ Effect.mapError((cause) => new FileSystemError({ operation: "read", path: dir, cause }))
177
+ )
178
+
179
+ for (const entryName of entries) {
180
+ const fullPath = pathService.join(dir, entryName)
181
+
182
+ const stat = yield* fs.stat(fullPath).pipe(
183
+ Effect.mapError((cause) => new FileSystemError({ operation: "read", path: fullPath, cause }))
184
+ )
185
+
186
+ if (stat.type === "Directory") {
187
+ yield* walkDir(fullPath)
188
+ } else if (stat.type === "File" && entryName.endsWith(".md")) {
189
+ files.push(fullPath)
190
+ }
191
+ }
192
+ })
193
+
194
+ yield* walkDir(dirPath)
195
+ return files
196
+ })
197
+
198
+ const ensureDir = (dirPath: string): Effect.Effect<void, FileSystemError> =>
199
+ fs.makeDirectory(dirPath, { recursive: true }).pipe(
200
+ Effect.mapError((cause) => new FileSystemError({ operation: "mkdir", path: dirPath, cause })),
201
+ Effect.asVoid
202
+ )
203
+
204
+ const deleteFile = (filePath: string): Effect.Effect<void, FileSystemError> =>
205
+ fs.remove(filePath).pipe(
206
+ Effect.mapError((cause) => new FileSystemError({ operation: "delete", path: filePath, cause }))
207
+ )
208
+
209
+ const exists = (filePath: string): Effect.Effect<boolean, FileSystemError> =>
210
+ fs.exists(filePath).pipe(
211
+ Effect.mapError((cause) => new FileSystemError({ operation: "read", path: filePath, cause }))
212
+ )
213
+
214
+ return LocalFileSystem.of({
215
+ readMarkdownFile,
216
+ writeMarkdownFile,
217
+ listMarkdownFiles,
218
+ ensureDir,
219
+ deleteFile,
220
+ exists,
221
+ getPagePath: pageToPath,
222
+ getPageDir: pageToDir
223
+ })
224
+ })
225
+ )
@@ -0,0 +1,187 @@
1
+ /**
2
+ * HTML to Markdown conversion service using unified/remark/rehype.
3
+ *
4
+ * @module
5
+ */
6
+ import * as Context from "effect/Context"
7
+ import * as Effect from "effect/Effect"
8
+ import * as Layer from "effect/Layer"
9
+ import rehypeParse from "rehype-parse"
10
+ import rehypeRemark from "rehype-remark"
11
+ import rehypeStringify from "rehype-stringify"
12
+ import remarkGfm from "remark-gfm"
13
+ import remarkParse from "remark-parse"
14
+ import remarkRehype from "remark-rehype"
15
+ import remarkStringify from "remark-stringify"
16
+ import { unified } from "unified"
17
+ import { ConversionError } from "./ConfluenceError.js"
18
+
19
+ /**
20
+ * Markdown conversion service for HTML <-> GFM conversion.
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * import { MarkdownConverter } from "@knpkv/confluence-to-markdown/MarkdownConverter"
25
+ * import { Effect } from "effect"
26
+ *
27
+ * const program = Effect.gen(function* () {
28
+ * const converter = yield* MarkdownConverter
29
+ * const md = yield* converter.htmlToMarkdown("<h1>Hello</h1><p>World</p>")
30
+ * console.log(md) // # Hello\n\nWorld
31
+ * })
32
+ *
33
+ * Effect.runPromise(
34
+ * program.pipe(Effect.provide(MarkdownConverter.layer))
35
+ * )
36
+ * ```
37
+ *
38
+ * @category Conversion
39
+ */
40
+ export class MarkdownConverter extends Context.Tag(
41
+ "@knpkv/confluence-to-markdown/MarkdownConverter"
42
+ )<
43
+ MarkdownConverter,
44
+ {
45
+ /**
46
+ * Convert Confluence storage format (HTML) to GitHub Flavored Markdown.
47
+ */
48
+ readonly htmlToMarkdown: (html: string) => Effect.Effect<string, ConversionError>
49
+
50
+ /**
51
+ * Convert GitHub Flavored Markdown to HTML (Confluence storage format).
52
+ */
53
+ readonly markdownToHtml: (markdown: string) => Effect.Effect<string, ConversionError>
54
+ }
55
+ >() {}
56
+
57
+ /** Maximum HTML input size (1MB) to prevent ReDoS attacks */
58
+ const MAX_HTML_SIZE = 1024 * 1024
59
+
60
+ /**
61
+ * Strip Confluence-specific macros while preserving text content.
62
+ * Uses iterative approach to avoid ReDoS with nested content.
63
+ */
64
+ const stripConfluenceMacros = (html: string): Effect.Effect<string, ConversionError> =>
65
+ Effect.gen(function*() {
66
+ // Limit input size to prevent ReDoS
67
+ if (html.length > MAX_HTML_SIZE) {
68
+ return yield* Effect.fail(
69
+ new ConversionError({
70
+ direction: "htmlToMarkdown",
71
+ cause: `HTML input too large: ${html.length} bytes (max ${MAX_HTML_SIZE})`
72
+ })
73
+ )
74
+ }
75
+
76
+ let result = html
77
+
78
+ // Process structured macros iteratively to handle nesting safely
79
+ let iterations = 0
80
+ const maxIterations = 100 // Prevent infinite loops
81
+
82
+ while (iterations < maxIterations) {
83
+ const macroStart = result.indexOf("<ac:structured-macro")
84
+ if (macroStart === -1) break
85
+
86
+ // Find matching closing tag by counting nesting
87
+ let depth = 1
88
+ let pos = macroStart + 20 // Skip past opening tag start
89
+ let endPos = -1
90
+
91
+ while (pos < result.length && depth > 0) {
92
+ if (result.slice(pos, pos + 20) === "<ac:structured-macro") {
93
+ depth++
94
+ pos += 20
95
+ } else if (result.slice(pos, pos + 21) === "</ac:structured-macro") {
96
+ depth--
97
+ if (depth === 0) {
98
+ endPos = result.indexOf(">", pos) + 1
99
+ }
100
+ pos += 21
101
+ } else {
102
+ pos++
103
+ }
104
+ }
105
+
106
+ if (endPos === -1) break // Malformed HTML, stop processing
107
+
108
+ const macroContent = result.slice(macroStart, endPos)
109
+
110
+ // Extract content from macro
111
+ let replacement = ""
112
+ const plainBodyStart = macroContent.indexOf("<ac:plain-text-body><![CDATA[")
113
+ const plainBodyEnd = macroContent.indexOf("]]></ac:plain-text-body>")
114
+ if (plainBodyStart !== -1 && plainBodyEnd !== -1) {
115
+ const content = macroContent.slice(plainBodyStart + 29, plainBodyEnd)
116
+ replacement = `<pre><code>${content}</code></pre>`
117
+ } else {
118
+ const richBodyStart = macroContent.indexOf("<ac:rich-text-body>")
119
+ const richBodyEnd = macroContent.indexOf("</ac:rich-text-body>")
120
+ if (richBodyStart !== -1 && richBodyEnd !== -1) {
121
+ replacement = macroContent.slice(richBodyStart + 19, richBodyEnd)
122
+ }
123
+ }
124
+
125
+ result = result.slice(0, macroStart) + replacement + result.slice(endPos)
126
+ iterations++
127
+ }
128
+
129
+ // Remove remaining simple tags with non-greedy bounded patterns
130
+ result = result
131
+ .replace(/<ac:parameter[^>]{0,1000}>[^<]{0,10000}<\/ac:parameter>/gi, "")
132
+ .replace(/<\/?ac:[a-z-]{1,50}[^>]{0,1000}>/gi, "")
133
+ .replace(/<\/?ri:[a-z-]{1,50}[^>]{0,1000}\/?>/gi, "")
134
+
135
+ return result
136
+ })
137
+
138
+ /**
139
+ * Create the markdown converter processor for HTML -> Markdown.
140
+ */
141
+ const createHtmlToMdProcessor = () =>
142
+ unified()
143
+ .use(rehypeParse, { fragment: true })
144
+ .use(rehypeRemark)
145
+ .use(remarkGfm)
146
+ .use(remarkStringify)
147
+
148
+ /**
149
+ * Create the markdown converter processor for Markdown -> HTML.
150
+ */
151
+ const createMdToHtmlProcessor = () =>
152
+ unified()
153
+ .use(remarkParse)
154
+ .use(remarkGfm)
155
+ .use(remarkRehype)
156
+ .use(rehypeStringify)
157
+
158
+ /**
159
+ * Layer that provides the MarkdownConverter service.
160
+ *
161
+ * @category Layers
162
+ */
163
+ export const layer: Layer.Layer<MarkdownConverter> = Layer.succeed(
164
+ MarkdownConverter,
165
+ MarkdownConverter.of({
166
+ htmlToMarkdown: (html) =>
167
+ Effect.gen(function*() {
168
+ const cleaned = yield* stripConfluenceMacros(html)
169
+ return yield* Effect.try({
170
+ try: () => {
171
+ const result = createHtmlToMdProcessor().processSync(cleaned)
172
+ return String(result).trim()
173
+ },
174
+ catch: (cause) => new ConversionError({ direction: "htmlToMarkdown", cause })
175
+ })
176
+ }),
177
+
178
+ markdownToHtml: (markdown) =>
179
+ Effect.try({
180
+ try: () => {
181
+ const result = createMdToHtmlProcessor().processSync(markdown)
182
+ return String(result).trim()
183
+ },
184
+ catch: (cause) => new ConversionError({ direction: "markdownToHtml", cause })
185
+ })
186
+ })
187
+ )