diff --git a/README.md b/README.md index b1ea0f92..ffec5edc 100644 --- a/README.md +++ b/README.md @@ -521,6 +521,9 @@ See https://www.chordpro.org/chordpro/directives-key/

Chordfont directive. See https://www.chordpro.org/chordpro/directives-props_title_legacy/

TITLESIZE : string

Chordfont directive. See https://www.chordpro.org/chordpro/directives-props_title_legacy/

+
TITLECOLOUR : string
+

Chorus directive. Support repeating an earlier defined section. +See https://www.chordpro.org/chordpro/directives-env_chorus/

defaultCssstring

Generates basic CSS, scoped within the provided selector, to use with output generated by [HtmlTableFormatter](#HtmlTableFormatter)

defaultCssstring
@@ -783,6 +786,7 @@ If not, it returns [INDETERMINATE](#INDETERMINATE)

* [.bodyLines](#Song+bodyLines) ⇒ [Array.<Line>](#Line) * [.bodyParagraphs](#Song+bodyParagraphs) ⇒ [Array.<Paragraph>](#Paragraph) * [.paragraphs](#Song+paragraphs) : [Array.<Paragraph>](#Paragraph) + * [.expandedBodyParagraphs](#Song+expandedBodyParagraphs) : [Array.<Paragraph>](#Paragraph) * ~~[.metaData](#Song+metaData) ⇒~~ * [.clone()](#Song+clone) ⇒ [Song](#Song) * [.setKey(key)](#Song+setKey) ⇒ [Song](#Song) @@ -826,6 +830,12 @@ if you want to skip the "header lines": the lines that only contain me ### song.paragraphs : [Array.<Paragraph>](#Paragraph)

The [Paragraph](#Paragraph) items of which the song consists

+**Kind**: instance property of [Song](#Song) + + +### song.expandedBodyParagraphs : [Array.<Paragraph>](#Paragraph) +

The body paragraphs of the song, with any {chorus} tag expanded into the targetted chorus

+ **Kind**: instance property of [Song](#Song) @@ -1116,6 +1126,7 @@ https://chordpro.org/chordpro/directives-env_bridge/, https://chordpro.org/chord | [configuration.metadata] | object | {} | | | [configuration.metadata.separator] | string | "\", \"" |

The separator to be used when rendering a metadata value that has multiple values. See: https://bit.ly/2SC9c2u

| | [configuration.key] | [Key](#Key) \| string | |

The key to use for rendering. The chord sheet will be transposed from the song's original key (as indicated by the {key} directive) to the specified key. Note that transposing will only work if the original song key is set.

| +| [configuration.expandChorusDirective] | boolean | false |

Whether or not to expand {chorus} directives by rendering the last defined chorus inline after the directive.

| @@ -1891,6 +1902,13 @@ See https://www.chordpro.org/chordpro/directives-key/

## TITLESIZE : string

Chordfont directive. See https://www.chordpro.org/chordpro/directives-props_title_legacy/

+**Kind**: global variable + + +## TITLECOLOUR : string +

Chorus directive. Support repeating an earlier defined section. +See https://www.chordpro.org/chordpro/directives-env_chorus/

+ **Kind**: global variable diff --git a/package.json b/package.json index 5ad12f7c..d7fc3190 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "build": "yarn build:code-generate && yarn build:sources && yarn build:browserify", "jest": "jest", "test": "yarn lint && yarn jest", - "test:debug": "open -a \"Brave Browser\" chrome://inspect && node --nolazy --inspect-brk ./node_modules/jest/bin/jest.js --runInBand --colors --verbose", + "jest:debug": "open -a \"Brave Browser\" chrome://inspect && node --nolazy --inspect-brk ./node_modules/jest/bin/jest.js --runInBand --colors --verbose", "eslint": "node_modules/.bin/eslint --ext .ts .", "lint": "yarn build:code-generate && yarn eslint", "lint:fix": "yarn build:code-generate && node_modules/.bin/eslint --fix --ext .ts .", diff --git a/src/chord_sheet/line.ts b/src/chord_sheet/line.ts index feab8c0a..00f4e9c5 100644 --- a/src/chord_sheet/line.ts +++ b/src/chord_sheet/line.ts @@ -5,7 +5,7 @@ import { CHORUS, NONE, VERSE } from '../constants'; import Item from './item'; import Font from './font'; -type MapItemFunc = (_item: Item) => Item; +type MapItemFunc = (_item: Item) => Item | null; export type LineType = 'verse' | 'chorus' | 'none'; @@ -32,6 +32,8 @@ class Line { transposeKey: string | null = null; + lineNumber: number | null = null; + /** * The text font that applies to this line. Is derived from the directives: * `textfont`, `textsize` and `textcolour` @@ -61,6 +63,10 @@ class Line { return this.items.length === 0; } + isNotEmpty(): boolean { + return !this.isEmpty(); + } + /** * Adds an item ({@link ChordLyricsPair} or {@link Tag}) to the line * @param {ChordLyricsPair|Tag} item The item to be added @@ -101,7 +107,7 @@ class Line { const clonedItem = item.clone(); return func ? func(clonedItem) : clonedItem; }) - .filter((item) => item); + .filter((item) => item !== null) as Item[]; clonedLine.type = this.type; return clonedLine; diff --git a/src/chord_sheet/song.ts b/src/chord_sheet/song.ts index 935d0527..5342bf93 100644 --- a/src/chord_sheet/song.ts +++ b/src/chord_sheet/song.ts @@ -15,16 +15,17 @@ import { } from '../constants'; import Tag, { + CAPO, END_OF_CHORUS, END_OF_TAB, END_OF_VERSE, + KEY, + NEW_KEY, START_OF_CHORUS, START_OF_TAB, START_OF_VERSE, TRANSPOSE, - NEW_KEY, - CAPO, - KEY, + CHORUS as CHORUS_TAG, } from './tag'; interface MapItemsCallback { @@ -146,18 +147,83 @@ class Song extends MetadataAccessors { this.setCurrentProperties(this.sectionType); this.currentLine.transposeKey = this.transposeKey ?? this.currentKey; this.currentLine.key = this.currentKey || this.metadata.getSingle(KEY); + this.currentLine.lineNumber = this.lines.length - 1; return this.currentLine; } + private expandLine(line: Line): Line[] { + const expandedLines = line.items.flatMap((item: Item) => { + if (item instanceof Tag && item.name === CHORUS_TAG) { + return this.getLastChorusBefore(line.lineNumber); + } + + return []; + }); + + return [line, ...expandedLines]; + } + + private getLastChorusBefore(lineNumber: number | null): Line[] { + const lines: Line[] = []; + + if (!lineNumber) { + return lines; + } + + for (let i = lineNumber - 1; i >= 0; i -= 1) { + const line = this.lines[i]; + + if (line.type === CHORUS) { + const filteredLine = this.filterChorusStartEndDirectives(line); + + if (!(line.isNotEmpty() && filteredLine.isEmpty())) { + lines.unshift(line); + } + } else if (lines.length > 0) { + break; + } + } + + return lines; + } + + private filterChorusStartEndDirectives(line: Line) { + return line.mapItems((item: Item) => { + if (item instanceof Tag) { + if (item.name === START_OF_CHORUS || item.name === END_OF_CHORUS) { + return null; + } + } + + return item; + }); + } + /** * The {@link Paragraph} items of which the song consists * @member {Paragraph[]} */ get paragraphs(): Paragraph[] { + return this.linesToParagraphs(this.lines); + } + + /** + * The body paragraphs of the song, with any `{chorus}` tag expanded into the targetted chorus + * @type {Paragraph[]} + */ + get expandedBodyParagraphs(): Paragraph[] { + return this.selectRenderableItems( + this.linesToParagraphs( + this.lines.flatMap((line: Line) => this.expandLine(line)), + ), + ) as Paragraph[]; + } + + linesToParagraphs(lines: Line[]) { let currentParagraph = new Paragraph(); const paragraphs = [currentParagraph]; - this.lines.forEach((line) => { + lines.forEach((line) => { if (line.isEmpty()) { currentParagraph = new Paragraph(); paragraphs.push(currentParagraph); diff --git a/src/chord_sheet/tag.ts b/src/chord_sheet/tag.ts index baa55a9d..8425dd28 100644 --- a/src/chord_sheet/tag.ts +++ b/src/chord_sheet/tag.ts @@ -205,6 +205,13 @@ export const TITLESIZE = 'titlesize'; */ export const TITLECOLOUR = 'titlecolour'; +/** + * Chorus directive. Support repeating an earlier defined section. + * See https://www.chordpro.org/chordpro/directives-env_chorus/ + * @type {string} + */ +export const CHORUS = 'chorus'; + const TITLE_SHORT = 't'; const SUBTITLE_SHORT = 'st'; const COMMENT_SHORT = 'c'; @@ -261,6 +268,7 @@ const DIRECTIVES_WITH_RENDERABLE_LABEL = [ START_OF_CHORUS, START_OF_BRIDGE, START_OF_TAB, + CHORUS, ]; const ALIASES: Record = { diff --git a/src/formatter/configuration/configuration.ts b/src/formatter/configuration/configuration.ts index d933d1e9..4931dc21 100644 --- a/src/formatter/configuration/configuration.ts +++ b/src/formatter/configuration/configuration.ts @@ -9,12 +9,14 @@ export type ConfigurationProperties = Record & { separator: string, }, key?: Key | string | null, + expandChorusDirective?: boolean, } export const defaultConfiguration: ConfigurationProperties = { evaluate: false, metadata: { separator: ',' }, key: null, + expandChorusDirective: false, }; class Configuration { @@ -26,13 +28,12 @@ class Configuration { configuration: Record; - constructor(configuration: ConfigurationProperties = defaultConfiguration) { - if ('evaluate' in configuration) { - this.evaluate = !!configuration.evaluate; - } else { - this.evaluate = !!defaultConfiguration.evaluate; - } + expandChorusDirective: boolean; + constructor(configuration: ConfigurationProperties = defaultConfiguration) { + const mergedConfig: ConfigurationProperties = { ...defaultConfiguration, ...configuration }; + this.evaluate = !!mergedConfig.evaluate; + this.expandChorusDirective = !!mergedConfig.expandChorusDirective; this.metadata = new MetadataConfiguration(configuration.metadata); this.key = configuration.key ? Key.wrap(configuration.key) : null; this.configuration = configuration; diff --git a/src/formatter/formatter.ts b/src/formatter/formatter.ts index 9bf25870..02168748 100644 --- a/src/formatter/formatter.ts +++ b/src/formatter/formatter.ts @@ -18,6 +18,8 @@ class Formatter { * from the song's original key (as indicated by the `{key}` directive) to the specified key. * Note that transposing will only work * if the original song key is set. + * @param {boolean} [configuration.expandChorusDirective=false] Whether or not to expand `{chorus}` directives + * by rendering the last defined chorus inline after the directive. */ constructor(configuration: ConfigurationProperties | null = null) { this.configuration = new Configuration(configuration || {}); diff --git a/src/formatter/html_formatter.ts b/src/formatter/html_formatter.ts index a5a07dbb..9530d07a 100644 --- a/src/formatter/html_formatter.ts +++ b/src/formatter/html_formatter.ts @@ -2,11 +2,13 @@ import Formatter from './formatter'; import Configuration from './configuration/configuration'; import Song from '../chord_sheet/song'; import { breakingChange, scopeCss } from '../utilities'; +import Paragraph from '../chord_sheet/paragraph'; export type HtmlTemplateArgs = { configuration: Configuration; song: Song; renderBlankLines?: boolean; + bodyParagraphs: Paragraph[], }; export type Template = (_args: HtmlTemplateArgs) => string; @@ -22,7 +24,15 @@ abstract class HtmlFormatter extends Formatter { * @returns {string} The HTML string */ format(song: Song): string { - return this.template({ song, configuration: this.configuration }); + const { bodyParagraphs, expandedBodyParagraphs } = song; + + return this.template( + { + song, + configuration: this.configuration, + bodyParagraphs: this.configuration.expandChorusDirective ? expandedBodyParagraphs : bodyParagraphs, + }, + ); } /** diff --git a/src/formatter/templates/html_div_formatter.ts b/src/formatter/templates/html_div_formatter.ts index 0e5a8b22..1990e64f 100644 --- a/src/formatter/templates/html_div_formatter.ts +++ b/src/formatter/templates/html_div_formatter.ts @@ -26,9 +26,9 @@ export default ( song: { title, subtitle, - bodyParagraphs, metadata, }, + bodyParagraphs, }: HtmlTemplateArgs, ): string => stripHTML(` ${ when(title, () => `

${ title }

`) } diff --git a/src/formatter/templates/html_table_formatter.ts b/src/formatter/templates/html_table_formatter.ts index 5a5b0176..073ef8ac 100644 --- a/src/formatter/templates/html_table_formatter.ts +++ b/src/formatter/templates/html_table_formatter.ts @@ -28,10 +28,10 @@ export default ( song: { title, subtitle, - bodyParagraphs, bodyLines, metadata, }, + bodyParagraphs, }: HtmlTemplateArgs, ): string => stripHTML(` ${ when(title, () => `

${ title}

`) } diff --git a/src/formatter/text_formatter.ts b/src/formatter/text_formatter.ts index b7fdad68..fe46f80a 100644 --- a/src/formatter/text_formatter.ts +++ b/src/formatter/text_formatter.ts @@ -42,9 +42,10 @@ class TextFormatter extends Formatter { } formatParagraphs(): string { - const { bodyParagraphs, metadata } = this.song; + const { bodyParagraphs, expandedBodyParagraphs, metadata } = this.song; + const { expandChorusDirective } = this.configuration; - return bodyParagraphs + return (expandChorusDirective ? expandedBodyParagraphs : bodyParagraphs) .map((paragraph: Paragraph) => this.formatParagraph(paragraph, metadata)) .join('\n\n'); } diff --git a/test/integration/chord_pro_to_chord_sheet.test.ts b/test/integration/chord_pro_to_chord_sheet.test.ts index 2a1a18a1..1bde4718 100644 --- a/test/integration/chord_pro_to_chord_sheet.test.ts +++ b/test/integration/chord_pro_to_chord_sheet.test.ts @@ -73,7 +73,7 @@ Let it [Am]be [G]wisdom, let it {end_of_bridge}`.substring(1); - const expectedHTML = ` + const expectedText = ` Verse 1: Am Let it be @@ -89,8 +89,102 @@ wisdom, let it`.substring(1); const song = new ChordProParser().parse(chordSheet); const formatted = new TextFormatter().format(song); - console.warn(formatted); + expect(formatted).toEqual(expectedText); + }); + + it('can expand chorus directives when expandChorusDirective=true', () => { + const chordSheet = ` +{start_of_chorus: Chorus 1:} +[C]Whisper words of +{end_of_chorus} + +{start_of_verse: Verse 1:} +Let it [Am]be +{end_of_verse} + +{chorus: Repeat chorus 1:} + +{start_of_chorus: Chorus 2:} +[G]wisdom, let it +{end_of_chorus} + +{chorus: Repeat chorus 2:} + +{chorus: Repeat chorus 2 again:}`.substring(1); + + const expectedText = ` +Chorus 1: +C +Whisper words of + +Verse 1: + Am +Let it be + +Repeat chorus 1: +C +Whisper words of + +Chorus 2: +G +wisdom, let it + +Repeat chorus 2: +G +wisdom, let it + +Repeat chorus 2 again: +G +wisdom, let it`.substring(1); + + const song = new ChordProParser().parse(chordSheet); + const formatted = new TextFormatter({ expandChorusDirective: true }).format(song); + + expect(formatted).toEqual(expectedText); + }); + + it('does not expand chorus directives when expandChorusDirective=false', () => { + const chordSheet = ` +{start_of_chorus: Chorus 1:} +[C]Whisper words of +{end_of_chorus} + +{start_of_verse: Verse 1:} +Let it [Am]be +{end_of_verse} + +{chorus: Repeat chorus 1:} + +{start_of_chorus: Chorus 2:} +[G]wisdom, let it +{end_of_chorus} + +{chorus: Repeat chorus 2:} + +{chorus: Repeat chorus 2 again:}`.substring(1); + + const expectedText = ` +Chorus 1: +C +Whisper words of + +Verse 1: + Am +Let it be + +Repeat chorus 1: + +Chorus 2: +G +wisdom, let it + +Repeat chorus 2: + +Repeat chorus 2 again:`.substring(1); + + const song = new ChordProParser().parse(chordSheet); + const formatted = new TextFormatter({ expandChorusDirective: false }).format(song); - expect(formatted).toEqual(expectedHTML); + expect(formatted).toEqual(expectedText); }); }); diff --git a/test/integration/chord_pro_to_html_div.test.ts b/test/integration/chord_pro_to_html_div.test.ts index a81f043b..8db0c13a 100644 --- a/test/integration/chord_pro_to_html_div.test.ts +++ b/test/integration/chord_pro_to_html_div.test.ts @@ -220,4 +220,216 @@ Let it [Am]Be expect(formatted).toEqual(expectedHTML); }); + + it('can expand {chorus} directives when expandChorusDirective=true', () => { + const chordSheet = ` +{start_of_chorus: Chorus 1:} +[C]Whisper words of +{end_of_chorus} + +{start_of_verse: Verse 1:} +Let it [Am]be +{end_of_verse} + +{chorus: Repeat chorus 1:} + +{start_of_chorus: Chorus 2:} +[G]wisdom, let it +{end_of_chorus} + +{chorus: Repeat chorus 2:} + +{chorus: Repeat chorus 2 again:}`.substring(1); + + const expectedText = stripHTML(` +
+
+
+

Chorus 1:

+
+
+
+
C
+
Whisper
+
+
+
+
words of
+
+
+
+
+
+

Verse 1:

+
+
+
+
+
Let it
+
+
+
Am
+
be
+
+
+
+
+
+

Repeat chorus 1:

+
+
+
+
C
+
Whisper
+
+
+
+
words of
+
+
+
+
+
+

Chorus 2:

+
+
+
+
G
+
wisdom,
+
+
+
+
let it
+
+
+
+
+
+

Repeat chorus 2:

+
+
+
+
G
+
wisdom,
+
+
+
+
let it
+
+
+
+
+
+

Repeat chorus 2 again:

+
+
+
+
G
+
wisdom,
+
+
+
+
let it
+
+
+
+
+ `); + + const song = new ChordProParser().parse(chordSheet); + const formatted = new HtmlDivFormatter({ expandChorusDirective: true }).format(song); + + expect(formatted).toEqual(expectedText); + }); + + it('does not expand {chorus} directives when expandChorusDirective=false', () => { + const chordSheet = ` +{start_of_chorus: Chorus 1:} +[C]Whisper words of +{end_of_chorus} + +{start_of_verse: Verse 1:} +Let it [Am]be +{end_of_verse} + +{chorus: Repeat chorus 1:} + +{start_of_chorus: Chorus 2:} +[G]wisdom, let it +{end_of_chorus} + +{chorus: Repeat chorus 2:} + +{chorus: Repeat chorus 2 again:}`.substring(1); + + const expectedText = stripHTML(` +
+
+
+

Chorus 1:

+
+
+
+
C
+
Whisper
+
+
+
+
words of
+
+
+
+
+
+

Verse 1:

+
+
+
+
+
Let it
+
+
+
Am
+
be
+
+
+
+
+
+

Repeat chorus 1:

+
+
+
+
+

Chorus 2:

+
+
+
+
G
+
wisdom,
+
+
+
+
let it
+
+
+
+
+
+

Repeat chorus 2:

+
+
+
+
+

Repeat chorus 2 again:

+
+
+
+ `); + + const song = new ChordProParser().parse(chordSheet); + const formatted = new HtmlDivFormatter({ expandChorusDirective: false }).format(song); + + expect(formatted).toEqual(expectedText); + }); }); diff --git a/test/integration/chord_pro_to_html_table.test.ts b/test/integration/chord_pro_to_html_table.test.ts index ac1f9b4e..67ee5618 100644 --- a/test/integration/chord_pro_to_html_table.test.ts +++ b/test/integration/chord_pro_to_html_table.test.ts @@ -223,4 +223,240 @@ Let it [Am]Be expect(formatted).toEqual(expectedHTML); }); + + it('can expand {chorus} directives when expandChorusDirective=true', () => { + const chordSheet = ` +{start_of_chorus: Chorus 1:} +[C]Whisper words of +{end_of_chorus} + +{start_of_verse: Verse 1:} +Let it [Am]be +{end_of_verse} + +{chorus: Repeat chorus 1:} + +{start_of_chorus: Chorus 2:} +[G]wisdom, let it +{end_of_chorus} + +{chorus: Repeat chorus 2:} + +{chorus: Repeat chorus 2 again:}`.substring(1); + + const expectedText = stripHTML(` +
+
+ + + + +

Chorus 1:

+ + + + + + + + + +
C
Whisper words of
+
+
+ + + + +

Verse 1:

+ + + + + + + + + +
Am
Let it be
+
+
+ + + + +

Repeat chorus 1:

+ + + + + + + + + +
C
Whisper words of
+
+
+ + + + +

Chorus 2:

+ + + + + + + + + +
G
wisdom, let it
+
+
+ + + + +

Repeat chorus 2:

+ + + + + + + + + +
G
wisdom, let it
+
+
+ + + + +

Repeat chorus 2 again:

+ + + + + + + + + +
G
wisdom, let it
+
+
+ `); + + const song = new ChordProParser().parse(chordSheet); + const formatted = new HtmlTableFormatter({ expandChorusDirective: true }).format(song); + + expect(formatted).toEqual(expectedText); + }); + + it('does not expand {chorus} directives when expandChorusDirective=false', () => { + const chordSheet = ` +{start_of_chorus: Chorus 1:} +[C]Whisper words of +{end_of_chorus} + +{start_of_verse: Verse 1:} +Let it [Am]be +{end_of_verse} + +{chorus: Repeat chorus 1:} + +{start_of_chorus: Chorus 2:} +[G]wisdom, let it +{end_of_chorus} + +{chorus: Repeat chorus 2:} + +{chorus: Repeat chorus 2 again:}`.substring(1); + + const expectedText = stripHTML(` +
+
+ + + + +

Chorus 1:

+ + + + + + + + + +
C
Whisper words of
+
+
+ + + + +

Verse 1:

+ + + + + + + + + +
Am
Let it be
+
+
+ + + + +

Repeat chorus 1:

+
+
+ + + + +

Chorus 2:

+ + + + + + + + + +
G
wisdom, let it
+
+
+ + + + +

Repeat chorus 2:

+
+
+ + + + +

Repeat chorus 2 again:

+
+
+ `); + + const song = new ChordProParser().parse(chordSheet); + const formatted = new HtmlTableFormatter({ expandChorusDirective: false }).format(song); + + expect(formatted).toEqual(expectedText); + }); });