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