From 3c4223c890c507f1d5d8c87c9fa7068785b70c88 Mon Sep 17 00:00:00 2001 From: SecPan Date: Fri, 10 Jul 2026 07:17:59 -0700 Subject: [PATCH] Resolve derived titles to plain text Draft and heading titles previously leaked markup: a first line like "***alpha*** bravo" lost its emphasis markers but kept the raw tags, and a styled heading was surfaced verbatim. Titles now reduce to plain text through a shared stripInlineMarkdown helper used by both the leading-line and heading paths: link/image text is kept, inline HTML tags are dropped, and paired delimiters are unwrapped. Only actual markup is removed. Paired code spans, asterisk emphasis, GFM strikethrough, and underscore emphasis (per CommonMark's rule that intraword underscores never emphasize) unwrap to their content, so literal punctuation like file_name, snake_case_name, 2*3, or foo~bar survives, including in suggested file names derived from titles. Marker-only runs reduce to nothing and fall through to the display-name and leading-text fallbacks. The tag pattern matches raw HTML the way CommonMark does: letter-led names including hyphenated custom elements, attributes whose quoted values may contain closing angle brackets, optional self-close. Autolinks like and remain content. --- src/lib/documentState.test.ts | 37 ++++++++++++++++++++++++ src/lib/documentState.ts | 53 +++++++++++++++++++++++++++-------- 2 files changed, 78 insertions(+), 12 deletions(-) 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