@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
package/src/bin.ts
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI entry point for confluence-to-markdown.
|
|
4
|
+
*/
|
|
5
|
+
import { Command, Options, Prompt } from "@effect/cli"
|
|
6
|
+
import * as NodeContext from "@effect/platform-node/NodeContext"
|
|
7
|
+
import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"
|
|
8
|
+
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
|
|
9
|
+
import * as NodeTerminal from "@effect/platform-node/NodeTerminal"
|
|
10
|
+
import * as Config from "effect/Config"
|
|
11
|
+
import * as Console from "effect/Console"
|
|
12
|
+
import * as Effect from "effect/Effect"
|
|
13
|
+
import * as Layer from "effect/Layer"
|
|
14
|
+
import * as Option from "effect/Option"
|
|
15
|
+
import pkg from "../package.json" with { type: "json" }
|
|
16
|
+
import type { PageId } from "./Brand.js"
|
|
17
|
+
import { ConfluenceClient, type ConfluenceClientConfig, layer as ConfluenceClientLayer } from "./ConfluenceClient.js"
|
|
18
|
+
import {
|
|
19
|
+
ConfluenceConfig,
|
|
20
|
+
createConfigFile,
|
|
21
|
+
layer as ConfluenceConfigLayer,
|
|
22
|
+
layerFromValues as ConfluenceConfigLayerFromValues
|
|
23
|
+
} from "./ConfluenceConfig.js"
|
|
24
|
+
import { AuthMissingError, ConfigError } from "./ConfluenceError.js"
|
|
25
|
+
import { layer as LocalFileSystemLayer } from "./LocalFileSystem.js"
|
|
26
|
+
import { layer as MarkdownConverterLayer } from "./MarkdownConverter.js"
|
|
27
|
+
import { layer as SyncEngineLayer, SyncEngine } from "./SyncEngine.js"
|
|
28
|
+
|
|
29
|
+
// === Auth config ===
|
|
30
|
+
const AuthConfig = Config.all({
|
|
31
|
+
apiKey: Config.string("CONFLUENCE_API_KEY"),
|
|
32
|
+
email: Config.string("CONFLUENCE_EMAIL")
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const getAuth = (): Effect.Effect<ConfluenceClientConfig["auth"], AuthMissingError> =>
|
|
36
|
+
AuthConfig.pipe(
|
|
37
|
+
Effect.map(({ apiKey, email }) => ({ type: "token" as const, email, token: apiKey })),
|
|
38
|
+
Effect.mapError(() => new AuthMissingError())
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
// === Init command ===
|
|
42
|
+
const rootPageIdOption = Options.text("root-page-id").pipe(
|
|
43
|
+
Options.withDescription("Confluence root page ID to sync from"),
|
|
44
|
+
Options.optional
|
|
45
|
+
)
|
|
46
|
+
const baseUrlOption = Options.text("base-url").pipe(
|
|
47
|
+
Options.withDescription("Confluence Cloud base URL (e.g., https://yoursite.atlassian.net)"),
|
|
48
|
+
Options.optional
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
/** Validate page ID format */
|
|
52
|
+
const validatePageId = (input: string): Effect.Effect<string, ConfigError> =>
|
|
53
|
+
input.trim().length > 0
|
|
54
|
+
? Effect.succeed(input.trim())
|
|
55
|
+
: Effect.fail(new ConfigError({ message: "Page ID cannot be empty" }))
|
|
56
|
+
|
|
57
|
+
/** Validate base URL format */
|
|
58
|
+
const validateBaseUrl = (input: string): Effect.Effect<string, ConfigError> => {
|
|
59
|
+
const pattern = /^https:\/\/[a-z0-9-]+\.atlassian\.net$/
|
|
60
|
+
return pattern.test(input)
|
|
61
|
+
? Effect.succeed(input)
|
|
62
|
+
: Effect.fail(
|
|
63
|
+
new ConfigError({
|
|
64
|
+
message: `Invalid Confluence URL: ${input}. Expected format: https://yoursite.atlassian.net`
|
|
65
|
+
})
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const initCommand = Command.make(
|
|
70
|
+
"init",
|
|
71
|
+
{ rootPageId: rootPageIdOption, baseUrl: baseUrlOption },
|
|
72
|
+
({ baseUrl, rootPageId }) =>
|
|
73
|
+
Effect.gen(function*() {
|
|
74
|
+
const rawPageId = Option.isSome(rootPageId)
|
|
75
|
+
? rootPageId.value
|
|
76
|
+
: yield* Prompt.text({ message: "Enter Confluence root page ID:" })
|
|
77
|
+
const rawUrl = Option.isSome(baseUrl)
|
|
78
|
+
? baseUrl.value
|
|
79
|
+
: yield* Prompt.text({ message: "Enter Confluence base URL (e.g., https://yoursite.atlassian.net):" })
|
|
80
|
+
|
|
81
|
+
const pageId = yield* validatePageId(rawPageId)
|
|
82
|
+
const url = yield* validateBaseUrl(rawUrl)
|
|
83
|
+
|
|
84
|
+
const path = yield* createConfigFile(pageId, url)
|
|
85
|
+
yield* Console.log(`Created configuration file: ${path}`)
|
|
86
|
+
})
|
|
87
|
+
).pipe(Command.withDescription("Initialize .confluence.json configuration"))
|
|
88
|
+
|
|
89
|
+
// === Pull command ===
|
|
90
|
+
const forceOption = Options.boolean("force").pipe(
|
|
91
|
+
Options.withAlias("f"),
|
|
92
|
+
Options.withDescription("Overwrite local changes")
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
const pullCommand = Command.make("pull", { force: forceOption }, ({ force }) =>
|
|
96
|
+
Effect.gen(function*() {
|
|
97
|
+
const engine = yield* SyncEngine
|
|
98
|
+
yield* Console.log("Pulling pages from Confluence...")
|
|
99
|
+
const result = yield* engine.pull({ force })
|
|
100
|
+
yield* Console.log(`Pulled ${result.pulled} pages`)
|
|
101
|
+
if (result.errors.length > 0) {
|
|
102
|
+
yield* Console.error("Errors:", result.errors.join("\n"))
|
|
103
|
+
}
|
|
104
|
+
})).pipe(Command.withDescription("Download pages from Confluence to local markdown"))
|
|
105
|
+
|
|
106
|
+
// === Push command ===
|
|
107
|
+
const dryRunOption = Options.boolean("dry-run").pipe(
|
|
108
|
+
Options.withAlias("n"),
|
|
109
|
+
Options.withDescription("Show changes without applying")
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
const pushCommand = Command.make("push", { dryRun: dryRunOption }, ({ dryRun }) =>
|
|
113
|
+
Effect.gen(function*() {
|
|
114
|
+
const engine = yield* SyncEngine
|
|
115
|
+
yield* Console.log(dryRun ? "Dry run - showing changes..." : "Pushing changes to Confluence...")
|
|
116
|
+
const result = yield* engine.push({ dryRun })
|
|
117
|
+
yield* Console.log(`Pushed: ${result.pushed}, Created: ${result.created}, Skipped: ${result.skipped}`)
|
|
118
|
+
if (result.errors.length > 0) {
|
|
119
|
+
yield* Console.error("Errors:", result.errors.join("\n"))
|
|
120
|
+
}
|
|
121
|
+
})).pipe(Command.withDescription("Upload local markdown changes to Confluence"))
|
|
122
|
+
|
|
123
|
+
// === Sync command ===
|
|
124
|
+
const syncCommand = Command.make("sync", {}, () =>
|
|
125
|
+
Effect.gen(function*() {
|
|
126
|
+
const engine = yield* SyncEngine
|
|
127
|
+
yield* Console.log("Syncing with Confluence...")
|
|
128
|
+
const result = yield* engine.sync()
|
|
129
|
+
yield* Console.log(`Pulled: ${result.pulled}, Pushed: ${result.pushed}, Created: ${result.created}`)
|
|
130
|
+
if (result.conflicts > 0) {
|
|
131
|
+
yield* Console.warn(`Conflicts: ${result.conflicts}`)
|
|
132
|
+
}
|
|
133
|
+
if (result.errors.length > 0) {
|
|
134
|
+
yield* Console.error("Errors:", result.errors.join("\n"))
|
|
135
|
+
}
|
|
136
|
+
})).pipe(Command.withDescription("Bidirectional sync with conflict detection"))
|
|
137
|
+
|
|
138
|
+
// === Status command ===
|
|
139
|
+
const statusCommand = Command.make("status", {}, () =>
|
|
140
|
+
Effect.gen(function*() {
|
|
141
|
+
const engine = yield* SyncEngine
|
|
142
|
+
const result = yield* engine.status()
|
|
143
|
+
yield* Console.log(`
|
|
144
|
+
Sync Status:
|
|
145
|
+
Synced: ${result.synced}
|
|
146
|
+
Local Modified: ${result.localModified}
|
|
147
|
+
Remote Modified: ${result.remoteModified}
|
|
148
|
+
Conflicts: ${result.conflicts}
|
|
149
|
+
Local Only: ${result.localOnly}
|
|
150
|
+
Remote Only: ${result.remoteOnly}
|
|
151
|
+
`)
|
|
152
|
+
if (result.files.length > 0 && result.synced < result.files.length) {
|
|
153
|
+
yield* Console.log("Changed files:")
|
|
154
|
+
for (const file of result.files) {
|
|
155
|
+
if (file._tag !== "Synced" && file._tag !== "RemoteOnly") {
|
|
156
|
+
yield* Console.log(` [${file._tag}] ${file.path}`)
|
|
157
|
+
} else if (file._tag === "RemoteOnly") {
|
|
158
|
+
yield* Console.log(` [${file._tag}] ${file.page.title}`)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
})).pipe(Command.withDescription("Show sync status"))
|
|
163
|
+
|
|
164
|
+
// === Main command ===
|
|
165
|
+
const confluence = Command.make("confluence").pipe(
|
|
166
|
+
Command.withDescription("Sync Confluence pages to local markdown"),
|
|
167
|
+
Command.withSubcommands([initCommand, pullCommand, pushCommand, syncCommand, statusCommand])
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
// === Build app layer ===
|
|
171
|
+
// Dummy config layer for help/init
|
|
172
|
+
const DummyConfigLayer = ConfluenceConfigLayerFromValues({
|
|
173
|
+
rootPageId: "dummy" as PageId,
|
|
174
|
+
baseUrl: "https://dummy.atlassian.net",
|
|
175
|
+
docsPath: ".docs/confluence",
|
|
176
|
+
excludePatterns: []
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// Dummy client layer for help/init (will fail if actually used)
|
|
180
|
+
const DummyConfluenceClientLayer = Layer.succeed(
|
|
181
|
+
ConfluenceClient,
|
|
182
|
+
ConfluenceClient.of({
|
|
183
|
+
getPage: () => Effect.dieMessage("Not configured"),
|
|
184
|
+
getChildren: () => Effect.dieMessage("Not configured"),
|
|
185
|
+
getAllChildren: () => Effect.dieMessage("Not configured"),
|
|
186
|
+
createPage: () => Effect.dieMessage("Not configured"),
|
|
187
|
+
updatePage: () => Effect.dieMessage("Not configured"),
|
|
188
|
+
deletePage: () => Effect.dieMessage("Not configured")
|
|
189
|
+
})
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
// Dummy sync engine that will fail if actually used
|
|
193
|
+
const DummySyncEngineLayer = Layer.succeed(
|
|
194
|
+
SyncEngine,
|
|
195
|
+
SyncEngine.of({
|
|
196
|
+
pull: () => Effect.dieMessage("Not configured - run 'confluence init' first"),
|
|
197
|
+
push: () => Effect.dieMessage("Not configured - run 'confluence init' first"),
|
|
198
|
+
sync: () => Effect.dieMessage("Not configured - run 'confluence init' first"),
|
|
199
|
+
status: () => Effect.dieMessage("Not configured - run 'confluence init' first")
|
|
200
|
+
})
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
// Build client layer dynamically based on auth
|
|
204
|
+
const ConfluenceClientLive = Layer.unwrapEffect(
|
|
205
|
+
Effect.gen(function*() {
|
|
206
|
+
const auth = yield* getAuth()
|
|
207
|
+
const config = yield* ConfluenceConfig
|
|
208
|
+
|
|
209
|
+
const clientConfig: ConfluenceClientConfig = {
|
|
210
|
+
baseUrl: config.baseUrl,
|
|
211
|
+
auth
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return ConfluenceClientLayer(clientConfig)
|
|
215
|
+
})
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
// Full app layer with all services
|
|
219
|
+
const AppLayer = SyncEngineLayer.pipe(
|
|
220
|
+
Layer.provideMerge(ConfluenceClientLive),
|
|
221
|
+
Layer.provideMerge(MarkdownConverterLayer),
|
|
222
|
+
Layer.provideMerge(LocalFileSystemLayer),
|
|
223
|
+
Layer.provideMerge(ConfluenceConfigLayer()),
|
|
224
|
+
Layer.provideMerge(NodeHttpClient.layer),
|
|
225
|
+
Layer.provideMerge(NodeContext.layer)
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
// Minimal layer for init/help (dummy services, won't be invoked)
|
|
229
|
+
const MinimalLayer = DummySyncEngineLayer.pipe(
|
|
230
|
+
Layer.provideMerge(DummyConfluenceClientLayer),
|
|
231
|
+
Layer.provideMerge(MarkdownConverterLayer),
|
|
232
|
+
Layer.provideMerge(LocalFileSystemLayer),
|
|
233
|
+
Layer.provideMerge(DummyConfigLayer),
|
|
234
|
+
Layer.provideMerge(NodeTerminal.layer),
|
|
235
|
+
Layer.provideMerge(NodeContext.layer)
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
// === Run CLI ===
|
|
239
|
+
const cli = Command.run(confluence, {
|
|
240
|
+
name: pkg.name,
|
|
241
|
+
version: pkg.version
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
// Check if we need the full layer based on command
|
|
245
|
+
const needsFullLayer = () => {
|
|
246
|
+
const args = process.argv
|
|
247
|
+
const cmd = args[2]
|
|
248
|
+
// init, --help, -h, --version don't need config
|
|
249
|
+
if (!cmd || cmd === "init" || cmd === "--help" || cmd === "-h" || cmd === "--version") {
|
|
250
|
+
return false
|
|
251
|
+
}
|
|
252
|
+
return true
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const main = Effect.suspend(() => cli(process.argv))
|
|
256
|
+
|
|
257
|
+
if (needsFullLayer()) {
|
|
258
|
+
main.pipe(
|
|
259
|
+
Effect.provide(AppLayer),
|
|
260
|
+
Effect.tapErrorCause(Effect.logError),
|
|
261
|
+
NodeRuntime.runMain
|
|
262
|
+
)
|
|
263
|
+
} else {
|
|
264
|
+
main.pipe(
|
|
265
|
+
Effect.provide(MinimalLayer),
|
|
266
|
+
Effect.tapErrorCause(Effect.logError),
|
|
267
|
+
NodeRuntime.runMain
|
|
268
|
+
)
|
|
269
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @knpkv/confluence-to-markdown
|
|
3
|
+
*
|
|
4
|
+
* Sync Confluence Cloud pages to local GitHub Flavored Markdown files.
|
|
5
|
+
*
|
|
6
|
+
* @packageDocumentation
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export * from "./Brand.js"
|
|
10
|
+
export {
|
|
11
|
+
ConfluenceClient,
|
|
12
|
+
type ConfluenceClientConfig,
|
|
13
|
+
type CreatePageRequest,
|
|
14
|
+
layer as ConfluenceClientLayer,
|
|
15
|
+
type UpdatePageRequest
|
|
16
|
+
} from "./ConfluenceClient.js"
|
|
17
|
+
export {
|
|
18
|
+
ConfluenceConfig,
|
|
19
|
+
createConfigFile,
|
|
20
|
+
layer as ConfluenceConfigLayer,
|
|
21
|
+
layerFromValues as ConfluenceConfigLayerFromValues
|
|
22
|
+
} from "./ConfluenceConfig.js"
|
|
23
|
+
export * from "./ConfluenceError.js"
|
|
24
|
+
export { layer as LocalFileSystemLayer, type LocalFile, LocalFileSystem } from "./LocalFileSystem.js"
|
|
25
|
+
export { layer as MarkdownConverterLayer, MarkdownConverter } from "./MarkdownConverter.js"
|
|
26
|
+
export * from "./Schemas.js"
|
|
27
|
+
export {
|
|
28
|
+
layer as SyncEngineLayer,
|
|
29
|
+
type PullResult,
|
|
30
|
+
type PushResult,
|
|
31
|
+
type StatusResult,
|
|
32
|
+
SyncEngine,
|
|
33
|
+
type SyncResult,
|
|
34
|
+
type SyncStatus
|
|
35
|
+
} from "./SyncEngine.js"
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Front-matter parsing and serialization utilities.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
* @internal
|
|
6
|
+
*/
|
|
7
|
+
import * as Effect from "effect/Effect"
|
|
8
|
+
import * as Schema from "effect/Schema"
|
|
9
|
+
import matter from "gray-matter"
|
|
10
|
+
import { FrontMatterError } from "../ConfluenceError.js"
|
|
11
|
+
import type { NewPageFrontMatter, PageFrontMatter } from "../Schemas.js"
|
|
12
|
+
import { NewPageFrontMatterSchema, PageFrontMatterSchema } from "../Schemas.js"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parsed markdown file with front-matter.
|
|
16
|
+
*/
|
|
17
|
+
export interface ParsedMarkdown {
|
|
18
|
+
readonly frontMatter: PageFrontMatter | NewPageFrontMatter | null
|
|
19
|
+
readonly content: string
|
|
20
|
+
readonly isNew: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse a markdown file with YAML front-matter.
|
|
25
|
+
*
|
|
26
|
+
* @param filePath - Path to the file (for error messages)
|
|
27
|
+
* @param content - The file content
|
|
28
|
+
* @returns Parsed markdown with front-matter and content
|
|
29
|
+
*
|
|
30
|
+
* @internal
|
|
31
|
+
*/
|
|
32
|
+
export const parseMarkdown = (
|
|
33
|
+
filePath: string,
|
|
34
|
+
content: string
|
|
35
|
+
): Effect.Effect<ParsedMarkdown, FrontMatterError> =>
|
|
36
|
+
Effect.gen(function*() {
|
|
37
|
+
const parsed = yield* Effect.try({
|
|
38
|
+
try: () => matter(content),
|
|
39
|
+
catch: (cause) => new FrontMatterError({ path: filePath, cause })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// If no front-matter or empty, treat as new page
|
|
43
|
+
if (!parsed.data || Object.keys(parsed.data).length === 0) {
|
|
44
|
+
return {
|
|
45
|
+
frontMatter: null,
|
|
46
|
+
content: parsed.content.trim(),
|
|
47
|
+
isNew: true
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Try to parse as existing page front-matter
|
|
52
|
+
const existingResult = yield* Schema.decodeUnknown(PageFrontMatterSchema)(parsed.data).pipe(
|
|
53
|
+
Effect.map((fm) => ({
|
|
54
|
+
frontMatter: fm,
|
|
55
|
+
content: parsed.content.trim(),
|
|
56
|
+
isNew: false
|
|
57
|
+
})),
|
|
58
|
+
Effect.catchAll(() =>
|
|
59
|
+
// Try to parse as new page front-matter
|
|
60
|
+
Schema.decodeUnknown(NewPageFrontMatterSchema)(parsed.data).pipe(
|
|
61
|
+
Effect.map((fm) => ({
|
|
62
|
+
frontMatter: fm as NewPageFrontMatter,
|
|
63
|
+
content: parsed.content.trim(),
|
|
64
|
+
isNew: true
|
|
65
|
+
})),
|
|
66
|
+
Effect.catchAll((cause) => Effect.fail(new FrontMatterError({ path: filePath, cause })))
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
return existingResult
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Serialize markdown with YAML front-matter.
|
|
76
|
+
*
|
|
77
|
+
* @param frontMatter - The front-matter data
|
|
78
|
+
* @param content - The markdown content
|
|
79
|
+
* @returns The serialized markdown file content
|
|
80
|
+
*
|
|
81
|
+
* @internal
|
|
82
|
+
*/
|
|
83
|
+
export const serializeMarkdown = (
|
|
84
|
+
frontMatter: PageFrontMatter,
|
|
85
|
+
content: string
|
|
86
|
+
): string => {
|
|
87
|
+
const fm = {
|
|
88
|
+
pageId: frontMatter.pageId,
|
|
89
|
+
version: frontMatter.version,
|
|
90
|
+
title: frontMatter.title,
|
|
91
|
+
updated: frontMatter.updated.toISOString(),
|
|
92
|
+
...(frontMatter.parentId !== undefined ? { parentId: frontMatter.parentId } : {}),
|
|
93
|
+
...(frontMatter.position !== undefined ? { position: frontMatter.position } : {}),
|
|
94
|
+
contentHash: frontMatter.contentHash
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return matter.stringify(content, fm)
|
|
98
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content hashing utilities for change detection.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
* @internal
|
|
6
|
+
*/
|
|
7
|
+
import * as crypto from "node:crypto"
|
|
8
|
+
import type { ContentHash } from "../Brand.js"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Compute SHA256 hash of content.
|
|
12
|
+
*
|
|
13
|
+
* @param content - The content to hash
|
|
14
|
+
* @returns The hex-encoded SHA256 hash
|
|
15
|
+
*
|
|
16
|
+
* @internal
|
|
17
|
+
*/
|
|
18
|
+
export const computeHash = (content: string): ContentHash =>
|
|
19
|
+
crypto.createHash("sha256").update(content, "utf8").digest("hex") as ContentHash
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path utilities for converting page titles to file paths.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
* @internal
|
|
6
|
+
*/
|
|
7
|
+
import * as path from "node:path"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Convert a page title to a URL-safe slug.
|
|
11
|
+
* Prevents path traversal by only allowing alphanumeric characters and hyphens.
|
|
12
|
+
*
|
|
13
|
+
* @param title - The page title
|
|
14
|
+
* @returns A slugified version of the title
|
|
15
|
+
*
|
|
16
|
+
* @internal
|
|
17
|
+
*/
|
|
18
|
+
export const slugify = (title: string): string => {
|
|
19
|
+
const slug = title
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.normalize("NFD")
|
|
22
|
+
.replace(/[\u0300-\u036f]/g, "") // Remove diacritics
|
|
23
|
+
.replace(/[^a-z0-9]+/g, "-") // Replace non-alphanumeric with hyphens (prevents ../ path traversal)
|
|
24
|
+
.replace(/^-+|-+$/g, "") // Trim leading/trailing hyphens
|
|
25
|
+
.substring(0, 100) // Limit length
|
|
26
|
+
|
|
27
|
+
// Ensure we have a valid slug (not empty after sanitization)
|
|
28
|
+
return slug || "untitled"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Convert a page to a local file path.
|
|
33
|
+
*
|
|
34
|
+
* @param title - The page title
|
|
35
|
+
* @param hasChildren - Whether the page has child pages
|
|
36
|
+
* @param parentPath - The parent directory path
|
|
37
|
+
* @returns The local file path for the page
|
|
38
|
+
*
|
|
39
|
+
* @internal
|
|
40
|
+
*/
|
|
41
|
+
export const pageToPath = (
|
|
42
|
+
title: string,
|
|
43
|
+
hasChildren: boolean,
|
|
44
|
+
parentPath: string
|
|
45
|
+
): string => {
|
|
46
|
+
const slug = slugify(title)
|
|
47
|
+
return hasChildren
|
|
48
|
+
? path.join(parentPath, slug, "index.md")
|
|
49
|
+
: path.join(parentPath, `${slug}.md`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get the directory path for a page (used when creating children).
|
|
54
|
+
*
|
|
55
|
+
* @param title - The page title
|
|
56
|
+
* @param parentPath - The parent directory path
|
|
57
|
+
* @returns The directory path for the page's children
|
|
58
|
+
*
|
|
59
|
+
* @internal
|
|
60
|
+
*/
|
|
61
|
+
export const pageToDir = (title: string, parentPath: string): string => {
|
|
62
|
+
const slug = slugify(title)
|
|
63
|
+
return path.join(parentPath, slug)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Extract page slug from a file path.
|
|
68
|
+
*
|
|
69
|
+
* @param filePath - The file path
|
|
70
|
+
* @returns The page slug
|
|
71
|
+
*
|
|
72
|
+
* @internal
|
|
73
|
+
*/
|
|
74
|
+
export const pathToSlug = (filePath: string): string => {
|
|
75
|
+
const basename = path.basename(filePath, ".md")
|
|
76
|
+
return basename === "index" ? path.basename(path.dirname(filePath)) : basename
|
|
77
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest"
|
|
2
|
+
import * as Either from "effect/Either"
|
|
3
|
+
import * as Schema from "effect/Schema"
|
|
4
|
+
import { ContentHash, PageId, PageIdSchema, SpaceKey, SpaceKeySchema } from "../src/Brand.js"
|
|
5
|
+
|
|
6
|
+
describe("Brand", () => {
|
|
7
|
+
describe("PageId", () => {
|
|
8
|
+
it("accepts valid page IDs", () => {
|
|
9
|
+
expect(PageId("123456")).toBe("123456")
|
|
10
|
+
expect(PageId("abc-def")).toBe("abc-def")
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it("rejects empty strings", () => {
|
|
14
|
+
expect(() => PageId("")).toThrow()
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe("PageIdSchema", () => {
|
|
19
|
+
it("decodes valid page IDs", () => {
|
|
20
|
+
const result = Schema.decodeEither(PageIdSchema)("123456")
|
|
21
|
+
expect(Either.isRight(result)).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("rejects empty strings", () => {
|
|
25
|
+
const result = Schema.decodeEither(PageIdSchema)("")
|
|
26
|
+
expect(Either.isLeft(result)).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe("SpaceKey", () => {
|
|
31
|
+
it("accepts valid space keys", () => {
|
|
32
|
+
expect(SpaceKey("MYSPACE")).toBe("MYSPACE")
|
|
33
|
+
expect(SpaceKey("DEV")).toBe("DEV")
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("rejects empty strings", () => {
|
|
37
|
+
expect(() => SpaceKey("")).toThrow()
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe("SpaceKeySchema", () => {
|
|
42
|
+
it("decodes uppercase alphanumeric keys", () => {
|
|
43
|
+
const result = Schema.decodeEither(SpaceKeySchema)("MYSPACE123")
|
|
44
|
+
expect(Either.isRight(result)).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("rejects lowercase keys", () => {
|
|
48
|
+
const result = Schema.decodeEither(SpaceKeySchema)("myspace")
|
|
49
|
+
expect(Either.isLeft(result)).toBe(true)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("rejects keys with special characters", () => {
|
|
53
|
+
const result = Schema.decodeEither(SpaceKeySchema)("MY-SPACE")
|
|
54
|
+
expect(Either.isLeft(result)).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe("ContentHash", () => {
|
|
59
|
+
it("accepts valid 64-char hex hashes", () => {
|
|
60
|
+
const validHash = "a".repeat(64)
|
|
61
|
+
expect(ContentHash(validHash)).toBe(validHash)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("rejects short hashes", () => {
|
|
65
|
+
expect(() => ContentHash("abc123")).toThrow()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("rejects empty strings", () => {
|
|
69
|
+
expect(() => ContentHash("")).toThrow()
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest"
|
|
2
|
+
import * as Effect from "effect/Effect"
|
|
3
|
+
import { layer as MarkdownConverterLayer, MarkdownConverter } from "../src/MarkdownConverter.js"
|
|
4
|
+
|
|
5
|
+
describe("MarkdownConverter", () => {
|
|
6
|
+
describe("htmlToMarkdown", () => {
|
|
7
|
+
it.effect("converts basic HTML to markdown", () =>
|
|
8
|
+
Effect.gen(function*() {
|
|
9
|
+
const converter = yield* MarkdownConverter
|
|
10
|
+
const html = "<p>Hello <strong>world</strong></p>"
|
|
11
|
+
const markdown = yield* converter.htmlToMarkdown(html)
|
|
12
|
+
expect(markdown).toContain("Hello")
|
|
13
|
+
expect(markdown).toContain("**world**")
|
|
14
|
+
}).pipe(Effect.provide(MarkdownConverterLayer)))
|
|
15
|
+
|
|
16
|
+
it.effect("converts headings", () =>
|
|
17
|
+
Effect.gen(function*() {
|
|
18
|
+
const converter = yield* MarkdownConverter
|
|
19
|
+
const html = "<h1>Title</h1><h2>Subtitle</h2>"
|
|
20
|
+
const markdown = yield* converter.htmlToMarkdown(html)
|
|
21
|
+
expect(markdown).toContain("# Title")
|
|
22
|
+
expect(markdown).toContain("## Subtitle")
|
|
23
|
+
}).pipe(Effect.provide(MarkdownConverterLayer)))
|
|
24
|
+
|
|
25
|
+
it.effect("converts lists", () =>
|
|
26
|
+
Effect.gen(function*() {
|
|
27
|
+
const converter = yield* MarkdownConverter
|
|
28
|
+
const html = "<ul><li>Item 1</li><li>Item 2</li></ul>"
|
|
29
|
+
const markdown = yield* converter.htmlToMarkdown(html)
|
|
30
|
+
expect(markdown).toContain("* Item 1")
|
|
31
|
+
expect(markdown).toContain("* Item 2")
|
|
32
|
+
}).pipe(Effect.provide(MarkdownConverterLayer)))
|
|
33
|
+
|
|
34
|
+
it.effect("converts links", () =>
|
|
35
|
+
Effect.gen(function*() {
|
|
36
|
+
const converter = yield* MarkdownConverter
|
|
37
|
+
const html = "<a href=\"https://example.com\">Link</a>"
|
|
38
|
+
const markdown = yield* converter.htmlToMarkdown(html)
|
|
39
|
+
expect(markdown).toContain("[Link](https://example.com)")
|
|
40
|
+
}).pipe(Effect.provide(MarkdownConverterLayer)))
|
|
41
|
+
|
|
42
|
+
it.effect("converts code blocks", () =>
|
|
43
|
+
Effect.gen(function*() {
|
|
44
|
+
const converter = yield* MarkdownConverter
|
|
45
|
+
const html = "<pre><code>const x = 1;</code></pre>"
|
|
46
|
+
const markdown = yield* converter.htmlToMarkdown(html)
|
|
47
|
+
expect(markdown).toContain("```")
|
|
48
|
+
expect(markdown).toContain("const x = 1;")
|
|
49
|
+
}).pipe(Effect.provide(MarkdownConverterLayer)))
|
|
50
|
+
|
|
51
|
+
it.effect("strips Confluence macros with rich-text-body", () =>
|
|
52
|
+
Effect.gen(function*() {
|
|
53
|
+
const converter = yield* MarkdownConverter
|
|
54
|
+
const html =
|
|
55
|
+
"<ac:structured-macro ac:name=\"info\"><ac:rich-text-body><p>Content</p></ac:rich-text-body></ac:structured-macro>"
|
|
56
|
+
const markdown = yield* converter.htmlToMarkdown(html)
|
|
57
|
+
expect(markdown).toContain("Content")
|
|
58
|
+
expect(markdown).not.toContain("ac:structured-macro")
|
|
59
|
+
}).pipe(Effect.provide(MarkdownConverterLayer)))
|
|
60
|
+
|
|
61
|
+
it.effect("converts Confluence code macros to code blocks", () =>
|
|
62
|
+
Effect.gen(function*() {
|
|
63
|
+
const converter = yield* MarkdownConverter
|
|
64
|
+
const html =
|
|
65
|
+
"<ac:structured-macro ac:name=\"code\"><ac:plain-text-body><![CDATA[const x = 1;]]></ac:plain-text-body></ac:structured-macro>"
|
|
66
|
+
const markdown = yield* converter.htmlToMarkdown(html)
|
|
67
|
+
expect(markdown).toContain("const x = 1;")
|
|
68
|
+
expect(markdown).toContain("```")
|
|
69
|
+
}).pipe(Effect.provide(MarkdownConverterLayer)))
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe("markdownToHtml", () => {
|
|
73
|
+
it.effect("converts basic markdown to HTML", () =>
|
|
74
|
+
Effect.gen(function*() {
|
|
75
|
+
const converter = yield* MarkdownConverter
|
|
76
|
+
const markdown = "Hello **world**"
|
|
77
|
+
const html = yield* converter.markdownToHtml(markdown)
|
|
78
|
+
expect(html).toContain("<strong>world</strong>")
|
|
79
|
+
}).pipe(Effect.provide(MarkdownConverterLayer)))
|
|
80
|
+
|
|
81
|
+
it.effect("converts headings", () =>
|
|
82
|
+
Effect.gen(function*() {
|
|
83
|
+
const converter = yield* MarkdownConverter
|
|
84
|
+
const markdown = "# Title\n\n## Subtitle"
|
|
85
|
+
const html = yield* converter.markdownToHtml(markdown)
|
|
86
|
+
expect(html).toContain("<h1>Title</h1>")
|
|
87
|
+
expect(html).toContain("<h2>Subtitle</h2>")
|
|
88
|
+
}).pipe(Effect.provide(MarkdownConverterLayer)))
|
|
89
|
+
|
|
90
|
+
it.effect("converts GFM tables", () =>
|
|
91
|
+
Effect.gen(function*() {
|
|
92
|
+
const converter = yield* MarkdownConverter
|
|
93
|
+
const markdown = "| A | B |\n|---|---|\n| 1 | 2 |"
|
|
94
|
+
const html = yield* converter.markdownToHtml(markdown)
|
|
95
|
+
expect(html).toContain("<table>")
|
|
96
|
+
expect(html).toContain("<th>A</th>")
|
|
97
|
+
expect(html).toContain("<td>1</td>")
|
|
98
|
+
}).pipe(Effect.provide(MarkdownConverterLayer)))
|
|
99
|
+
|
|
100
|
+
it.effect("converts task lists", () =>
|
|
101
|
+
Effect.gen(function*() {
|
|
102
|
+
const converter = yield* MarkdownConverter
|
|
103
|
+
const markdown = "- [ ] Todo\n- [x] Done"
|
|
104
|
+
const html = yield* converter.markdownToHtml(markdown)
|
|
105
|
+
expect(html).toContain("checkbox")
|
|
106
|
+
}).pipe(Effect.provide(MarkdownConverterLayer)))
|
|
107
|
+
})
|
|
108
|
+
})
|