Skip to content

Automatically Extract Title from First Heading in Markdown #655

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
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
29 changes: 28 additions & 1 deletion src/engine/info-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,30 @@ export interface EngineInfo {

export const engineInfo = Symbol()

/**
* Extracts the title from the higher hierarchy heading in the Markdown tokens.
* @param tokens - The list of Markdown tokens.
* @returns The extracted title or undefined if no heading is found.
*/
function extractTitleFromFirstHeading(tokens: any[]): string | undefined {
let bestHeading: { level: number; content: string } | undefined
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]
if (token.type === 'heading_open') {
const level = parseInt(token.tag.slice(1), 10)
const headingContentToken = tokens[i + 1]
if (
headingContentToken &&
headingContentToken.type === 'inline' &&
(!bestHeading || level < bestHeading.level)
) {
bestHeading = { level, content: headingContentToken.content.trim() }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the initial release is fine with this. In future releases, it would be better to use MarkdownIt.renderInline() to reflect the exact contents.

}
}
}
return bestHeading?.content
}

export default function infoPlugin(md: any) {
const { marpit } = md

Expand All @@ -22,6 +46,9 @@ export default function infoPlugin(md: any) {
const { themeSet, lastGlobalDirectives } = marpit
const globalDirectives = lastGlobalDirectives || {}
const theme = globalDirectives.theme || (themeSet.default || {}).name
const title =
globalDirectives.marpCLITitle ??
extractTitleFromFirstHeading(state.tokens)

const info: EngineInfo = {
theme,
Expand All @@ -30,7 +57,7 @@ export default function infoPlugin(md: any) {
image: globalDirectives.marpCLIImage,
keywords: globalDirectives.marpCLIKeywords,
lang: globalDirectives.lang || marpit.options.lang,
title: globalDirectives.marpCLITitle,
title,
url: globalDirectives.marpCLIURL,
size: {
height: themeSet.getThemeProp(theme, 'heightPixel'),
Expand Down
314 changes: 314 additions & 0 deletions test/engine/info-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,314 @@
import infoPlugin, { engineInfo } from '../../src/engine/info-plugin'

describe('Engine info plugin', () => {
// Helper to create mock heading tokens
const createHeadingTokens = (tag: string, content: string) => [
Comment on lines +4 to +5
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be better to test with the result of parse by actual Marp or markdown-it instance.

{
type: 'heading_open',
tag,
attrs: null,
map: [0, 1],
nesting: 1,
level: 0,
children: null,
content: '',
markup: '#',
info: '',
meta: null,
block: true,
hidden: false,
},
{
type: 'inline',
tag: '',
attrs: null,
map: [0, 1],
nesting: 0,
level: 1,
children: [],
content,
markup: '',
info: '',
meta: null,
block: true,
hidden: false,
},
{
type: 'heading_close',
tag,
attrs: null,
map: null,
nesting: -1,
level: 0,
children: null,
content: '',
markup: '#',
info: '',
meta: null,
block: true,
hidden: false,
},
]

describe('#infoPlugin title extraction', () => {
const marpitMock = () => ({
customDirectives: { global: {} },
themeSet: {
default: { name: 'default' },
getThemeProp: jest.fn(() => 1080),
},
options: { lang: 'en' },
})

const mdMock = () => ({
core: { ruler: { push: jest.fn() } },
marpit: marpitMock(),
})

// Test cases for title extraction behavior
it('returns undefined when there are no tokens', () => {
const md = mdMock()
infoPlugin(md)

const infoRule = md.core.ruler.push.mock.calls[0][1]
const state = { inlineMode: false, tokens: [] }

infoRule(state)

expect(md.marpit[engineInfo].title).toBeUndefined()
})

it('returns undefined when there are no heading tokens', () => {
const md = mdMock()
infoPlugin(md)

const infoRule = md.core.ruler.push.mock.calls[0][1]
const state = {
inlineMode: false,
tokens: [
{ type: 'paragraph_open' },
{ type: 'inline', content: 'Hello world' },
{ type: 'paragraph_close' },
],
}

infoRule(state)

expect(md.marpit[engineInfo].title).toBeUndefined()
})

it('extracts title from a single heading token', () => {
const md = mdMock()
infoPlugin(md)

const infoRule = md.core.ruler.push.mock.calls[0][1]
const state = {
inlineMode: false,
tokens: createHeadingTokens('h1', 'Slide Title'),
}

infoRule(state)

expect(md.marpit[engineInfo].title).toBe('Slide Title')
})

it('extracts title from the highest hierarchy heading', () => {
const md = mdMock()
infoPlugin(md)

const infoRule = md.core.ruler.push.mock.calls[0][1]
const state = {
inlineMode: false,
tokens: [
...createHeadingTokens('h2', 'Secondary Title'),
...createHeadingTokens('h1', 'Main Title'),
...createHeadingTokens('h3', 'Tertiary Title'),
],
}

infoRule(state)

expect(md.marpit[engineInfo].title).toBe('Main Title')
})

it('extracts title from the first highest heading when multiple exist', () => {
const md = mdMock()
infoPlugin(md)

const infoRule = md.core.ruler.push.mock.calls[0][1]
const state = {
inlineMode: false,
tokens: [
...createHeadingTokens('h2', 'First Section'),
...createHeadingTokens('h1', 'First Main Title'),
...createHeadingTokens('h1', 'Second Main Title'),
],
}

infoRule(state)

expect(md.marpit[engineInfo].title).toBe('First Main Title')
})

it('ignores malformed heading tokens missing content', () => {
const md = mdMock()
infoPlugin(md)

const infoRule = md.core.ruler.push.mock.calls[0][1]
const state = {
inlineMode: false,
tokens: [
{
type: 'heading_open',
tag: 'h1',
},
// Missing inline content token
{
type: 'heading_close',
tag: 'h1',
},
...createHeadingTokens('h2', 'Secondary Title'),
],
}

infoRule(state)

expect(md.marpit[engineInfo].title).toBe('Secondary Title')
})

it('trims whitespace from heading content', () => {
const md = mdMock()
infoPlugin(md)

const infoRule = md.core.ruler.push.mock.calls[0][1]
const state = {
inlineMode: false,
tokens: createHeadingTokens('h1', ' Title with spaces '),
}

infoRule(state)

expect(md.marpit[engineInfo].title).toBe('Title with spaces')
})
})

describe('#infoPlugin', () => {
const marpitMock = () => {
const marpit = {
customDirectives: { global: {} },
themeSet: {
default: { name: 'default' },
getThemeProp: jest.fn(() => 1080),
},
options: { lang: 'en' },
}
return marpit
}

const mdMock = () => {
const md: any = {
core: { ruler: { push: jest.fn() } },
marpit: marpitMock(),
}
return md
}

it('adds marp_cli_info ruler to markdown-it', () => {
const md = mdMock()
infoPlugin(md)

expect(md.core.ruler.push).toHaveBeenCalledWith(
'marp_cli_info',
expect.any(Function)
)
})

it('returns early when in inline mode', () => {
const md = mdMock()
infoPlugin(md)

const infoRule = md.core.ruler.push.mock.calls[0][1]
const state = { inlineMode: true }

expect(infoRule(state)).toBeUndefined()
expect(md.marpit[engineInfo]).toBeUndefined()
})

it('uses global directive title when available', () => {
const md = mdMock()
md.marpit.lastGlobalDirectives = { marpCLITitle: 'Title from Directive' }

infoPlugin(md)

const infoRule = md.core.ruler.push.mock.calls[0][1]
const state = {
inlineMode: false,
tokens: createHeadingTokens('h1', 'Heading Title'),
}

infoRule(state)

expect(md.marpit[engineInfo].title).toBe('Title from Directive')
})

it('counts slides correctly', () => {
const md = mdMock()
infoPlugin(md)

const infoRule = md.core.ruler.push.mock.calls[0][1]
const state = {
inlineMode: false,
tokens: [
{ type: 'slide_open', meta: { marpitSlideElement: 1 } },
...createHeadingTokens('h1', 'Slide 1'),
{ type: 'slide_close' },
{ type: 'slide_open', meta: { marpitSlideElement: 1 } },
...createHeadingTokens('h2', 'Slide 2'),
{ type: 'slide_close' },
],
}

infoRule(state)

expect(md.marpit[engineInfo]).toHaveLength(2)
})

it('sets all info properties correctly', () => {
const md = mdMock()
md.marpit.lastGlobalDirectives = {
theme: 'gaia',
marpCLITitle: 'Presentation Title',
marpCLIAuthor: 'Author Name',
marpCLIDescription: 'Description text',
marpCLIImage: 'image.png',
marpCLIKeywords: ['key1', 'key2'],
marpCLIURL: 'https://example.com',
lang: 'es',
}

infoPlugin(md)

const infoRule = md.core.ruler.push.mock.calls[0][1]
const state = { inlineMode: false, tokens: [] }

infoRule(state)

const info = md.marpit[engineInfo]
expect(info).toMatchObject({
theme: 'gaia',
title: 'Presentation Title',
author: 'Author Name',
description: 'Description text',
image: 'image.png',
keywords: ['key1', 'key2'],
url: 'https://example.com',
lang: 'es',
length: 0,
size: {
height: 1080,
width: 1080,
},
})
})
})
})