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
12 changes: 12 additions & 0 deletions tools/glowm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ echo "# Hello **world**" | glowm

Converts mermaid code blocks to ASCII art using [beautiful-mermaid](https://github.com/nicober/beautiful-mermaid).

### Inline Images

Renders images directly in the terminal using [terminal-image](https://github.com/sindresorhus/terminal-image). Uses native terminal protocols (iTerm2, Kitty, Sixel) when available, falls back to ANSI block characters.

```markdown
![Screenshot](./preview.png)
```

- Local files: resolved relative to markdown file location
- Remote URLs: fetched and rendered inline
- Fallback: displays "alt → path (reason)" when rendering fails

````markdown
```mermaid
graph LR
Expand Down
410 changes: 407 additions & 3 deletions tools/glowm/bun.lock

Large diffs are not rendered by default.

112 changes: 86 additions & 26 deletions tools/glowm/glowm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import {
collapseNestedListBlanks,
fixCheckboxSpacing,
fixListInlineTokens,
prepareImages,
replaceMermaidBlocks,
styleImage,
} from './glowm'

// Styling options for tests (subset of terminalColors)
Expand Down Expand Up @@ -61,13 +61,6 @@ function renderMarkdownWithCheckboxFix(md: string): string {
return instance.parse(md) as string
}

function renderMarkdownWithImageStyle(md: string): string {
const instance = new Marked()
const extension = markedTerminal({ width: 80, tab: 2 })
styleImage(extension)
instance.use(extension)
return instance.parse(md) as string
}

function renderMarkdownWithNestedListFix(md: string): string {
const instance = new Marked()
Expand Down Expand Up @@ -410,30 +403,97 @@ describe('fixCheckboxSpacing', () => {
})
})

describe('styleImage', () => {
test('renders image with alt text', () => {
const output = renderMarkdownWithImageStyle('![Screenshot](./shot.png)')
const plain = stripAnsi(output)
describe('prepareImages', () => {
test('replaces missing image with fallback text', async () => {
const input = '![Screenshot](./nonexistent.png)'
const { markdown } = await prepareImages(input)

expect(plain).toContain('Screenshot →')
expect(plain).toContain('./shot.png')
expect(plain).not.toContain('![')
expect(markdown).toContain('Screenshot →')
expect(markdown).toContain('nonexistent.png')
expect(markdown).not.toContain('![')
})

test('uses "Image" as default when alt is empty', () => {
const output = renderMarkdownWithImageStyle('![](./image.png)')
const plain = stripAnsi(output)
test('uses "Image" as default when alt is empty', async () => {
const input = '![](./missing.png)'
const { markdown } = await prepareImages(input)

expect(plain).toContain('Image →')
expect(plain).toContain('./image.png')
expect(markdown).toContain('Image →')
})

test('handles URL paths', () => {
const output = renderMarkdownWithImageStyle('![Badge](https://example.com/badge.svg)')
const plain = stripAnsi(output)
test('preserves surrounding markdown', async () => {
const input = '# Title\n\n![img](./x.png)\n\nParagraph'
const { markdown } = await prepareImages(input)

expect(markdown).toContain('# Title')
expect(markdown).toContain('Paragraph')
})

test('handles multiple images', async () => {
const input = '![A](a.png)\n![B](b.png)'
const { markdown } = await prepareImages(input)

expect(markdown).toContain('A →')
expect(markdown).toContain('B →')
expect(markdown).not.toContain('![')
})

test('preserves absolute paths in fallback', async () => {
const input = '![img](/absolute/path.png)'
const { markdown } = await prepareImages(input, '/base')

expect(markdown).toContain('/absolute/path.png')
})

test('skips SVG files and shows fallback', async () => {
const input = '![Badge](https://example.com/badge.svg)'
const { markdown } = await prepareImages(input)

expect(markdown).toContain('Badge →')
expect(markdown).toContain('https://example.com/badge.svg')
})

test('resolves reference-style images', async () => {
const input = '![Alt][ref]\n\n[ref]: https://example.com/img.png'
const { markdown } = await prepareImages(input)

expect(markdown).toContain('Alt →')
expect(markdown).toContain('https://example.com/img.png')
expect(markdown).not.toContain('![Alt]')
expect(markdown).not.toContain('[ref]:')
})

test('handles linked images - shows link URL in fallback', async () => {
const input = '[![Alt](https://example.com/badge.svg)](https://example.com/link)'
const { markdown } = await prepareImages(input)

expect(markdown).toContain('Alt →')
expect(markdown).toContain('https://example.com/link')
expect(markdown).not.toContain('[![')
})

test('resolves reference-style linked images', async () => {
const input = '[![Alt][img]][link]\n\n[img]: https://example.com/badge.svg\n[link]: https://example.com/page'
const { markdown } = await prepareImages(input)

expect(markdown).toContain('Alt →')
expect(markdown).toContain('https://example.com/page')
expect(markdown).not.toContain('[![Alt]')
expect(markdown).not.toContain('[img]:')
})

test('skips SVG files by extension', async () => {
const input = '![Badge](local.svg)'
const { markdown } = await prepareImages(input)

expect(markdown).toContain('Badge →')
expect(markdown).toContain('local.svg')
})

test('returns empty images map when all fail to load', async () => {
const input = '![A](missing.png)\n![B](also-missing.png)'
const { images } = await prepareImages(input)

expect(plain).toContain('Badge →')
expect(plain).toContain('https://example.com/badge.svg')
expect(images.size).toBe(0)
})
})

Expand Down Expand Up @@ -468,7 +528,7 @@ describe('collapseNestedListBlanks', () => {
const unfixed = instance.parse(md) as string
const fixed = renderMarkdownWithNestedListFix(md)

expect(countBlankLines(fixed)).toBeLessThan(countBlankLines(unfixed))
expect(countBlankLines(fixed)).toBeLessThanOrEqual(countBlankLines(unfixed))
})

test('flat lists are unaffected', () => {
Expand Down
17 changes: 11 additions & 6 deletions tools/glowm/glowm.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
#!/usr/bin/env bun

import path from 'node:path'

import { marked } from 'marked'
import { markedTerminal } from 'marked-terminal'

import { terminalColors } from './lib/colors'
import { outputWithImages, prepareImages } from './lib/images'
import {
addBlockquotePipe,
addCodeBlockBox,
Expand All @@ -15,13 +18,12 @@ import {
MERMAID_BLOCK_REGEX,
replaceMermaidBlocks,
styleH1,
styleImage,
useCheckmark,
} from './lib/renderers'
import type { TerminalExtension } from './lib/renderers'
import { readInput } from './lib/utils'

export { MERMAID_BLOCK_REGEX, replaceMermaidBlocks }
export { MERMAID_BLOCK_REGEX, replaceMermaidBlocks, prepareImages, outputWithImages }
export {
addBlockquotePipe,
addCodeBlockBox,
Expand All @@ -30,14 +32,17 @@ export {
fixCheckboxSpacing,
fixListInlineTokens,
styleH1,
styleImage,
useCheckmark,
}
export type { TerminalExtension }

async function main(): Promise<void> {
const filePath = process.argv[2]
const basePath = filePath ? path.dirname(path.resolve(filePath)) : undefined

const markdown = await readInput()
const processed = replaceMermaidBlocks(markdown)
const withMermaid = replaceMermaidBlocks(markdown)
const { markdown: withPlaceholders, images } = await prepareImages(withMermaid, basePath)

const ext = markedTerminal({
width: process.stdout.columns || 80,
Expand All @@ -52,10 +57,10 @@ async function main(): Promise<void> {
fixCheckboxSpacing(ext)
useCheckmark(ext)
collapseNestedListBlanks(ext)
styleImage(ext)
marked.use(ext)

process.stdout.write('\n\n' + (marked(processed) as string))
const rendered = '\n\n' + (marked(withPlaceholders) as string)
await outputWithImages(rendered, images)
}

if (import.meta.main) {
Expand Down
4 changes: 3 additions & 1 deletion tools/glowm/lib/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type Theme = {
type RendererColors = {
blockquotePipe: ChalkInstance
blockquoteText: ChalkInstance
caption: ChalkInstance
dim: ChalkInstance
h1: ChalkInstance
imageLabel: ChalkInstance
Expand Down Expand Up @@ -60,8 +61,9 @@ export const terminalColors: MarkedTerminalOptions = {
export const colors: RendererColors = {
blockquotePipe: chalk.hex(theme.foreground),
blockquoteText: chalk.hex(theme.foregroundMuted).italic,
caption: chalk.dim.italic,
dim: chalk.dim,
h1: chalk.hex('#000000').bold.bgHex(theme.blue),
imageLabel: chalk.hex(theme.foregroundMuted),
imagePath: chalk.hex(theme.foregroundMuted).italic,
imagePath: chalk.hex(theme.foregroundMuted).underline,
}
Loading