Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 39 additions & 14 deletions src/elements/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,51 @@ import type { Image } from 'mdast'
import { registerConverter } from '../registry.js'
import { escapeAttr } from '../utils.js'

registerConverter<Image>('image', (node, _context) => {
registerConverter<Image>('image', (node, context) => {
const url = node.url
const alt = node.alt || ''
const title = node.title
const { alt, width, height } = parseAltSize(node.alt || '')

// Check if it's a remote URL or local file
const isRemote = url.startsWith('http://') || url.startsWith('https://')
const altAttr = alt ? ` ac:alt="${escapeAttr(alt)}"` : ''
const titleAttr = title ? ` ac:title="${escapeAttr(title)}"` : ''
// width/height come from \d+ matches, so they need no escaping.
const widthAttr = width ? ` ac:width="${width}"` : ''
const heightAttr = height ? ` ac:height="${height}"` : ''
const attrs = `${altAttr}${titleAttr}${widthAttr}${heightAttr}`

const isRemote = url.startsWith('http://') || url.startsWith('https://')
if (isRemote) {
// External image
const titleAttr = title ? ` ac:title="${escapeAttr(title)}"` : ''
const altAttr = alt ? ` ac:alt="${escapeAttr(alt)}"` : ''
return `<ac:image${altAttr}${titleAttr}><ri:url ri:value="${escapeAttr(url)}"/></ac:image>`
return `<ac:image${attrs}><ri:url ri:value="${escapeAttr(url)}"/></ac:image>`
}

// Local file - will be uploaded as attachment
// The filename is the last part of the path
const filename = url.split('/').pop() || url
const titleAttr = title ? ` ac:title="${escapeAttr(title)}"` : ''
const altAttr = alt ? ` ac:alt="${escapeAttr(alt)}"` : ''
// data: URIs can't be uploaded as attachments and Confluence won't render them inline.
// Drop them with a warning rather than emit a bogus <ri:attachment> with the whole URI as a filename.
if (url.startsWith('data:')) {
console.warn('Skipping unsupported data: URI image')
return ''
}

return `<ac:image${altAttr}${titleAttr}><ri:attachment ri:filename="${escapeAttr(filename)}"/></ac:image>`
// Local file: preprocessImages records the uploaded (content-hashed) name keyed by url.
// Fall back to the basename when it wasn't uploaded (missing file, or converter run without preprocess).
const filename = context.localImages.get(url) ?? (url.split('/').pop() || url)
return `<ac:image${attrs}><ri:attachment ri:filename="${escapeAttr(filename)}"/></ac:image>`
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

interface AltSize {
alt: string
width?: string
height?: string
}

// Obsidian-style sizing lives after the last `|` in the alt text: `alt|300` or `alt|300x200`.
// A segment that isn't `<digits>` or `<digits>x<digits>` is left verbatim in the alt text.
function parseAltSize(alt: string): AltSize {
const idx = alt.lastIndexOf('|')
if (idx === -1) return { alt }

const sizePart = alt.slice(idx + 1).trim()
const m = /^(\d+)(?:x(\d+))?$/.exec(sizePart)
if (!m) return { alt }

return { alt: alt.slice(0, idx).trim(), width: m[1], height: m[2] }
}
68 changes: 68 additions & 0 deletions src/images/preprocess.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { basename, extname, resolve } from 'node:path'
import type { Image, Node, Parent } from 'mdast'
import type { ConversionContext } from '../types.js'

const CONTENT_TYPES: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
}

function contentTypeFor(ext: string): string | undefined {
return CONTENT_TYPES[ext.toLowerCase()]
}

// A url is a local-file candidate unless it points at a remote or inline resource.
function isLocalCandidate(url: string): boolean {
return !url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('data:')
}

function collectImages(node: Node, images: Image[]): void {
if (node.type === 'image') {
images.push(node as Image)
}

if ('children' in node && Array.isArray((node as Parent).children)) {
for (const child of (node as Parent).children) {
collectImages(child, images)
}
}
}

export async function preprocessImages(ast: Node, context: ConversionContext, baseDir: string): Promise<void> {
const images: Image[] = []
collectImages(ast, images)

for (const image of images) {
const url = image.url
if (!isLocalCandidate(url)) continue
// Dedup by reference: the same url only needs reading once.
if (context.localImages.has(url)) continue

const ext = extname(url)
const contentType = contentTypeFor(ext)
if (!contentType) {
console.warn(`Skipping image with unsupported extension: ${url}`)
continue
}

let data: Buffer
try {
data = await readFile(resolve(baseDir, url))
} catch (error) {
console.warn(`Failed to read local image ${url}: ${error}`)
continue
}

const hash = createHash('md5').update(data).digest('hex').slice(0, 12)
const filename = `${basename(url, ext)}-${hash}${ext}`

context.attachments.set(filename, { filename, data, contentType })
context.localImages.set(url, filename)
}
}
7 changes: 6 additions & 1 deletion src/sync.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { createHash } from 'node:crypto'
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
import { extname, resolve } from 'node:path'
import { dirname, extname, resolve } from 'node:path'
import { ConfluenceApiError, ConfluenceClient } from './confluence/client.js'
import type { Page } from './confluence/types.js'
import { convert } from './converter.js'
import { preprocessImages } from './images/preprocess.js'
import { closeBrowser, preprocessMermaid } from './mermaid/index.js'
import { parse } from './parser.js'
import type { AttachmentInfo, Config, ConversionContext } from './types.js'
Expand Down Expand Up @@ -54,12 +55,16 @@ async function syncFile(file: string, config: Config, client: ConfluenceClient):
config,
frontmatter,
attachments: new Map(),
localImages: new Map(),
pageId: frontmatter['confluence-page-id'],
}

// Pre-render mermaid diagrams (populates context.attachments)
await preprocessMermaid(ast, context)

// Upload local image files referenced by the markdown (populates context.attachments + localImages)
await preprocessImages(ast, context, dirname(absolutePath))

const content = convert(ast, context)
const contentHash = createHash('md5').update(content).digest('hex')

Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface ConversionContext {
config: Config
frontmatter: Frontmatter
attachments: Map<string, AttachmentInfo>
localImages: Map<string, string>
pageId?: string
}

Expand Down
46 changes: 45 additions & 1 deletion test/converter.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { convert } from '../src/converter.js'
import { getMermaidFilename } from '../src/mermaid/render.js'
import { parse } from '../src/parser.js'
Expand All @@ -15,6 +15,7 @@ function createContext(): ConversionContext {
},
frontmatter: {},
attachments: new Map(),
localImages: new Map(),
}
}

Expand Down Expand Up @@ -124,6 +125,49 @@ describe('Images', () => {
const result = md2confluence('![alt](./image.png)')
expect(result).toContain('<ri:attachment ri:filename="image.png"/>')
})

it('parses Obsidian-style width on remote images', () => {
const result = md2confluence('![a|300](https://x/p.png)')
expect(result).toContain('ac:width="300"')
expect(result).toContain('<ri:url ri:value="https://x/p.png"/>')
expect(result).toContain('ac:alt="a"')
expect(result).not.toContain('ac:height')
})

it('parses Obsidian-style width and height', () => {
const result = md2confluence('![a|300x200](https://x/p.png)')
expect(result).toContain('ac:width="300"')
expect(result).toContain('ac:height="200"')
})

it('leaves non-numeric pipe segments in alt text', () => {
const result = md2confluence('![a|b](https://x/p.png)')
expect(result).not.toContain('ac:width')
expect(result).toContain('ac:alt="a|b"')
})

it('omits empty alt when only a width is given', () => {
const result = md2confluence('![|300](https://x/p.png)')
expect(result).toContain('ac:width="300"')
expect(result).not.toContain('ac:alt')
})

it('applies width to local images via the basename fallback', () => {
const result = md2confluence('![a|300](./pic.png)')
expect(result).toContain('ac:width="300"')
expect(result).toContain('<ri:attachment ri:filename="pic.png"/>')
})

it('drops data: URI images instead of emitting a bogus attachment', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const result = md2confluence('![x](data:image/png;base64,iVBORw0KGgo=)')
expect(result).toBe('<p></p>')
expect(result).not.toContain('ri:attachment')
} finally {
warn.mockRestore()
}
})
})

describe('Blockquotes', () => {
Expand Down
121 changes: 121 additions & 0 deletions test/images.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { createHash } from 'node:crypto'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { convert } from '../src/converter.js'
import { preprocessImages } from '../src/images/preprocess.js'
import { parse } from '../src/parser.js'
import type { ConversionContext } from '../src/types.js'

function createContext(): ConversionContext {
return {
config: {
domain: 'test.atlassian.net',
space: 'TEST',
auth: { email: 'test@example.com', token: 'xxx' },
},
frontmatter: {},
attachments: new Map(),
localImages: new Map(),
}
}

describe('preprocessImages', () => {
it('uploads a local image under a content-hashed name and emits it', async () => {
const dir = mkdtempSync(join(tmpdir(), 'markfluence-img-'))
try {
const bytes = Buffer.from('not-a-real-png-but-fine-for-hashing')
writeFileSync(join(dir, 'img.png'), bytes)
const hash = createHash('md5').update(bytes).digest('hex').slice(0, 12)
const expectedName = `img-${hash}.png`

const { ast } = parse('![diagram](./img.png)')
const context = createContext()
await preprocessImages(ast, context, dir)

expect(context.attachments.has(expectedName)).toBe(true)
const attachment = context.attachments.get(expectedName)
expect(attachment?.contentType).toBe('image/png')
expect(attachment?.data).toEqual(bytes)
expect(context.localImages.get('./img.png')).toBe(expectedName)

const xml = convert(ast, context)
expect(xml).toContain(`<ri:attachment ri:filename="${expectedName}"/>`)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})

it('warns and skips a missing local image without throwing', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const { ast } = parse('![missing](./nope.png)')
const context = createContext()

await expect(preprocessImages(ast, context, tmpdir())).resolves.toBeUndefined()
expect(context.attachments.size).toBe(0)
expect(context.localImages.size).toBe(0)
expect(warn).toHaveBeenCalled()

// Converter falls back to the basename for the un-uploaded file.
expect(convert(ast, context)).toContain('<ri:attachment ri:filename="nope.png"/>')
} finally {
warn.mockRestore()
}
})

it('skips unsupported extensions and leaves remote images alone', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const dir = mkdtempSync(join(tmpdir(), 'markfluence-img-'))
try {
writeFileSync(join(dir, 'notes.txt'), 'text')

const { ast } = parse('![doc](./notes.txt)\n\n![remote](https://x/p.png)')
const context = createContext()
await preprocessImages(ast, context, dir)

expect(context.attachments.size).toBe(0)
expect(context.localImages.size).toBe(0)
// Only the unsupported local file warns; the remote URL is left untouched.
expect(warn).toHaveBeenCalledTimes(1)
} finally {
warn.mockRestore()
rmSync(dir, { recursive: true, force: true })
}
})

it('skips data: URIs without reading or warning', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const { ast } = parse('![inline](data:image/png;base64,iVBORw0KGgo=)')
const context = createContext()
await preprocessImages(ast, context, tmpdir())

expect(context.attachments.size).toBe(0)
expect(context.localImages.size).toBe(0)
expect(warn).not.toHaveBeenCalled()
} finally {
warn.mockRestore()
}
})

it('reads a repeated local image only once', async () => {
const dir = mkdtempSync(join(tmpdir(), 'markfluence-img-'))
try {
const bytes = Buffer.from('dedup-me')
writeFileSync(join(dir, 'dup.png'), bytes)
const hash = createHash('md5').update(bytes).digest('hex').slice(0, 12)

const { ast } = parse('![one](./dup.png)\n\n![two](./dup.png)')
const context = createContext()
await preprocessImages(ast, context, dir)

expect(context.attachments.size).toBe(1)
expect(context.localImages.size).toBe(1)
expect(context.localImages.get('./dup.png')).toBe(`dup-${hash}.png`)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})
1 change: 1 addition & 0 deletions test/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ describe('syncFiles', () => {
config: mockConfig,
frontmatter: {},
attachments: new Map(),
localImages: new Map(),
}
const expectedContent = convert(ast, context)

Expand Down