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
37 changes: 37 additions & 0 deletions src/lib/documentState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,43 @@ describe('documentState', () => {
expect(getDocumentTitle('1. step one\n2. step two')).toBe('step one')
})

it('drops inline HTML tags from derived titles', () => {
// Styled first lines always resolve to plain text — with or without
// emphasis around the tags, the title is the same readable content.
expect(getDocumentTitle('***<u>alpha</u>*** bravo charlie delta'))
.toBe('alpha bravo charlie delta')
expect(getDocumentTitle('<u>alpha</u> bravo charlie delta'))
.toBe('alpha bravo charlie delta')
expect(getDocumentTitle('<mark>note</mark> for later')).toBe('note for later')
expect(getDocumentTitle('alpha<br>beta')).toBe('alpha beta')
// Autolinks are content, not markup.
expect(getDocumentTitle('<https://example.com> reading list'))
.toBe('<https://example.com> reading list')
})

it('reduces styled headings to plain-text titles', () => {
expect(getDocumentTitle('# **Trip** to <u>Paris</u>')).toBe('Trip to Paris')
// A pure-markup heading names nothing; the content below wins.
expect(getDocumentTitle('# ***\n\nreal text')).toBe('real text')
})

it('keeps literal Markdown punctuation that is not paired markup', () => {
expect(getDocumentTitle('# file_name')).toBe('file_name')
expect(getDocumentTitle('# snake_case_name here')).toBe('snake_case_name here')
expect(getDocumentTitle('# `file_name`')).toBe('file_name')
expect(getDocumentTitle('# 2*3 benchmark')).toBe('2*3 benchmark')
expect(getDocumentTitle('# foo~bar')).toBe('foo~bar')
expect(getDocumentTitle('# ~~done~~ next steps')).toBe('done next steps')
expect(getDocumentTitle('# _emphasized_ word')).toBe('emphasized word')
})

it('strips custom elements and tags with quoted attribute values', () => {
expect(getDocumentTitle('<my-widget>alpha</my-widget> beta')).toBe('alpha beta')
expect(getDocumentTitle('<span title="a>b">alpha</span> beta')).toBe('alpha beta')
expect(getDocumentTitle('<user@example.com> contact line'))
.toBe('<user@example.com> contact line')
})

it('skips front matter and pure-syntax lines when deriving draft titles', () => {
expect(getDocumentTitle('---\ntitle: meta\n---\nActual first line')).toBe('Actual first line')
expect(getDocumentTitle('---\n\nBelow a thematic break')).toBe('Below a thematic break')
Expand Down
53 changes: 41 additions & 12 deletions src/lib/documentState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,34 @@ const THEMATIC_BREAK_REGEXP = /^\s*([-*_])(\s*\1){2,}\s*$/
const CODE_FENCE_REGEXP = /^\s*(?:`{3,}|~{3,})/
const TITLE_MAX_LENGTH = 48

// Matches inline HTML tags the way CommonMark treats raw HTML: a letter-led
// tag name (hyphenated custom elements included), attributes whose quoted
// values may contain '>', optional self-close. Autolinks like
// <https://example.com> and <user@example.com> do not match because ':' and
// '@' follow the leading letters directly.
const INLINE_HTML_TAG_REGEXP = /<\/?[a-z][a-z0-9-]*(?:[\s/](?:[^<>"']|"[^"]*"|'[^']*')*)?>/gi

// Best-effort plain-text reduction of inline Markdown for use as a title:
// keeps link/image text, drops inline HTML tags, and unwraps paired
// delimiters only — literal punctuation like `file_name`, `2*3`, or
// `foo~bar` is content, not markup, and survives. Underscores follow
// CommonMark's rule that intraword `_` never emphasizes.
function stripInlineMarkdown(text: string) {
const reduced = text
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(INLINE_HTML_TAG_REGEXP, ' ')
.replace(/`([^`]+)`/g, '$1')
.replace(/\*{1,3}([^*]+)\*{1,3}/g, '$1')
.replace(/(?<![\p{L}\p{N}])_{1,3}([^_]+)_{1,3}(?![\p{L}\p{N}])/gu, '$1')
.replace(/~~([^~]+)~~/g, '$1')
.replace(/\s+/g, ' ')
.trim()

// Unpaired marker runs ("***", "**") carry no title content.
return /^[`*_~\s]+$/.test(reduced) ? '' : reduced
}

// Best-effort plain-text reduction of a Markdown line for use as a title:
// strips the common block prefixes and inline markers without a full parse.
// Lines that are pure syntax (fences, thematic breaks) reduce to nothing.
Expand All @@ -146,16 +174,13 @@ function extractTitleTextFromLine(line: string) {
return ''
}

return trimmed
.replace(/^#{1,6}\s+/, '')
.replace(/^(?:>\s*)+/, '')
.replace(/^(?:[-*+]|\d+[.)])\s+/, '')
.replace(/^\[[ xX]\]\s+/, '')
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/[`*_~]/g, '')
.replace(/\s+/g, ' ')
.trim()
return stripInlineMarkdown(
trimmed
.replace(/^#{1,6}\s+/, '')
.replace(/^(?:>\s*)+/, '')
.replace(/^(?:[-*+]|\d+[.)])\s+/, '')
.replace(/^\[[ xX]\]\s+/, ''),
)
}

// The first line of the document that still reads as text once Markdown
Expand Down Expand Up @@ -257,8 +282,12 @@ export function getDocumentTitle(markdown: string, displayName = DEFAULT_UNTITLE
.map(line => line.match(/^#{1,6}\s+(.+)$/)?.[1]?.trim())
.find(Boolean)

if (heading) {
return heading
// Titles are plain text: a styled heading like `# ***<u>alpha</u>***` must
// not leak its markers or tags. A pure-markup heading reduces to nothing
// and falls through to the display-name / leading-text fallbacks.
const headingText = heading ? stripInlineMarkdown(heading) : ''
if (headingText) {
return headingText
}

// A real display name (an opened file's name) still wins over content, but
Expand Down
Loading