@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
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
+ })