diff --git a/src/lib/documentState.test.ts b/src/lib/documentState.test.ts
index c6c586b..7d4455b 100644
--- a/src/lib/documentState.test.ts
+++ b/src/lib/documentState.test.ts
@@ -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('***alpha*** bravo charlie delta'))
+ .toBe('alpha bravo charlie delta')
+ expect(getDocumentTitle('alpha bravo charlie delta'))
+ .toBe('alpha bravo charlie delta')
+ expect(getDocumentTitle('note for later')).toBe('note for later')
+ expect(getDocumentTitle('alpha
beta')).toBe('alpha beta')
+ // Autolinks are content, not markup.
+ expect(getDocumentTitle(' reading list'))
+ .toBe(' reading list')
+ })
+
+ it('reduces styled headings to plain-text titles', () => {
+ expect(getDocumentTitle('# **Trip** to Paris')).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('alpha beta')).toBe('alpha beta')
+ expect(getDocumentTitle('alpha beta')).toBe('alpha beta')
+ expect(getDocumentTitle(' contact line'))
+ .toBe(' 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')
diff --git a/src/lib/documentState.ts b/src/lib/documentState.ts
index a377614..5da1614 100644
--- a/src/lib/documentState.ts
+++ b/src/lib/documentState.ts
@@ -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
+// and 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(/(?\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
@@ -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 `# ***alpha***` 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