From d886c6e509c078a95fddb5232008e584a208520f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Tue, 4 Aug 2026 21:32:02 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9E=95=20Include=20chords=20for=20open=20lyr?= =?UTF-8?q?ics=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/utils.ts | 50 ++++++++++++++++++++++++++++++++++-- frontend/tests/utils.test.ts | 33 ++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index a57c9f7..964c871 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -17,6 +17,52 @@ const isChordLine = (line: string): boolean => { return line.slice(-2) === ' '; }; +// escape a value for use inside a single-quoted XML attribute +const escapeXmlAttr = (value: string): string => + value.replace(/&/g, '&').replace(//g, '>').replace(/'/g, '''); + +// splice a chord line's tokens into its paired lyric line as OpenLyrics tags at their column position. +// Important: lyricLine must stay unescaped until after splicing — column offsets are computed against the raw +// (unescaped) text, so escaping first would shift indices and misalign chords with text. +const embedChords = (chordLine: string, lyricLine: string): string => { + const tokens: { name: string; column: number }[] = []; + const re = /\S+/g; + let match: RegExpExecArray | null; + while ((match = re.exec(chordLine)) !== null) { + tokens.push({ name: match[0], column: match.index }); + } + let result = '', cursor = 0; + for (const { name, column } of tokens) { + result += lyricLine.slice(cursor, column); // slice clamps automatically if column > lyricLine.length + result += ``; + cursor = column; + } + return result + lyricLine.slice(cursor); +}; + +// walk a song part's raw lines (lyrics interleaved with chord lines), pairing each chord line with the +// non-blank lyric line directly beneath it and join everything with the file's existing '
' convention +const chordTaggedLines = (content: string): string => { + const lines = content.split('\n'); + const result: string[] = []; + for (let i = 0; i < lines.length; i++) { + if (isChordLine(lines[i])) { + const next = lines[i + 1]; + if (next !== undefined && next.trim() !== '' && !isChordLine(next)) { + result.push(embedChords(lines[i], next)); + i++; + } else { + // standalone/instrumental chord line (back-to-back chord lines, last line of a part, or + // followed by a blank separator line): emit bare chord tags, don't swallow a blank line + result.push(embedChords(lines[i], '')); + } + } else { + result.push(lines[i]); + } + } + return result.join('
'); +}; + // parse song content syntax function parsedContent(content: string, keyOffset: number, showChords: boolean, twoColumns: false): SongPart[]; function parsedContent(content: string, keyOffset: number, showChords: boolean, twoColumns: true): [SongPart[], SongPart[]]; @@ -367,13 +413,13 @@ const openLyricsXML = (song: SongEntity, version: string, translatedSong: SongEn ? `]]>]]>]]>]]>]]>]]>` : ''; const tParts = translatedSong ? parsedContent(translatedSong.content, 0, false, false) : []; - const lyrics = parsedContent(song.content, 0, false, false).map((p, i) => { + const lyrics = parsedContent(song.content, 0, true, false).map((p, i) => { const type = p.type ? p.type.toUpperCase() : 'V'; const num = Number(p.number) > 0 ? p.number : '1'; const tContent = (i in tParts) ? `

${tParts[i].content.replace(/\n/g, "
")}
` : ''; - return `${p.content.replace(/\n/g, "
")}${tContent}
`; + return `${chordTaggedLines(p.content)}${tContent}`; }).join(''); return `${title}${subtitle}${copyright}${year}${ccli}${authors}${tags}${format}${lyrics}`; diff --git a/frontend/tests/utils.test.ts b/frontend/tests/utils.test.ts index 77cd5cc..ce79566 100644 --- a/frontend/tests/utils.test.ts +++ b/frontend/tests/utils.test.ts @@ -331,4 +331,37 @@ describe('openLyricsXML', () => { 'Original line

Translated line
' ); }); + + it('embeds chords inline at their column position', () => { + const song: SongEntity = { ...minimalSong, content: '--V1\nEm D \nAmazing grace' }; + const xml = openLyricsXML(song, '1.0.0'); + expect(xml).toContain( + 'Amazing grace' + ); + }); + + it('emits a bare chord tag for an instrumental chord line with no lyric', () => { + const song: SongEntity = { ...minimalSong, content: '--I\nEm ' }; + expect(openLyricsXML(song, '1.0.0')).toContain(''); + }); + + it('handles two consecutive chord lines without swallowing either', () => { + const song: SongEntity = { ...minimalSong, content: '--I\nEm \nD ' }; + expect(openLyricsXML(song, '1.0.0')).toContain( + '
' + ); + }); + + it('preserves a blank separator line after an orphan chord line in markerless content', () => { + // no '--' marker at all, so parsedContent takes the raw-passthrough branch and doesn't + // pre-strip blank lines itself, unlike the marked branch used by the other cases above + const song: SongEntity = { ...minimalSong, content: 'Em \n\nSome lyric' }; + const xml = openLyricsXML(song, '1.0.0'); + expect(xml).toContain('

Some lyric'); + }); + + it('escapes special characters in chord names', () => { + const song: SongEntity = { ...minimalSong, content: '--V1\nA&B \nline' }; + expect(openLyricsXML(song, '1.0.0')).toContain(''); + }); });