@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.
- package/CHANGELOG.md +12 -0
- package/README.md +62 -0
- package/dist/Brand.d.ts +77 -0
- package/dist/Brand.d.ts.map +1 -0
- package/dist/Brand.js +44 -0
- package/dist/Brand.js.map +1 -0
- package/dist/ConfluenceClient.d.ts +140 -0
- package/dist/ConfluenceClient.d.ts.map +1 -0
- package/dist/ConfluenceClient.js +195 -0
- package/dist/ConfluenceClient.js.map +1 -0
- package/dist/ConfluenceConfig.d.ts +83 -0
- package/dist/ConfluenceConfig.d.ts.map +1 -0
- package/dist/ConfluenceConfig.js +122 -0
- package/dist/ConfluenceConfig.js.map +1 -0
- package/dist/ConfluenceError.d.ts +178 -0
- package/dist/ConfluenceError.d.ts.map +1 -0
- package/dist/ConfluenceError.js +131 -0
- package/dist/ConfluenceError.js.map +1 -0
- package/dist/LocalFileSystem.d.ts +85 -0
- package/dist/LocalFileSystem.d.ts.map +1 -0
- package/dist/LocalFileSystem.js +101 -0
- package/dist/LocalFileSystem.js.map +1 -0
- package/dist/MarkdownConverter.d.ts +50 -0
- package/dist/MarkdownConverter.d.ts.map +1 -0
- package/dist/MarkdownConverter.js +151 -0
- package/dist/MarkdownConverter.js.map +1 -0
- package/dist/Schemas.d.ts +225 -0
- package/dist/Schemas.d.ts.map +1 -0
- package/dist/Schemas.js +164 -0
- package/dist/Schemas.js.map +1 -0
- package/dist/SyncEngine.d.ts +132 -0
- package/dist/SyncEngine.d.ts.map +1 -0
- package/dist/SyncEngine.js +267 -0
- package/dist/SyncEngine.js.map +1 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +163 -0
- package/dist/bin.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/frontmatter.d.ts +38 -0
- package/dist/internal/frontmatter.d.ts.map +1 -0
- package/dist/internal/frontmatter.js +69 -0
- package/dist/internal/frontmatter.js.map +1 -0
- package/dist/internal/hashUtils.d.ts +11 -0
- package/dist/internal/hashUtils.d.ts.map +1 -0
- package/dist/internal/hashUtils.js +17 -0
- package/dist/internal/hashUtils.js.map +1 -0
- package/dist/internal/pathUtils.d.ts +41 -0
- package/dist/internal/pathUtils.d.ts.map +1 -0
- package/dist/internal/pathUtils.js +69 -0
- package/dist/internal/pathUtils.js.map +1 -0
- package/package.json +113 -0
- package/src/Brand.ts +104 -0
- package/src/ConfluenceClient.ts +387 -0
- package/src/ConfluenceConfig.ts +184 -0
- package/src/ConfluenceError.ts +193 -0
- package/src/LocalFileSystem.ts +225 -0
- package/src/MarkdownConverter.ts +187 -0
- package/src/Schemas.ts +235 -0
- package/src/SyncEngine.ts +429 -0
- package/src/bin.ts +269 -0
- package/src/index.ts +35 -0
- package/src/internal/frontmatter.ts +98 -0
- package/src/internal/hashUtils.ts +19 -0
- package/src/internal/pathUtils.ts +77 -0
- package/test/Brand.test.ts +72 -0
- package/test/MarkdownConverter.test.ts +108 -0
- package/test/Schemas.test.ts +99 -0
- package/tsconfig.json +32 -0
- package/vitest.config.integration.ts +12 -0
- 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
|
+
)
|