From 22924367d50223df4f6ebef14861d76731f6282b Mon Sep 17 00:00:00 2001 From: Daniel Pecos Martinez Date: Thu, 16 Apr 2026 13:26:43 +0200 Subject: [PATCH] fix: only extract properties from leading front-matter block Previously extractProperties used a greedy regex that consumed any 'word: value' line anywhere in the slide source, causing content lines like 'Example: this line disappears' to be silently eaten as properties. Now property extraction stops at the first blank line or non-property line, matching YAML front-matter conventions. HTML comment properties () are still extracted anywhere as they are invisible markers by design. Closes #4 --- src/__tests__/parser.test.ts | 31 +++++++++++++++++++++++++++- src/mdeck/parser.ts | 39 +++++++++++++++++++++++++++--------- 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts index a8ca2b7..b052db9 100644 --- a/src/__tests__/parser.test.ts +++ b/src/__tests__/parser.test.ts @@ -197,7 +197,36 @@ describe('Parser', () => { }); it('removes extracted properties from content', () => { - expect(parser.parse('name: a\n1')[0].content).toEqual(['\n1']); + expect(parser.parse('name: a\n1')[0].content).toEqual(['1']); + }); + + it('does not consume a word:value line that appears after other content (issue #4)', () => { + // "Example: this line disappears" appears after real content — must stay as content. + const slide = parser.parse('# Heading\n\nExample: this line disappears\n\nMore content.')[0]; + expect(slide.properties['Example']).toBeUndefined(); + expect(slide.content.join('')).toContain('Example: this line disappears'); + }); + + it('does not consume a word:value line mixed in with regular content', () => { + const slide = parser.parse('Some intro\n\nNote: this should stay\n\nMore text')[0]; + expect(slide.properties['Note']).toBeUndefined(); + expect(slide.content.join('')).toContain('Note: this should stay'); + }); + + it('stops extracting properties at the first non-property line', () => { + // "name" is a valid property but the next line is not, so class:b further down stays as content. + const slide = parser.parse('name: a\nNot valid prop\nclass: b')[0]; + expect(slide.properties.name).toBe('a'); + expect(slide.properties.class).toBeUndefined(); + expect(slide.content.join('')).toContain('class: b'); + }); + + it('stops extracting properties at a blank line separating front-matter from content', () => { + const slide = parser.parse('name: a\n\nclass: b')[0]; + expect(slide.properties.name).toBe('a'); + // class: b is after a blank line — it is content, not a property + expect(slide.properties.class).toBeUndefined(); + expect(slide.content.join('')).toContain('class: b'); }); }); diff --git a/src/mdeck/parser.ts b/src/mdeck/parser.ts index 490aded..ba9430c 100644 --- a/src/mdeck/parser.ts +++ b/src/mdeck/parser.ts @@ -114,18 +114,39 @@ function appendTo(element: ParsedSlide | ContentClass, content: ContentItem): vo } function extractProperties(source: string, properties: Record): string { - const propertyFinder = /^\n*([-\w]+):([^$\n]*)|\n*(?:)/i; + if (typeof source !== 'string') return source as unknown as string; + + // Extract inline HTML comment properties anywhere in the source (they are invisible markers). + const commentFinder = /\n*(?:)/gi; let match: RegExpExecArray | null; - while ((match = propertyFinder.exec(source)) !== null) { + while ((match = commentFinder.exec(source)) !== null) { source = source.slice(0, match.index) + source.slice(match.index + match[0].length); - if (match[1] !== undefined) { - properties[match[1].trim()] = match[2].trim(); - } else { - properties[match[3].trim()] = match[4].trim(); - } - propertyFinder.lastIndex = match.index; + properties[match[1].trim()] = match[2].trim(); + commentFinder.lastIndex = match.index; + } + + // Extract plain `key: value` properties only from the leading front-matter block. + // Stop as soon as a line that is not a property (or blank) is encountered so that + // content lines like "Example: this disappears" are never consumed as properties. + const lines = source.split('\n'); + const consumed: number[] = []; + const propertyLine = /^([-\w]+):([^$\n]*)$/i; + let i = 0; + // Skip leading blank lines. + while (i < lines.length && lines[i].trim() === '') { i++; } + // Consume contiguous property lines. + while (i < lines.length) { + const line = lines[i]; + if (line.trim() === '') break; // blank line ends the front-matter block + const m = propertyLine.exec(line); + if (!m) break; // non-property line ends the block + properties[m[1].trim()] = m[2].trim(); + consumed.push(i); + i++; } - return source; + // Remove consumed lines from source. + const remaining = lines.filter((_, idx) => !consumed.includes(idx)); + return remaining.join('\n'); } function cleanInput(source: string): string {