├── .yarnrc.yml ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── release.yml ├── src ├── chord_parsing_error.ts ├── parser │ ├── chord │ │ ├── simple_suffix_grammar.pegjs │ │ └── base_grammar.pegjs │ ├── null_tracer.ts │ ├── whitespace_grammar.pegjs │ ├── parser_helpers.ts │ ├── parser_warning.ts │ ├── chord_definition │ │ └── grammar.pegjs │ ├── chord_pro_parser.ts │ └── chords_over_words_parser.ts ├── template_helpers │ ├── when_callback.ts │ ├── when_clause.ts │ └── when.ts ├── chord_sheet │ ├── soft_line_break.ts │ ├── trace_info.ts │ ├── item.ts │ ├── chord_pro │ │ ├── evaluatable.ts │ │ ├── literal.ts │ │ ├── evaluation_error.ts │ │ └── composite.ts │ ├── ast_type.ts │ ├── ast_component.ts │ ├── comment.ts │ ├── metadata_accessors.ts │ ├── song_mapper.ts │ ├── line_expander.ts │ ├── font_size.ts │ ├── font_stack.ts │ └── font.ts ├── formatter │ ├── formatting_context.ts │ ├── html_div_formatter.ts │ ├── html_table_formatter.ts │ ├── formatter.ts │ └── configuration.ts ├── chord_definition │ ├── chord_definition_set.ts │ └── chord_definition.ts ├── constants.ts └── serialized_types.ts ├── test ├── setup.ts ├── cloneable_stub.ts ├── numeral_chord │ ├── is_numeral.test.ts │ ├── is_numeric.test.ts │ ├── is_chord_symbol.test.ts │ ├── to_numeric.test.ts │ ├── to_numeral.test.ts │ ├── use_modifier.test.ts │ ├── transpose.test.ts │ ├── normalize.test.ts │ ├── to_chord_symbol.test.ts │ ├── to_string.test.ts │ ├── transpose_up.test.ts │ └── transpose_down.test.ts ├── chord_symbol │ ├── is_chord_symbol.test.ts │ ├── is_numeric.test.ts │ ├── is_numeral.test.ts │ ├── to_chord_symbol_string.test.ts │ ├── to_numeral_string.test.ts │ ├── to_numeric.test.ts │ ├── constructor.test.ts │ ├── to_chord_symbol.test.ts │ ├── to_numeral.test.ts │ ├── use_modifier.test.ts │ ├── transpose.test.ts │ ├── normalize.test.ts │ ├── normalize-suffix.test.ts │ ├── transpose_up.test.ts │ ├── transpose_down.test.ts │ └── to_string.test.ts ├── chord_solfege │ ├── is_chord_symbol.test.ts │ ├── is_numeric.test.ts │ ├── is_numeral.test.ts │ ├── to_chord_symbol_string.test.ts │ ├── to_numeral_string.test.ts │ ├── to_numeric.test.ts │ ├── constructor.test.ts │ ├── to_chord_symbol.test.ts │ ├── to_numeral.test.ts │ ├── use_modifier.test.ts │ ├── transpose.test.ts │ ├── normalize.test.ts │ ├── normalize-suffix.test.ts │ ├── transpose_up.test.ts │ ├── transpose_down.test.ts │ └── to_string.test.ts ├── numeric_chord │ ├── to_numeral.test.ts │ ├── is_numeral.test.ts │ ├── is_numeric.test.ts │ ├── is_chord_symbol.test.ts │ ├── to_numeric.test.ts │ ├── normalize.test.ts │ ├── use_modifier.test.ts │ ├── to_chord_symbol.test.ts │ ├── transpose.test.ts │ ├── to_string.test.ts │ ├── normalize-suffix.test.ts │ ├── transpose_up.test.ts │ └── transpose_down.test.ts ├── key │ ├── to_numeric_string.test.ts │ ├── to_numeral_string.test.ts │ ├── to_chord_symbol_string.test.ts │ ├── to_chord_solfege_string.test.ts │ ├── is_minor.test.ts │ ├── is.test.ts │ ├── clone.test.ts │ ├── is_chord_symbol.test.ts │ ├── is_chord_solfege.test.ts │ ├── is_numeral.test.ts │ ├── distance.test.ts │ ├── is_numeric.test.ts │ ├── equals.test.ts │ ├── to_chord_symbol.test.ts │ ├── to_chord_solfege.test.ts │ ├── wrap.test.ts │ ├── to_numeral.test.ts │ ├── to_string.test.ts │ ├── to_numeric.test.ts │ ├── normalize.test.ts │ └── transpose.test.ts ├── note │ ├── to_string.test.ts │ ├── is_minor.test.ts │ ├── clone.test.ts │ ├── is.test.ts │ ├── is_one_of.test.ts │ ├── is_chord_symbol.test.ts │ ├── is_numeric.test.ts │ ├── is_numeral.test.ts │ ├── is_chord_solfege.test.ts │ ├── equals.test.ts │ ├── to_numeric.test.ts │ ├── to_numeral.test.ts │ ├── parse.test.ts │ ├── get_transpose_distance.test.ts │ ├── up.test.ts │ ├── down.test.ts │ └── change.test.ts ├── formatter │ ├── configuration.test.ts │ └── chord_pro_formatter.test.ts ├── formatter.test.ts ├── get_capos.test.ts ├── fixtures │ ├── song_with_intro.ts │ ├── ultimate_guitar_chordsheet_expected_chordpro_format.txt │ ├── ultimate_guitar_chordsheet.txt │ └── chord_pro_sheet.ts ├── chord │ ├── clone.test.ts │ └── parse_suffixes.test.ts ├── default_export.test.ts ├── integration │ ├── serialize.test.ts │ ├── use_modifier.test.ts │ ├── chordpro_to_chords_over_words.test.ts │ ├── setting_key.test.ts │ └── chord_pro_to_chord_pro.test.ts ├── get_keys.test.ts ├── jest.d.ts ├── parser │ ├── parser_helpers.test.ts │ └── chord_pro │ │ └── helpers.test.ts ├── chord_sheet │ ├── chord_pro │ │ └── composite.test.ts │ ├── font.test.ts │ ├── chord_lyrics_pair.test.ts │ └── font_size.test.ts └── chord_definition │ ├── chord_definition_set.test.ts │ └── chord_definition.test.ts ├── typedoc.json ├── jest.config.ts ├── data ├── sections.ts └── scales.ts ├── .gitignore ├── script ├── debug_parser.ts ├── build_chord_suffix_normalize_mapping.ts ├── build_chord_suffix_grammar.ts ├── check_trailing_whitespace.ts ├── helpers │ ├── peggy_online.ts │ └── parser_builder.ts └── build_chord_pro_section_grammar.ts ├── bin ├── open_inspector └── check_deprecated_packages ├── tsconfig.json ├── .codeclimate.yml ├── package.json └── CONTRIBUTING.md /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: martijnversluis 2 | -------------------------------------------------------------------------------- /src/chord_parsing_error.ts: -------------------------------------------------------------------------------- 1 | export default class ChordParsingError extends Error {} 2 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | import nodeConsole from 'console'; 2 | 3 | global.console = nodeConsole; 4 | -------------------------------------------------------------------------------- /src/parser/chord/simple_suffix_grammar.pegjs: -------------------------------------------------------------------------------- 1 | ChordSuffix 2 | = [a-zA-Z0-9\(\)#\+\-o♭♯Δ]* 3 | -------------------------------------------------------------------------------- /src/parser/null_tracer.ts: -------------------------------------------------------------------------------- 1 | class NullTracer { 2 | trace() {} 3 | } 4 | 5 | export default NullTracer; 6 | -------------------------------------------------------------------------------- /src/template_helpers/when_callback.ts: -------------------------------------------------------------------------------- 1 | type WhenCallback = () => string; 2 | 3 | export default WhenCallback; 4 | -------------------------------------------------------------------------------- /src/chord_sheet/soft_line_break.ts: -------------------------------------------------------------------------------- 1 | class SoftLineBreak { 2 | clone() { 3 | return new SoftLineBreak(); 4 | } 5 | } 6 | 7 | export default SoftLineBreak; 8 | -------------------------------------------------------------------------------- /src/chord_sheet/trace_info.ts: -------------------------------------------------------------------------------- 1 | interface TraceInfo { 2 | line?: number | null; 3 | column?: number | null; 4 | offset?: number | null; 5 | } 6 | 7 | export default TraceInfo; 8 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://typedoc.org/schema.json", 3 | "entryPoints": ["./src/index.ts"], 4 | "out": "tmp/docs", 5 | "excludeNotDocumented": true, 6 | "logLevel": "Error" 7 | } 8 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | export default { 3 | preset: 'ts-jest', 4 | setupFilesAfterEnv: ['/test/setup.ts'], 5 | testEnvironment: 'node', 6 | }; 7 | -------------------------------------------------------------------------------- /test/cloneable_stub.ts: -------------------------------------------------------------------------------- 1 | export default class CloneableStub { 2 | value: any; 3 | 4 | constructor(value: any) { 5 | this.value = value; 6 | } 7 | 8 | clone() { 9 | return new CloneableStub(this.value); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /data/sections.ts: -------------------------------------------------------------------------------- 1 | const sections: [string, boolean][] = [ 2 | // name, generate shorthand tag (eg. sog, eot) 3 | ['abc', false], 4 | ['grid', true], 5 | ['ly', false], 6 | ['tab', true], 7 | ]; 8 | 9 | export default sections; 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: npm 8 | directory: / 9 | schedule: 10 | interval: daily 11 | -------------------------------------------------------------------------------- /src/formatter/formatting_context.ts: -------------------------------------------------------------------------------- 1 | import Configuration from './configuration'; 2 | import Metadata from '../chord_sheet/metadata'; 3 | 4 | interface FormattingContext { 5 | configuration: Configuration; 6 | metadata: Metadata; 7 | } 8 | 9 | export default FormattingContext; 10 | -------------------------------------------------------------------------------- /test/numeral_chord/is_numeral.test.ts: -------------------------------------------------------------------------------- 1 | import { Chord } from '../../src'; 2 | 3 | describe('Chord', () => { 4 | describe('numeral', () => { 5 | describe('isNumeral', () => { 6 | it('returns true', () => { 7 | expect(Chord.parse('I/III')?.isNumeral()).toBe(true); 8 | }); 9 | }); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /test/numeral_chord/is_numeric.test.ts: -------------------------------------------------------------------------------- 1 | import { Chord } from '../../src'; 2 | 3 | describe('Chord', () => { 4 | describe('numeral', () => { 5 | describe('isNumeric', () => { 6 | it('returns true', () => { 7 | expect(Chord.parse('I/III')?.isNumeric()).toBe(false); 8 | }); 9 | }); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/chord_sheet/item.ts: -------------------------------------------------------------------------------- 1 | import ChordLyricsPair from './chord_lyrics_pair'; 2 | import Comment from './comment'; 3 | import Literal from './chord_pro/literal'; 4 | import Tag from './tag'; 5 | import Ternary from './chord_pro/ternary'; 6 | 7 | type Item = ChordLyricsPair | Comment | Tag | Ternary | Literal; 8 | 9 | export default Item; 10 | -------------------------------------------------------------------------------- /test/numeral_chord/is_chord_symbol.test.ts: -------------------------------------------------------------------------------- 1 | import { Chord } from '../../src'; 2 | 3 | describe('Chord', () => { 4 | describe('isChordSymbol', () => { 5 | describe('for a numeral chord', () => { 6 | it('returns false', () => { 7 | expect(Chord.parse('V/VII')?.isChordSymbol()).toBe(false); 8 | }); 9 | }); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /test/chord_symbol/is_chord_symbol.test.ts: -------------------------------------------------------------------------------- 1 | import { Chord } from '../../src'; 2 | 3 | describe('Chord', () => { 4 | describe('chord symbol', () => { 5 | describe('isChordSymbol', () => { 6 | it('returns true for a chord symbol', () => { 7 | expect(Chord.parse('A/C')?.isChordSymbol()).toBe(true); 8 | }); 9 | }); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /test/chord_solfege/is_chord_symbol.test.ts: -------------------------------------------------------------------------------- 1 | import { Chord } from '../../src'; 2 | 3 | describe('Chord', () => { 4 | describe('chord solfege', () => { 5 | describe('isChordSolfege', () => { 6 | it('returns true for a chord solfege', () => { 7 | expect(Chord.parse('La/Do')?.isChordSolfege()).toBe(true); 8 | }); 9 | }); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /test/chord_solfege/is_numeric.test.ts: -------------------------------------------------------------------------------- 1 | import { Chord } from '../../src'; 2 | 3 | describe('Chord', () => { 4 | describe('chord solfege', () => { 5 | describe('for a pure numeric chord', () => { 6 | it('returns true for a numeric chord', () => { 7 | expect(Chord.parse('1/3')?.isNumeric()).toBe(true); 8 | }); 9 | }); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /test/chord_symbol/is_numeric.test.ts: -------------------------------------------------------------------------------- 1 | import { Chord } from '../../src'; 2 | 3 | describe('Chord', () => { 4 | describe('chord symbol', () => { 5 | describe('for a pure numeric chord', () => { 6 | it('returns true for a numeric chord', () => { 7 | expect(Chord.parse('1/3')?.isNumeric()).toBe(true); 8 | }); 9 | }); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | .yarn 4 | lib 5 | src/formatter/templates/*.js 6 | src/parser/*/peg_parser.ts 7 | .tool-versions 8 | src/normalize_mappings/suffix-normalize-mapping.ts 9 | .parcel-cache 10 | src/parser/chord/combined_grammer.pegjs 11 | src/parser/chord/suffix_grammar.pegjs 12 | src/parser/chord_pro/sections_grammar.pegjs 13 | src/version.ts 14 | tmp 15 | -------------------------------------------------------------------------------- /script/debug_parser.ts: -------------------------------------------------------------------------------- 1 | import process from 'process'; 2 | 3 | import ParserBuilder from './helpers/parser_builder'; 4 | import PeggyOnline from './helpers/peggy_online'; 5 | 6 | const parserSource = new ParserBuilder(process.argv[2]).build(); 7 | 8 | PeggyOnline 9 | .open(parserSource) 10 | .then(() => console.log('Done')) 11 | .catch((e) => console.error(e)); 12 | -------------------------------------------------------------------------------- /test/numeric_chord/to_numeral.test.ts: -------------------------------------------------------------------------------- 1 | import { Chord } from '../../src'; 2 | 3 | describe('Chord', () => { 4 | describe('numeric', () => { 5 | describe('toNumeral', () => { 6 | it('returns the numeral version', () => { 7 | expect(Chord.parse('#6sus4/b4')?.toNumeral().toString()).toEqual('#VIsus4/bIV'); 8 | }); 9 | }); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/chord_sheet/chord_pro/evaluatable.ts: -------------------------------------------------------------------------------- 1 | import AstComponent from '../ast_component'; 2 | import Metadata from '../metadata'; 3 | 4 | abstract class Evaluatable extends AstComponent { 5 | abstract evaluate(_metadata: Metadata, _metadataSeparator: string, _variable?: string | null): string; 6 | 7 | abstract clone(): Evaluatable; 8 | } 9 | 10 | export default Evaluatable; 11 | -------------------------------------------------------------------------------- /bin/open_inspector: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | browser_app_id=$(defaults read com.apple.LaunchServices/com.apple.launchservices.secure LSHandlers | sed -n -e "/LSHandlerURLScheme = https;/{x;p;d;}" -e 's/.*=[^"]"\(.*\)";/\1/g' -e x) 4 | 5 | osascript < { 5 | describe('toNumericString', () => { 6 | it('converts a numeric key to a string', () => { 7 | const key = buildKey(2, NUMERIC, 'b'); 8 | 9 | expect(key.toNumericString()).toEqual('b2'); 10 | }); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /test/chord_symbol/is_numeral.test.ts: -------------------------------------------------------------------------------- 1 | import { Chord } from '../../src'; 2 | 3 | describe('Chord', () => { 4 | describe('chord symbol', () => { 5 | describe('isNumeral', () => { 6 | describe('for a chord symbol', () => { 7 | it('returns false', () => { 8 | expect(Chord.parse('C/E')?.isNumeral()).toBe(false); 9 | }); 10 | }); 11 | }); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /test/key/to_numeral_string.test.ts: -------------------------------------------------------------------------------- 1 | import { NUMERAL } from '../../src/constants'; 2 | import { buildKey } from '../utilities'; 3 | 4 | describe('Key', () => { 5 | describe('toNumeralString', () => { 6 | it('converts a numeral key to a string', () => { 7 | const key = buildKey('IV', NUMERAL, 'b'); 8 | 9 | expect(key.toNumeralString()).toEqual('bIV'); 10 | }); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /test/note/to_string.test.ts: -------------------------------------------------------------------------------- 1 | import Note from '../../src/note'; 2 | 3 | describe('Note', () => { 4 | describe('#toString', () => { 5 | it('returns a string version of the note', () => { 6 | expect(Note.parse('A').toString()).toEqual('A'); 7 | expect(Note.parse(5).toString()).toEqual('5'); 8 | expect(Note.parse('VI').toString()).toEqual('VI'); 9 | }); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /test/numeric_chord/is_numeral.test.ts: -------------------------------------------------------------------------------- 1 | import { Chord } from '../../src'; 2 | 3 | describe('Chord', () => { 4 | describe('numeric', () => { 5 | describe('isNumeral', () => { 6 | describe('for a numeric chord', () => { 7 | it('returns false', () => { 8 | expect(Chord.parse('1/3')?.isNumeral()).toBe(false); 9 | }); 10 | }); 11 | }); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /test/numeric_chord/is_numeric.test.ts: -------------------------------------------------------------------------------- 1 | import { Chord } from '../../src'; 2 | 3 | describe('Chord', () => { 4 | describe('numeric', () => { 5 | describe('isNumeric', () => { 6 | describe('for a pure numeric chord', () => { 7 | it('returns true', () => { 8 | expect(Chord.parse('1/3')?.isNumeric()).toBe(true); 9 | }); 10 | }); 11 | }); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /test/chord_solfege/is_numeral.test.ts: -------------------------------------------------------------------------------- 1 | import { Chord } from '../../src'; 2 | 3 | describe('Chord', () => { 4 | describe('chord solfege', () => { 5 | describe('isNumeral', () => { 6 | describe('for a chord solfege', () => { 7 | it('returns false', () => { 8 | expect(Chord.parse('Do/Mi')?.isNumeral()).toBe(false); 9 | }); 10 | }); 11 | }); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /test/formatter/configuration.test.ts: -------------------------------------------------------------------------------- 1 | describe('Configuration', () => { 2 | it('merges in default delegates', () => { 3 | const customDelegate = (content: string) => content.toUpperCase(); 4 | 5 | const configuration = { 6 | delegates: { 7 | abc: customDelegate, 8 | }, 9 | }; 10 | 11 | expect(configuration.delegates.abc).toEqual(customDelegate); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /test/note/is_minor.test.ts: -------------------------------------------------------------------------------- 1 | import Note from '../../src/note'; 2 | 3 | describe('Note', () => { 4 | describe('#isMinor', () => { 5 | it('returns true for minor numerals', () => { 6 | expect(Note.parse('iii').isMinor()).toBe(true); 7 | }); 8 | 9 | it('returns false for major numerals', () => { 10 | expect(Note.parse('III').isMinor()).toBe(false); 11 | }); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /test/numeral_chord/to_numeric.test.ts: -------------------------------------------------------------------------------- 1 | import '../matchers'; 2 | 3 | import { Chord } from '../../src'; 4 | 5 | describe('Chord', () => { 6 | describe('numeral', () => { 7 | describe('toNumeric', () => { 8 | it('returns a numeric version of the chord', () => { 9 | expect(Chord.parse('#IIIsus4/bV')?.toNumeric().toString()).toEqual('#3sus4/b5'); 10 | }); 11 | }); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /test/key/to_chord_symbol_string.test.ts: -------------------------------------------------------------------------------- 1 | import { buildKey } from '../utilities'; 2 | import { NUMERIC, SYMBOL } from '../../src'; 3 | 4 | describe('Key', () => { 5 | describe('toChordSymbolString', () => { 6 | it('returns a string version of the chord symbol', () => { 7 | const songKey = buildKey('E', SYMBOL); 8 | const key = buildKey(4, NUMERIC, '#'); 9 | 10 | expect(key.toChordSymbolString(songKey)).toEqual('A#'); 11 | }); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/parser/whitespace_grammar.pegjs: -------------------------------------------------------------------------------- 1 | __ "whitespace" 2 | = WhitespaceCharacter+ 3 | 4 | _ "optional whitespace" 5 | = WhitespaceCharacter* 6 | 7 | WhitespaceCharacter 8 | = [ \t\n\r] 9 | 10 | Space "space" 11 | = $([ \t]+) 12 | 13 | NewLine 14 | = CarriageReturn / LineFeed / CarriageReturnLineFeed 15 | 16 | CarriageReturnLineFeed 17 | = CarriageReturn LineFeed 18 | 19 | LineFeed 20 | = "\n" 21 | 22 | CarriageReturn 23 | = "\r" 24 | 25 | Escape 26 | = "\\" 27 | -------------------------------------------------------------------------------- /src/chord_sheet/ast_type.ts: -------------------------------------------------------------------------------- 1 | import ChordLyricsPair from './chord_lyrics_pair'; 2 | import Comment from './comment'; 3 | import Evaluatable from './chord_pro/evaluatable'; 4 | import Literal from './chord_pro/literal'; 5 | import SoftLineBreak from './soft_line_break'; 6 | import Tag from './tag'; 7 | import Ternary from './chord_pro/ternary'; 8 | 9 | type AstType = ChordLyricsPair | Comment | Tag | Ternary | Evaluatable | Literal | SoftLineBreak; 10 | 11 | export default AstType; 12 | -------------------------------------------------------------------------------- /test/key/to_chord_solfege_string.test.ts: -------------------------------------------------------------------------------- 1 | import { buildKey } from '../utilities'; 2 | import { NUMERIC, SOLFEGE } from '../../src'; 3 | 4 | describe('Key', () => { 5 | describe('toChordSolfegeString', () => { 6 | it('returns a string version of the chord solfege', () => { 7 | const songKey = buildKey('Mi', SOLFEGE); 8 | const key = buildKey(4, NUMERIC, '#'); 9 | 10 | expect(key.toChordSolfegeString(songKey)).toEqual('La#'); 11 | }); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/chord_sheet/chord_pro/literal.ts: -------------------------------------------------------------------------------- 1 | import Evaluatable from './evaluatable'; 2 | 3 | class Literal extends Evaluatable { 4 | string: string; 5 | 6 | constructor(string: string) { 7 | super(); 8 | this.string = string; 9 | } 10 | 11 | evaluate(): string { 12 | return this.string; 13 | } 14 | 15 | isRenderable(): boolean { 16 | return true; 17 | } 18 | 19 | clone(): Literal { 20 | return new Literal(this.string); 21 | } 22 | } 23 | 24 | export default Literal; 25 | -------------------------------------------------------------------------------- /test/formatter.test.ts: -------------------------------------------------------------------------------- 1 | import Formatter from '../src/formatter/formatter'; 2 | 3 | describe('Formatter', () => { 4 | it('correctly assigns configuration', () => { 5 | const customDelegate = (content: string) => content.toUpperCase(); 6 | 7 | const configuration = { 8 | delegates: { 9 | abc: customDelegate, 10 | }, 11 | }; 12 | 13 | const formatter = new Formatter(configuration); 14 | 15 | expect(formatter.configuration.delegates.abc).toEqual(customDelegate); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/parser/parser_helpers.ts: -------------------------------------------------------------------------------- 1 | export function chopFirstWord(string: string) { 2 | const result = /(\s+)(\S+)/.exec(string); 3 | const secondWordPosition = result ? (result.index + result[1].length) : null; 4 | 5 | if (secondWordPosition && secondWordPosition !== -1) { 6 | return [ 7 | string.substring(0, secondWordPosition).trim(), 8 | string.substring(secondWordPosition), 9 | ]; 10 | } 11 | 12 | return [ 13 | /.+\s+$/.test(string) ? `${string.trim()} ` : string, 14 | null, 15 | ]; 16 | } 17 | -------------------------------------------------------------------------------- /test/numeral_chord/to_numeral.test.ts: -------------------------------------------------------------------------------- 1 | import '../matchers'; 2 | 3 | import { Chord } from '../../src'; 4 | 5 | describe('Chord', () => { 6 | describe('numeral', () => { 7 | describe('toNumeral', () => { 8 | it('returns a clone of the chord', () => { 9 | const originalChord = Chord.parse('#iiisus4/bV'); 10 | const numeralChord = originalChord?.toNumeral(); 11 | expect(numeralChord).toEqual(originalChord); 12 | expect(numeralChord).not.toBe(originalChord); 13 | }); 14 | }); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /test/note/clone.test.ts: -------------------------------------------------------------------------------- 1 | import Note from '../../src/note'; 2 | 3 | import { NUMERIC } from '../../src/constants'; 4 | 5 | describe('Note', () => { 6 | describe('#clone', () => { 7 | it('returns a deep copy of the note', () => { 8 | const note = new Note({ note: 5, type: NUMERIC, minor: true }); 9 | const clone = note.clone(); 10 | 11 | expect(note).not.toBe(clone); 12 | expect(note.note).toEqual(5); 13 | expect(note.type).toEqual(NUMERIC); 14 | expect(note.minor).toBe(true); 15 | }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /test/key/is_minor.test.ts: -------------------------------------------------------------------------------- 1 | import { NUMERIC } from '../../src/constants'; 2 | import { buildKey } from '../utilities'; 3 | 4 | describe('Key', () => { 5 | describe('#isMinor', () => { 6 | it('returns true when the note is minor', () => { 7 | const key = buildKey(3, NUMERIC, null, true); 8 | 9 | expect(key.isMinor()).toBe(true); 10 | }); 11 | 12 | it('returns false when the note is major', () => { 13 | const key = buildKey(3, NUMERIC, null, false); 14 | 15 | expect(key.isMinor()).toBe(false); 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/chord_sheet/chord_pro/evaluation_error.ts: -------------------------------------------------------------------------------- 1 | class EvaluationError extends Error { 2 | line: number | null = null; 3 | 4 | column: number | null = null; 5 | 6 | offset: number | null = null; 7 | 8 | constructor(message: string, line: number | null = null, column: number | null = null, offset: number | null = null) { 9 | super(`${message} on line ${line} column ${column}`); 10 | this.name = 'ExpressionError'; 11 | this.line = line; 12 | this.column = column; 13 | this.offset = offset; 14 | } 15 | } 16 | 17 | export default EvaluationError; 18 | -------------------------------------------------------------------------------- /src/chord_sheet/ast_component.ts: -------------------------------------------------------------------------------- 1 | import TraceInfo from './trace_info'; 2 | 3 | abstract class AstComponent { 4 | line: number | null = null; 5 | 6 | column: number | null = null; 7 | 8 | offset: number | null = null; 9 | 10 | protected constructor(traceInfo: TraceInfo | null = null) { 11 | if (traceInfo) { 12 | this.line = traceInfo.line || null; 13 | this.column = traceInfo.column || null; 14 | this.offset = traceInfo.offset || null; 15 | } 16 | } 17 | 18 | abstract clone(): AstComponent; 19 | } 20 | 21 | export default AstComponent; 22 | -------------------------------------------------------------------------------- /test/key/is.test.ts: -------------------------------------------------------------------------------- 1 | import { buildKey } from '../utilities'; 2 | import { NUMERIC, SYMBOL } from '../../src'; 3 | 4 | describe('Key', () => { 5 | describe('#is', () => { 6 | it('returns true when the provided type matches the note type', () => { 7 | const key = buildKey(5, NUMERIC); 8 | 9 | expect(key.is(NUMERIC)).toBe(true); 10 | }); 11 | 12 | it('returns false when the provided type does not match the note type', () => { 13 | const key = buildKey(5, NUMERIC); 14 | 15 | expect(key.is(SYMBOL)).toBe(false); 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /test/get_capos.test.ts: -------------------------------------------------------------------------------- 1 | import { Key } from '../src'; 2 | import { getCapos } from '../src/helpers'; 3 | 4 | describe('getCapos', () => { 5 | it('returns the applicable capos for the provided key object', () => { 6 | const key = Key.parseOrFail('Eb'); 7 | const capos = getCapos(key); 8 | 9 | expect(capos).toEqual({ 10 | 1: 'D', 3: 'C', 6: 'A', 8: 'G', 11 | }); 12 | }); 13 | 14 | it('returns the applicable capos for the provided key string', () => { 15 | const capos = getCapos('eb'); 16 | 17 | expect(capos).toEqual({ 18 | 1: 'D', 3: 'C', 6: 'A', 8: 'G', 19 | }); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /test/chord_symbol/to_chord_symbol_string.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | 3 | describe('Chord', () => { 4 | describe('chord symbol', () => { 5 | describe('toChordSymbolString', () => { 6 | describe('for a chord symbol', () => { 7 | it('converts correctly to a string', () => { 8 | expect(Chord.parse('Ebsus/G#')?.toChordSymbolString()).toEqual('Ebsus/G#'); 9 | }); 10 | 11 | it('converts correctly minor chord to a string', () => { 12 | expect(Chord.parse('Gm7/C')?.toChordSymbolString()).toEqual('Gm7/C'); 13 | }); 14 | }); 15 | }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /test/chord_solfege/to_chord_symbol_string.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | 3 | describe('Chord', () => { 4 | describe('chord solfege', () => { 5 | describe('toChordSolfegeString', () => { 6 | describe('for a chord solfege', () => { 7 | it('converts correctly to a string', () => { 8 | expect(Chord.parse('Mibsus/Sol#')?.toChordSolfegeString()).toEqual('Mibsus/Sol#'); 9 | }); 10 | 11 | it('converts correctly minor chord to a string', () => { 12 | expect(Chord.parse('Solm7/Do')?.toChordSolfegeString()).toEqual('Solm7/Do'); 13 | }); 14 | }); 15 | }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /test/numeric_chord/is_chord_symbol.test.ts: -------------------------------------------------------------------------------- 1 | import { Chord } from '../../src'; 2 | 3 | describe('Chord', () => { 4 | describe('numeric', () => { 5 | describe('isChordSymbol', () => { 6 | describe('for a numeric chord', () => { 7 | it('returns false', () => { 8 | expect(Chord.parse('1/3')?.isChordSymbol()).toBe(false); 9 | }); 10 | }); 11 | }); 12 | describe('isChordSolfege', () => { 13 | describe('for a numeric chord', () => { 14 | it('returns false', () => { 15 | expect(Chord.parse('1/3')?.isChordSolfege()).toBe(false); 16 | }); 17 | }); 18 | }); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /test/note/is.test.ts: -------------------------------------------------------------------------------- 1 | import Note from '../../src/note'; 2 | 3 | import { NUMERAL } from '../../src/constants'; 4 | import { SYMBOL } from '../../src'; 5 | 6 | describe('Note', () => { 7 | describe('#is', () => { 8 | it('returns true when the provided type equals the note type', () => { 9 | const note = new Note({ note: 'A', type: SYMBOL }); 10 | 11 | expect(note.is(SYMBOL)).toBe(true); 12 | }); 13 | 14 | it('returns false when the provided type does not equal the note type', () => { 15 | const note = new Note({ note: 'III', type: NUMERAL }); 16 | 17 | expect(note.is(SYMBOL)).toBe(false); 18 | }); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /test/note/is_one_of.test.ts: -------------------------------------------------------------------------------- 1 | import Note from '../../src/note'; 2 | 3 | describe('Note', () => { 4 | describe('#isOneOf', () => { 5 | it('checks whether the note is one of the supplied options', () => { 6 | const note = Note.parse('E'); 7 | 8 | expect(note.isOneOf('A', 6, 'E')).toBe(true); 9 | expect(note.isOneOf('F', 4, 'A')).toBe(false); 10 | }); 11 | 12 | it('checks whether the note is one of the supplied options with solfege', () => { 13 | const note = Note.parse('Mi'); 14 | 15 | expect(note.isOneOf('La', 6, 'Mi')).toBe(true); 16 | expect(note.isOneOf('Fa', 4, 'La')).toBe(false); 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /test/fixtures/song_with_intro.ts: -------------------------------------------------------------------------------- 1 | import { chordLyricsPair, createSongFromAst } from '../utilities'; 2 | 3 | // Mimic the following chord sheet: 4 | // [Intro: ][C] 5 | // Let it [Am]be, let it [C/G]be, let it [F]be, let it [C]be 6 | // [C]Whisper words of [G]wisdom, let it [F]be [C/E] [Dm] [C] 7 | 8 | export default createSongFromAst([ 9 | [ 10 | chordLyricsPair('Intro: ', ''), 11 | chordLyricsPair('C', ''), 12 | ], 13 | 14 | [ 15 | chordLyricsPair('', 'Let it '), 16 | chordLyricsPair('Am', 'be, let it '), 17 | chordLyricsPair('C/G', 'be, let it '), 18 | chordLyricsPair('F', 'be, let it '), 19 | chordLyricsPair('C', 'be'), 20 | ], 21 | ]); 22 | -------------------------------------------------------------------------------- /src/template_helpers/when_clause.ts: -------------------------------------------------------------------------------- 1 | import WhenCallback from './when_callback'; 2 | 3 | class WhenClause { 4 | condition: boolean; 5 | 6 | callback: WhenCallback; 7 | 8 | constructor(condition: any, callback: WhenCallback) { 9 | this.condition = !!condition; 10 | this.callback = callback; 11 | } 12 | 13 | evaluate(otherClauses: WhenClause[]): string { 14 | if (this.condition) { 15 | return this.callback(); 16 | } 17 | 18 | if (otherClauses.length > 0) { 19 | const [firstClause, ...rest] = otherClauses; 20 | return firstClause.evaluate(rest); 21 | } 22 | 23 | return ''; 24 | } 25 | } 26 | 27 | export default WhenClause; 28 | -------------------------------------------------------------------------------- /test/chord/clone.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | 3 | describe('Chord', () => { 4 | describe('numeral', () => { 5 | describe('clone', () => { 6 | it('assigns the right instance variables', () => { 7 | const chord = Chord.parseOrFail('bisus/#IV'); 8 | 9 | const clonedChord = chord.clone(); 10 | 11 | expect(clonedChord.suffix).toEqual(chord.suffix); 12 | expect(clonedChord.root).toEqual(chord.root); 13 | expect(clonedChord.root).not.toBe(chord.root); 14 | expect(clonedChord.bass).toEqual(chord.bass); 15 | expect(clonedChord.bass).not.toBe(chord.bass); 16 | }); 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./lib", 4 | "module": "commonjs", 5 | "target": "es2018", 6 | "moduleResolution": "node", 7 | "lib": ["es2018", "es2019", "dom"], 8 | "types": ["node", "jest"], 9 | "downlevelIteration": true, 10 | "esModuleInterop": true, 11 | "resolveJsonModule": true, 12 | "noEmitOnError": true, 13 | "noImplicitAny": false, 14 | "noFallthroughCasesInSwitch": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "strictNullChecks": true, 17 | "strict": true 18 | }, 19 | "parserOptions": { 20 | "project": "./tsconfig.json" 21 | }, 22 | "exclude": ["./unibuild.ts"] 23 | } 24 | -------------------------------------------------------------------------------- /script/build_chord_suffix_normalize_mapping.ts: -------------------------------------------------------------------------------- 1 | import { EOL } from 'os'; 2 | 3 | interface BuildOptions { 4 | force: boolean; 5 | release: boolean; 6 | } 7 | 8 | export default function buildChordSuffixNormalizeMapping(_: BuildOptions, data: string): string { 9 | const suffixes = data 10 | .split(EOL) 11 | .map((line) => { 12 | const variants = line.split(/,\s*/); 13 | return variants.reduce((acc, variant) => ({ ...acc, [variant]: variants[0] }), {}); 14 | }) 15 | .reduce((acc, set) => ({ ...acc, ...set }), {}); 16 | 17 | const json = JSON.stringify(suffixes, null, 2); 18 | return `const mapping: Record = ${json};${EOL}${EOL}export default mapping;`; 19 | } 20 | -------------------------------------------------------------------------------- /test/chord_symbol/to_numeral_string.test.ts: -------------------------------------------------------------------------------- 1 | import '../matchers'; 2 | 3 | import { Chord, Key } from '../../src'; 4 | 5 | describe('Chord', () => { 6 | describe('toNumeral', () => { 7 | describe('for a chord symbol', () => { 8 | it('returns a the numeral version', () => { 9 | const key = Key.parseOrFail('Ab'); 10 | const parsedChord = Chord.parse('Dsus/F#'); 11 | const numeral = parsedChord?.toNumeral(key); 12 | 13 | expect(numeral?.toString()).toEqual('bVsus/bVII'); 14 | }); 15 | 16 | it('accepts a string key', () => { 17 | expect(Chord.parse('Dsus/F#')?.toNumeral('Ab').toString()).toEqual('bVsus/bVII'); 18 | }); 19 | }); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /test/numeric_chord/to_numeric.test.ts: -------------------------------------------------------------------------------- 1 | import { Chord, NUMERIC } from '../../src'; 2 | 3 | describe('Chord', () => { 4 | describe('numeric', () => { 5 | describe('toNumeric', () => { 6 | it('returns a clone of the chord', () => { 7 | const originalChord = new Chord({ 8 | base: 3, 9 | modifier: '#', 10 | suffix: 'sus', 11 | bassBase: 5, 12 | bassModifier: 'b', 13 | chordType: NUMERIC, 14 | }); 15 | 16 | const numericChord = originalChord.toNumeric(); 17 | 18 | expect(numericChord.equals(originalChord)).toBeTruthy(); 19 | expect(numericChord).not.toBe(originalChord); 20 | }); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/chord_solfege/to_numeral_string.test.ts: -------------------------------------------------------------------------------- 1 | import '../matchers'; 2 | 3 | import { Chord, Key } from '../../src'; 4 | 5 | describe('Chord', () => { 6 | describe('toNumeral', () => { 7 | describe('for a chord solfege', () => { 8 | it('returns a the numeral version', () => { 9 | const key = Key.parseOrFail('Lab'); 10 | const parsedChord = Chord.parse('Resus/Fa#'); 11 | const numeral = parsedChord?.toNumeral(key); 12 | 13 | expect(numeral?.toString()).toEqual('bVsus/bVII'); 14 | }); 15 | 16 | it('accepts a string key', () => { 17 | expect(Chord.parse('Resus/Fa#')?.toNumeral('Ab').toString()).toEqual('bVsus/bVII'); 18 | }); 19 | }); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /test/note/is_chord_symbol.test.ts: -------------------------------------------------------------------------------- 1 | import Note from '../../src/note'; 2 | import { SYMBOL } from '../../src'; 3 | 4 | describe('Note', () => { 5 | describe('#isChordSymbol', () => { 6 | it('returns true if the note is a chord symbol', () => { 7 | expect(Note.parse('F').is(SYMBOL)).toBe(true); 8 | }); 9 | 10 | it('returns true if the note is a chord solfege', () => { 11 | expect(Note.parse('Fa').is(SYMBOL)).toBe(false); 12 | }); 13 | 14 | it('returns false if the note is a numeric', () => { 15 | expect(Note.parse('4').is(SYMBOL)).toBe(false); 16 | }); 17 | 18 | it('returns false if the note is a numeral', () => { 19 | expect(Note.parse('IV').is(SYMBOL)).toBe(false); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /test/note/is_numeric.test.ts: -------------------------------------------------------------------------------- 1 | import Note from '../../src/note'; 2 | 3 | import { NUMERIC } from '../../src'; 4 | 5 | describe('Note', () => { 6 | describe('#isNumeric', () => { 7 | it('returns true if the note is numeric', () => { 8 | expect(Note.parse('5').is(NUMERIC)).toBe(true); 9 | }); 10 | 11 | it('returns false if the note is a chord symbol', () => { 12 | expect(Note.parse('F').is(NUMERIC)).toBe(false); 13 | }); 14 | 15 | it('returns false if the note is a chord solfege', () => { 16 | expect(Note.parse('Fa').is(NUMERIC)).toBe(false); 17 | }); 18 | 19 | it('returns false if the note is a numeral', () => { 20 | expect(Note.parse('IV').is(NUMERIC)).toBe(false); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/note/is_numeral.test.ts: -------------------------------------------------------------------------------- 1 | import Note from '../../src/note'; 2 | import { NUMERAL, SOLFEGE } from '../../src'; 3 | 4 | describe('Note', () => { 5 | describe('#isNumeral', () => { 6 | it('returns true if the note is a numeral', () => { 7 | expect(Note.parse('V').is(NUMERAL)).toBe(true); 8 | }); 9 | 10 | it('returns false if the note is numeric', () => { 11 | expect(Note.parse('5').is(NUMERAL)).toBe(false); 12 | }); 13 | 14 | it('returns false if the note is a chord symbol', () => { 15 | expect(Note.parse('F').is(NUMERAL)).toBe(false); 16 | }); 17 | 18 | it('returns false if the note is a chord solfege', () => { 19 | expect(Note.parse('F').is(SOLFEGE)).toBe(false); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /test/chord_symbol/to_numeric.test.ts: -------------------------------------------------------------------------------- 1 | import '../matchers'; 2 | 3 | import { Chord, Key } from '../../src'; 4 | 5 | describe('Chord', () => { 6 | describe('chord symbol', () => { 7 | describe('toNumeric', () => { 8 | it('returns a the numeric version', () => { 9 | const key = Key.parse('Ab'); 10 | expect(Chord.parse('Dsus/F#')?.toNumeric(key).toString()).toEqual('b5sus/b7'); 11 | }); 12 | 13 | it('accepts a string key', () => { 14 | expect(Chord.parse('Dsus/F#')?.toNumeric('Ab').toString()).toEqual('b5sus/b7'); 15 | }); 16 | 17 | it.skip('supports a minor chord', () => { 18 | expect(Chord.parse('Gm')?.toNumeric('Bb')?.toString()).toEqual('6'); 19 | }); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /test/note/is_chord_solfege.test.ts: -------------------------------------------------------------------------------- 1 | import Note from '../../src/note'; 2 | import { SOLFEGE } from '../../src'; 3 | 4 | describe('Note', () => { 5 | describe('#isChordSolfege', () => { 6 | it('returns true if the note is a chord solfege', () => { 7 | expect(Note.parse('Fa').is(SOLFEGE)).toBe(true); 8 | }); 9 | 10 | it('returns false if the note is a chord symbol', () => { 11 | expect(Note.parse('F').is(SOLFEGE)).toBe(false); 12 | }); 13 | 14 | it('returns false if the note is a numeric', () => { 15 | expect(Note.parse('4').is(SOLFEGE)).toBe(false); 16 | }); 17 | 18 | it('returns false if the note is a numeral', () => { 19 | expect(Note.parse('IV').is(SOLFEGE)).toBe(false); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /test/numeric_chord/normalize.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | 3 | describe('Chord', () => { 4 | describe('numeric', () => { 5 | describe('normalize', () => { 6 | it('normalizes #3', () => { 7 | expect(Chord.parse('#3/#3')?.normalize().toString()).toEqual('4/4'); 8 | }); 9 | 10 | it('normalizes #7', () => { 11 | expect(Chord.parse('#7/#7')?.normalize().toString()).toEqual('1/1'); 12 | }); 13 | 14 | it('normalizes b1', () => { 15 | expect(Chord.parse('b1/b1')?.normalize().toString()).toEqual('7/7'); 16 | }); 17 | 18 | it('normalizes b4', () => { 19 | expect(Chord.parse('b4/b4')?.normalize().toString()).toEqual('3/3'); 20 | }); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/chord_symbol/constructor.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | import { SYMBOL } from '../../src'; 3 | 4 | describe('Chord', () => { 5 | describe('chord symbol', () => { 6 | describe('constructor', () => { 7 | it('assigns the right instance variables', () => { 8 | expect(Chord.parse('Ebsus/G#')?.toString()).toEqual('Ebsus/G#'); 9 | }); 10 | 11 | it('marks simple minor keys as minor', () => { 12 | expect(Chord.parse('Em')?.isMinor()).toBe(true); 13 | }); 14 | 15 | it('marks complex minor keys as minor', () => { 16 | const chord = new Chord({ base: 'E', suffix: 'm7', chordType: SYMBOL }); 17 | 18 | expect(chord.root?.minor).toBe(true); 19 | }); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /test/chord_solfege/to_numeric.test.ts: -------------------------------------------------------------------------------- 1 | import '../matchers'; 2 | 3 | import { Chord, Key } from '../../src'; 4 | 5 | describe('Chord', () => { 6 | describe('chord solfege', () => { 7 | describe('toNumeric', () => { 8 | it('returns a the numeric version', () => { 9 | const key = Key.parse('Lab'); 10 | expect(Chord.parse('Resus/Fa#')?.toNumeric(key).toString()).toEqual('b5sus/b7'); 11 | }); 12 | 13 | it('accepts a string key', () => { 14 | expect(Chord.parse('Resus/Fa#')?.toNumeric('Lab').toString()).toEqual('b5sus/b7'); 15 | }); 16 | 17 | it.skip('supports a minor chord', () => { 18 | expect(Chord.parse('Solm')?.toNumeric('Sib')?.toString()).toEqual('6'); 19 | }); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /test/key/clone.test.ts: -------------------------------------------------------------------------------- 1 | import Key from '../../src/key'; 2 | import { FLAT, NUMERAL, SHARP } from '../../src/constants'; 3 | 4 | describe('Key', () => { 5 | describe('#clone', () => { 6 | it('returns a deep copy of the key', () => { 7 | const key = new Key({ 8 | grade: 5, modifier: SHARP, preferredModifier: FLAT, minor: true, type: NUMERAL, referenceKeyGrade: 4, 9 | }); 10 | 11 | const clone = key.clone(); 12 | 13 | expect(clone.grade).toEqual(5); 14 | expect(clone.modifier).toEqual(SHARP); 15 | expect(clone.preferredModifier).toEqual(FLAT); 16 | expect(clone.minor).toEqual(true); 17 | expect(clone.type).toEqual(NUMERAL); 18 | expect(clone.referenceKeyGrade).toEqual(4); 19 | }); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /test/chord_solfege/constructor.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | import { SOLFEGE } from '../../src'; 3 | 4 | describe('Chord', () => { 5 | describe('chord solfege', () => { 6 | describe('constructor', () => { 7 | it('assigns the right instance variables', () => { 8 | expect(Chord.parse('Mibsus/Sol#')?.toString()).toEqual('Mibsus/Sol#'); 9 | }); 10 | 11 | it('marks simple minor keys as minor', () => { 12 | expect(Chord.parse('Mim')?.isMinor()).toBe(true); 13 | }); 14 | 15 | it('marks complex minor keys as minor', () => { 16 | const chord = new Chord({ base: 'Mi', suffix: 'm7', chordType: SOLFEGE }); 17 | 18 | expect(chord.root?.minor).toBe(true); 19 | }); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /test/note/equals.test.ts: -------------------------------------------------------------------------------- 1 | import { NUMERAL } from '../../src/constants'; 2 | import Note from '../../src/note'; 3 | 4 | describe('Note', () => { 5 | describe('#equals', () => { 6 | it('returns true when two notes are equal', () => { 7 | const noteA = new Note({ note: 'iii', type: NUMERAL, minor: true }); 8 | const noteB = new Note({ note: 'iii', type: NUMERAL, minor: true }); 9 | 10 | expect(noteA.equals(noteB)).toBe(true); 11 | }); 12 | 13 | it('returns false when any property differs', () => { 14 | const noteA = new Note({ note: 'iii', type: NUMERAL, minor: true }); 15 | const noteB = new Note({ note: 'iii', type: NUMERAL, minor: false }); 16 | 17 | expect(noteA.equals(noteB)).toBe(false); 18 | }); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /test/numeral_chord/use_modifier.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | 3 | describe('Chord', () => { 4 | describe('useModifier', () => { 5 | describe('for a chord without modifier', () => { 6 | it('does not change the chord', () => { 7 | expect(Chord.parse('IV/IV')?.useModifier('b').toString()).toEqual('IV/IV'); 8 | }); 9 | }); 10 | 11 | describe('for #', () => { 12 | it('changes to b', () => { 13 | expect(Chord.parse('#V/#V')?.useModifier('b').toString()).toEqual('bVI/bVI'); 14 | }); 15 | }); 16 | 17 | describe('for b', () => { 18 | it('changes to #', () => { 19 | expect(Chord.parse('bV/bV')?.useModifier('#').toString()).toEqual('#IV/#IV'); 20 | }); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/chord_symbol/to_chord_symbol.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | import { SYMBOL } from '../../src'; 3 | 4 | describe('Chord', () => { 5 | describe('chord symbol', () => { 6 | describe('toChordSymbol', () => { 7 | it('returns a clone of the chord', () => { 8 | const originalChord = new Chord({ 9 | base: 'E', 10 | modifier: 'b', 11 | suffix: 'sus', 12 | bassBase: 'G', 13 | bassModifier: '#', 14 | chordType: SYMBOL, 15 | }); 16 | 17 | const convertedChord = originalChord.toChordSymbol(); 18 | 19 | expect(convertedChord.equals(originalChord)).toBeTruthy(); 20 | expect(convertedChord).not.toBe(originalChord); 21 | }); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/chord_symbol/to_numeral.test.ts: -------------------------------------------------------------------------------- 1 | import '../matchers'; 2 | 3 | import { Chord, Key } from '../../src'; 4 | 5 | describe('Chord', () => { 6 | describe('toNumeral', () => { 7 | describe('for a chord symbol', () => { 8 | it('returns a the numeral version', () => { 9 | const key = Key.parseOrFail('Ab'); 10 | 11 | expect(Chord.parse('Dsus/F#')?.toNumeral(key).toString()).toEqual('bVsus/bVII'); 12 | }); 13 | 14 | it('accepts a string key', () => { 15 | expect(Chord.parse('Dsus/F#')?.toNumeral('Ab').toString()).toEqual('bVsus/bVII'); 16 | }); 17 | 18 | it.skip('supports a minor chord', () => { 19 | expect(Chord.parse('Gm')?.toNumeral('Bb').toString()).toEqual('vi'); 20 | }); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/chord_solfege/to_chord_symbol.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | import { SOLFEGE } from '../../src'; 3 | 4 | describe('Chord', () => { 5 | describe('chord solfege', () => { 6 | describe('toChordSolfege', () => { 7 | it('returns a clone of the chord', () => { 8 | const originalChord = new Chord({ 9 | base: 'Mi', 10 | modifier: 'b', 11 | suffix: 'sus', 12 | bassBase: 'Sol', 13 | bassModifier: '#', 14 | chordType: SOLFEGE, 15 | }); 16 | 17 | const convertedChord = originalChord.toChordSolfege(); 18 | 19 | expect(convertedChord.equals(originalChord)).toBeTruthy(); 20 | expect(convertedChord).not.toBe(originalChord); 21 | }); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/chord_solfege/to_numeral.test.ts: -------------------------------------------------------------------------------- 1 | import '../matchers'; 2 | 3 | import { Chord, Key } from '../../src'; 4 | 5 | describe('Chord', () => { 6 | describe('toNumeral', () => { 7 | describe('for a chord symbol', () => { 8 | it('returns a the numeral version', () => { 9 | const key = Key.parseOrFail('Lab'); 10 | 11 | expect(Chord.parse('Resus/Fa#')?.toNumeral(key).toString()).toEqual('bVsus/bVII'); 12 | }); 13 | 14 | it('accepts a string key', () => { 15 | expect(Chord.parse('Resus/Fa#')?.toNumeral('Lab').toString()).toEqual('bVsus/bVII'); 16 | }); 17 | 18 | it.skip('supports a minor chord', () => { 19 | expect(Chord.parse('Solm')?.toNumeral('Sib').toString()).toEqual('vi'); 20 | }); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/numeral_chord/transpose.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | 3 | describe('Chord', () => { 4 | describe('transpose', () => { 5 | describe('when delta > 0', () => { 6 | it('transposes up', () => { 7 | expect(Chord.parse('bII/#VI')?.transpose(5).toString()).toEqual('bV/#II'); 8 | }); 9 | }); 10 | 11 | describe('when delta < 0', () => { 12 | it('transposes down', () => { 13 | expect(Chord.parse('#VI/bvii')?.transpose(-4).toString()).toEqual('#IV/iv'); 14 | }); 15 | }); 16 | 17 | describe('when delta = 0', () => { 18 | it('does not change the chord', () => { 19 | const chord = Chord.parse('#vii/bI'); 20 | expect(chord?.transpose(0)).toEqual(chord); 21 | }); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/numeric_chord/use_modifier.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | 3 | describe('Chord', () => { 4 | describe('numeric', () => { 5 | describe('useModifier', () => { 6 | describe('for a chord without modifier', () => { 7 | it('does not change the chord', () => { 8 | expect(Chord.parse('4/4')?.useModifier('b').toString()).toEqual('4/4'); 9 | }); 10 | }); 11 | 12 | describe('for #', () => { 13 | it('changes to b', () => { 14 | expect(Chord.parse('#5/#5')?.useModifier('b').toString()).toEqual('b6/b6'); 15 | }); 16 | }); 17 | 18 | describe('for b', () => { 19 | it('changes to #', () => { 20 | expect(Chord.parse('b5/b5')?.useModifier('#').toString()).toEqual('#4/#4'); 21 | }); 22 | }); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /test/chord_symbol/use_modifier.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | 3 | describe('Chord', () => { 4 | describe('chord symbol', () => { 5 | describe('useModifier', () => { 6 | describe('for a chord without modifier', () => { 7 | it('does not change the chord', () => { 8 | expect(Chord.parse('F/F')?.useModifier('b').toString()).toEqual('F/F'); 9 | }); 10 | }); 11 | 12 | describe('for #', () => { 13 | it('changes to b', () => { 14 | expect(Chord.parse('G#/G#')?.useModifier('b').toString()).toEqual('Ab/Ab'); 15 | }); 16 | }); 17 | 18 | describe('for b', () => { 19 | it('changes to #', () => { 20 | expect(Chord.parse('Gb/Gb')?.useModifier('#').toString()).toEqual('F#/F#'); 21 | }); 22 | }); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /test/default_export.test.ts: -------------------------------------------------------------------------------- 1 | import chordsheetjs from '../src'; 2 | 3 | describe('default export', () => { 4 | [ 5 | 'Chord', 6 | 'ChordProParser', 7 | 'ChordSheetParser', 8 | 'UltimateGuitarParser', 9 | 'TextFormatter', 10 | 'HtmlTableFormatter', 11 | 'HtmlDivFormatter', 12 | 'ChordProFormatter', 13 | 'ChordLyricsPair', 14 | 'Line', 15 | 'Song', 16 | 'Tag', 17 | 'Comment', 18 | 'Metadata', 19 | 'Paragraph', 20 | 'Ternary', 21 | 'Composite', 22 | 'Literal', 23 | 'ChordSheetSerializer', 24 | 'CHORUS', 25 | 'INDETERMINATE', 26 | 'VERSE', 27 | 'PART', 28 | 'NONE', 29 | ].forEach((constantName) => { 30 | it(`contains ${constantName}`, () => { 31 | expect(typeof chordsheetjs[constantName]).not.toEqual('undefined'); 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/chord_sheet/comment.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents a comment. See https://www.chordpro.org/chordpro/chordpro-file-format-specification/#overview 3 | */ 4 | class Comment { 5 | content: string; 6 | 7 | constructor(content: string) { 8 | this.content = content; 9 | } 10 | 11 | /** 12 | * Indicates whether a Comment should be visible in a formatted chord sheet (except for ChordPro sheets) 13 | * @returns {boolean} 14 | */ 15 | isRenderable(): boolean { 16 | return false; 17 | } 18 | 19 | /** 20 | * Returns a deep copy of the Comment, useful when programmatically transforming a song 21 | * @returns {Comment} 22 | */ 23 | clone(): Comment { 24 | return new Comment(this.content); 25 | } 26 | 27 | toString(): string { 28 | return `Comment(content=${this.content})`; 29 | } 30 | } 31 | 32 | export default Comment; 33 | -------------------------------------------------------------------------------- /test/numeral_chord/normalize.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | 3 | describe('Chord', () => { 4 | describe('numeral', () => { 5 | describe('normalize', () => { 6 | it('normalizes #3', () => { 7 | expect(Chord.parse('#iii/#III')?.normalize().toString()).toEqual('#iii/IV'); 8 | }); 9 | 10 | it('normalizes #7', () => { 11 | expect(Chord.parse('#vii/#VII')?.normalize().toString()).toEqual('#vii/I'); 12 | }); 13 | 14 | it('normalizes b1', () => { 15 | expect(Chord.parse('bI')?.normalize().toString()).toEqual('VII'); 16 | }); 17 | 18 | it('normalizes biv', () => { 19 | const parsed = Chord.parse('biv/bIV'); 20 | const normalized = parsed?.normalize(); 21 | 22 | expect(normalized?.toString()).toEqual('biv/III'); 23 | }); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /test/chord_solfege/use_modifier.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | 3 | describe('Chord', () => { 4 | describe('chord solfege', () => { 5 | describe('useModifier', () => { 6 | describe('for a chord without modifier', () => { 7 | it('does not change the chord', () => { 8 | expect(Chord.parse('Fa/Fa')?.useModifier('b').toString()).toEqual('Fa/Fa'); 9 | }); 10 | }); 11 | 12 | describe('for #', () => { 13 | it('changes to b', () => { 14 | expect(Chord.parse('Sol#/Sol#')?.useModifier('b').toString()).toEqual('Lab/Lab'); 15 | }); 16 | }); 17 | 18 | describe('for b', () => { 19 | it('changes to #', () => { 20 | expect(Chord.parse('Solb/Solb')?.useModifier('#').toString()).toEqual('Fa#/Fa#'); 21 | }); 22 | }); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /test/chord_symbol/transpose.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | 3 | describe('Chord', () => { 4 | describe('chord symbol', () => { 5 | describe('transpose', () => { 6 | describe('when delta > 0', () => { 7 | it('transposes up', () => { 8 | expect(Chord.parse('Db/A#')?.transpose(5).toString()).toEqual('Gb/D#'); 9 | }); 10 | }); 11 | 12 | describe('when delta < 0', () => { 13 | it('transposes down', () => { 14 | expect(Chord.parse('A#/Bb')?.transpose(-4).toString()).toEqual('F#/Gb'); 15 | }); 16 | }); 17 | 18 | describe('when delta = 0', () => { 19 | it('does not change the chord', () => { 20 | const chord = Chord.parse('B#/Cb'); 21 | expect(chord?.transpose(0)).toEqual(chord); 22 | }); 23 | }); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /test/chord_solfege/transpose.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | 3 | describe('Chord', () => { 4 | describe('chord solfege', () => { 5 | describe('transpose', () => { 6 | describe('when delta > 0', () => { 7 | it('transposes up', () => { 8 | expect(Chord.parse('Reb/La#')?.transpose(5).toString()).toEqual('Solb/Re#'); 9 | }); 10 | }); 11 | 12 | describe('when delta < 0', () => { 13 | it('transposes down', () => { 14 | expect(Chord.parse('La#/Sib')?.transpose(-4).toString()).toEqual('Fa#/Solb'); 15 | }); 16 | }); 17 | 18 | describe('when delta = 0', () => { 19 | it('does not change the chord', () => { 20 | const chord = Chord.parse('Si#/Dob'); 21 | expect(chord?.transpose(0)).toEqual(chord); 22 | }); 23 | }); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /test/key/is_chord_symbol.test.ts: -------------------------------------------------------------------------------- 1 | import { buildKey } from '../utilities'; 2 | import { NUMERIC, SOLFEGE, SYMBOL } from '../../src'; 3 | 4 | describe('Key', () => { 5 | describe('isChordSymbol', () => { 6 | describe('for a symbol key', () => { 7 | it('returns true', () => { 8 | const key = buildKey('A', SYMBOL, '#'); 9 | 10 | expect(key.isChordSymbol()).toBe(true); 11 | }); 12 | }); 13 | 14 | describe('for a numeric key', () => { 15 | it('returns false', () => { 16 | const key = buildKey(5, NUMERIC, '#'); 17 | 18 | expect(key.isChordSymbol()).toBe(false); 19 | }); 20 | }); 21 | 22 | describe('for a solfege key', () => { 23 | it('returns false', () => { 24 | const key = buildKey('La', SOLFEGE, '#'); 25 | 26 | expect(key.isChordSymbol()).toBe(false); 27 | }); 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /test/key/is_chord_solfege.test.ts: -------------------------------------------------------------------------------- 1 | import { buildKey } from '../utilities'; 2 | import { NUMERIC, SOLFEGE, SYMBOL } from '../../src'; 3 | 4 | describe('Key', () => { 5 | describe('isChordSolfege', () => { 6 | describe('for a solfege key', () => { 7 | it('returns true', () => { 8 | const key = buildKey('La', SOLFEGE, '#'); 9 | 10 | expect(key.isChordSolfege()).toBe(true); 11 | }); 12 | }); 13 | 14 | describe('for a numeric key', () => { 15 | it('returns false', () => { 16 | const key = buildKey(5, NUMERIC, '#'); 17 | 18 | expect(key.isChordSolfege()).toBe(false); 19 | }); 20 | }); 21 | 22 | describe('for a symbol key', () => { 23 | it('returns false', () => { 24 | const key = buildKey('A', SYMBOL, '#'); 25 | 26 | expect(key.isChordSolfege()).toBe(false); 27 | }); 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /test/key/is_numeral.test.ts: -------------------------------------------------------------------------------- 1 | import { NUMERAL } from '../../src/constants'; 2 | import { buildKey } from '../utilities'; 3 | import { NUMERIC, SYMBOL } from '../../src'; 4 | 5 | describe('Key', () => { 6 | describe('isNumeral', () => { 7 | describe('for a symbol key', () => { 8 | it('returns false', () => { 9 | const key = buildKey('A', SYMBOL, '#'); 10 | 11 | expect(key.isNumeral()).toBe(false); 12 | }); 13 | }); 14 | 15 | describe('for a numeric key', () => { 16 | it('returns false', () => { 17 | const key = buildKey(5, NUMERIC, '#'); 18 | 19 | expect(key.isNumeral()).toBe(false); 20 | }); 21 | }); 22 | 23 | describe('for a numeral', () => { 24 | it('returns true', () => { 25 | const key = buildKey('V', NUMERAL, '#'); 26 | 27 | expect(key.isNumeral()).toBe(true); 28 | }); 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/chord_sheet/chord_pro/composite.ts: -------------------------------------------------------------------------------- 1 | import Evaluatable from './evaluatable'; 2 | import Metadata from '../metadata'; 3 | 4 | class Composite extends Evaluatable { 5 | expressions: Evaluatable[] = []; 6 | 7 | variable: string | null; 8 | 9 | constructor(expressions: Evaluatable[], variable: string | null = null) { 10 | super(); 11 | this.expressions = expressions; 12 | this.variable = variable; 13 | } 14 | 15 | evaluate(metadata: Metadata, metadataSeparator: string): string { 16 | return this.expressions.map((expression) => ( 17 | expression.evaluate(metadata, metadataSeparator, this.variable) 18 | )).join(''); 19 | } 20 | 21 | isRenderable(): boolean { 22 | return true; 23 | } 24 | 25 | clone(): Composite { 26 | return new Composite( 27 | this.expressions.map((expression) => expression.clone()), 28 | this.variable, 29 | ); 30 | } 31 | } 32 | 33 | export default Composite; 34 | -------------------------------------------------------------------------------- /test/integration/serialize.test.ts: -------------------------------------------------------------------------------- 1 | import { ChordSheetSerializer } from '../../src'; 2 | import { exampleSongSolfege, exampleSongSymbol } from '../fixtures/song'; 3 | import { serializedSongSolfege, serializedSongSymbol } from '../fixtures/serialized_song'; 4 | 5 | describe('serializing a song', () => { 6 | it('serializes a symbol song object', () => { 7 | expect(new ChordSheetSerializer().serialize(exampleSongSymbol)).toEqual(serializedSongSymbol); 8 | }); 9 | 10 | it('deserializes a symbol song object', () => { 11 | expect(new ChordSheetSerializer().deserialize(serializedSongSymbol)).toEqual(exampleSongSymbol); 12 | }); 13 | 14 | it('serializes a solfege song object', () => { 15 | expect(new ChordSheetSerializer().serialize(exampleSongSolfege)).toEqual(serializedSongSolfege); 16 | }); 17 | 18 | it('deserializes a solfege song object', () => { 19 | expect(new ChordSheetSerializer().deserialize(serializedSongSolfege)).toEqual(exampleSongSolfege); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /script/build_chord_suffix_grammar.ts: -------------------------------------------------------------------------------- 1 | import { EOL } from 'os'; 2 | 3 | interface BuildOptions { 4 | force: boolean; 5 | release: boolean; 6 | } 7 | 8 | export default function buildChordSuffixGrammar(_: BuildOptions, data: string): string { 9 | const suffixes: string[] = data 10 | .split(EOL) 11 | .filter((s) => s.trim().length > 0) 12 | .flatMap((line) => line.split(/,\s*/)) 13 | .sort((a, b) => b.length - a.length) 14 | .map((suffix) => `"${suffix}"`); 15 | 16 | const groups: string[][] = []; 17 | 18 | const copy = [...suffixes]; 19 | 20 | while (copy.length > 0) { 21 | const chunk = copy.splice(0, 100) as string[]; 22 | groups.push(chunk); 23 | } 24 | 25 | const groupsGrammar = groups.map((groupSuffixes, i) => ( 26 | `ChordSuffix${i}\n = ${groupSuffixes.join('\n / ')}\n` 27 | )); 28 | 29 | return ` 30 | ChordSuffix 31 | = (${groupsGrammar.map((_grammar, i) => `ChordSuffix${i}`).join(' / ')})? 32 | 33 | ${groupsGrammar.join('\n')} 34 | `; 35 | } 36 | -------------------------------------------------------------------------------- /test/get_keys.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint max-len: 0 */ 2 | 3 | import { getKeys } from '../src/helpers'; 4 | 5 | describe('getKeys', () => { 6 | it('returns the applicable keys for a major key symbol', () => { 7 | expect(getKeys('A')).toEqual(['A', 'Bb', 'B', 'C', 'C#', 'Db', 'D', 'Eb', 'E', 'F', 'F#', 'Gb', 'G', 'G#', 'Ab']); 8 | }); 9 | 10 | it('returns the applicable keys for a minor key symbol', () => { 11 | expect(getKeys('Dm')).toEqual(['F#m', 'Gm', 'G#m', 'Am', 'Bbm', 'Bm', 'Cm', 'C#m', 'Dm', 'D#m', 'Ebm', 'Em', 'Fm']); 12 | }); 13 | 14 | it('returns the applicable keys for a major key solfege', () => { 15 | expect(getKeys('La')).toEqual(['La', 'Sib', 'Si', 'Do', 'Do#', 'Reb', 'Re', 'Mib', 'Mi', 'Fa', 'Fa#', 'Solb', 'Sol', 'Sol#', 'Lab']); 16 | }); 17 | 18 | it('returns the applicable keys for a minor key solfege', () => { 19 | expect(getKeys('Rem')).toEqual(['Fa#m', 'Solm', 'Sol#m', 'Lam', 'Sibm', 'Sim', 'Dom', 'Do#m', 'Rem', 'Re#m', 'Mibm', 'Mim', 'Fam']); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /test/jest.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { ContentType } from '../src/serialized_types'; 3 | import { TernaryProperties } from '../src/chord_sheet/chord_pro/ternary'; 4 | 5 | declare global { 6 | namespace jest { 7 | interface Matchers { 8 | toBeKey({ note, modifier, minor: boolean }): jest.CustomMatcherResult; 9 | 10 | toBeChordLyricsPair(chords: string, lyrics: string, annotation?: string): jest.CustomMatcherResult; 11 | 12 | toBeLiteral(string: string): jest.CustomMatcherResult; 13 | 14 | toBeSection(_type: ContentType, _contents: string): jest.CustomMatcherResult; 15 | 16 | toBeTernary(properties: TernaryProperties): jest.CustomMatcherResult; 17 | 18 | toBeComment(_contents: string): jest.CustomMatcherResult; 19 | 20 | toBeTag(_name: string, _value?: string, _selector?: string): jest.CustomMatcherResult; 21 | 22 | toBeSoftLineBreak(): jest.CustomMatcherResult; 23 | } 24 | } 25 | } 26 | 27 | export {}; 28 | -------------------------------------------------------------------------------- /test/chord_symbol/normalize.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | 3 | describe('Chord', () => { 4 | describe('chord symbol', () => { 5 | describe('normalize', () => { 6 | it('normalizes E#', () => { 7 | expect(Chord.parse('E#/E#')?.normalize().toString()).toEqual('F/F'); 8 | }); 9 | 10 | it('normalizes B#', () => { 11 | expect(Chord.parse('B#/B#')?.normalize().toString()).toEqual('C/C'); 12 | }); 13 | 14 | it('normalizes Cb', () => { 15 | expect(Chord.parse('Cb/Cb')?.normalize().toString()).toEqual('B/B'); 16 | }); 17 | 18 | it('normalizes Fb', () => { 19 | expect(Chord.parse('Fb/Fb')?.normalize().toString()).toEqual('E/E'); 20 | }); 21 | 22 | it('normalizes Em/A#', () => { 23 | expect(Chord.parse('Em/A#')?.normalize().toString()).toEqual('Em/Bb'); 24 | }); 25 | 26 | it('normalizes D/F#', () => { 27 | expect(Chord.parse('D/F#')?.normalize().toString()).toEqual('D/F#'); 28 | }); 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /test/integration/use_modifier.test.ts: -------------------------------------------------------------------------------- 1 | import { heredoc } from '../utilities'; 2 | import { ChordProParser, TextFormatter } from '../../src'; 3 | 4 | describe('changing the song modifiers', () => { 5 | it('can change to #', () => { 6 | const chordpro = 'Let it [D#m]be let it [Gb]be'; 7 | 8 | const changedSheet = heredoc` 9 | D#m F# 10 | Let it be let it be 11 | `; 12 | 13 | const song = new ChordProParser().parse(chordpro); 14 | const updatedSong = song.useModifier('#'); 15 | 16 | expect(new TextFormatter().format(updatedSong)).toEqual(changedSheet); 17 | }); 18 | 19 | it('can change to b', () => { 20 | const chordpro = 'Let it [D#m]be let it [Gb]be'; 21 | 22 | const changedSheet = heredoc` 23 | Ebm Gb 24 | Let it be let it be 25 | `; 26 | 27 | const song = new ChordProParser().parse(chordpro); 28 | const updatedSong = song.useModifier('b'); 29 | 30 | expect(new TextFormatter().format(updatedSong)).toEqual(changedSheet); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /test/numeric_chord/to_chord_symbol.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | import { Key } from '../../src'; 3 | 4 | describe('Chord', () => { 5 | describe('numeric', () => { 6 | describe('toChordSymbol', () => { 7 | it('returns a chord symbol version', () => { 8 | const key = Key.parse('Ab'); 9 | expect(Chord.parse('b5sus/#7')?.toChordSymbol(key).toString()).toEqual('Dsus/G#'); 10 | }); 11 | 12 | it('accepts a string key', () => { 13 | expect(Chord.parse('b5sus/#7')?.toChordSymbol('Ab').toString()).toEqual('Dsus/G#'); 14 | }); 15 | }); 16 | describe('toChordSolfege', () => { 17 | it('returns a chord solfege version', () => { 18 | const key = Key.parse('Lab'); 19 | expect(Chord.parse('b5sus/#7')?.toChordSolfege(key).toString()).toEqual('Resus/Sol#'); 20 | }); 21 | 22 | it('accepts a string key', () => { 23 | expect(Chord.parse('b5sus/#7')?.toChordSolfege('Lab').toString()).toEqual('Resus/Sol#'); 24 | }); 25 | }); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /test/key/distance.test.ts: -------------------------------------------------------------------------------- 1 | import { Key } from '../../src'; 2 | 3 | describe('Key', () => { 4 | describe('distance', () => { 5 | it('calculates the distance between two sharp keys', () => { 6 | expect(Key.distance('G#', 'D#')).toEqual(7); 7 | }); 8 | 9 | it('calculates the distance between two flat keys', () => { 10 | expect(Key.distance('Ab', 'Eb')).toEqual(7); 11 | }); 12 | 13 | it('calculates the distance between a flat key and a sharp key', () => { 14 | expect(Key.distance('Ab', 'D#')).toEqual(7); 15 | }); 16 | 17 | it('calculates the distance between a sharp key and a flat key', () => { 18 | expect(Key.distance('G#', 'Eb')).toEqual(7); 19 | }); 20 | 21 | it('calculate the distance between a Key object and a string', () => { 22 | expect(Key.distance(Key.parseOrFail('G#'), 'Eb')).toEqual(7); 23 | }); 24 | 25 | it('calculate the distance between a string and a Key object', () => { 26 | expect(Key.distance('G#', Key.parseOrFail('Eb'))).toEqual(7); 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /test/numeral_chord/to_chord_symbol.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | import { Key } from '../../src'; 3 | 4 | describe('Chord', () => { 5 | describe('numeral', () => { 6 | describe('toChordSymbol', () => { 7 | it('returns a chord symbol version', () => { 8 | const key = Key.parse('Ab'); 9 | expect(Chord.parse('bVsus/#VII')?.toChordSymbol(key).toString()).toEqual('Dsus/G#'); 10 | }); 11 | 12 | it('accepts a string key', () => { 13 | expect(Chord.parse('bVsus/#VII')?.toChordSymbol('Ab').toString()).toEqual('Dsus/G#'); 14 | }); 15 | }); 16 | 17 | describe('toChordSolfege', () => { 18 | it('returns a chord solfege version', () => { 19 | const key = Key.parse('Lab'); 20 | expect(Chord.parse('bVsus/#VII')?.toChordSolfege(key).toString()).toEqual('Resus/Sol#'); 21 | }); 22 | 23 | it('accepts a string key', () => { 24 | expect(Chord.parse('bVsus/#VII')?.toChordSolfege('Lab').toString()).toEqual('Resus/Sol#'); 25 | }); 26 | }); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /test/note/to_numeric.test.ts: -------------------------------------------------------------------------------- 1 | import Note from '../../src/note'; 2 | 3 | import { NUMERIC } from '../../src'; 4 | 5 | describe('Note', () => { 6 | describe('#toNumeric', () => { 7 | it('converts from a numeral', () => { 8 | const numeral = Note.parse('VI'); 9 | const numeric = numeral.toNumeric(); 10 | 11 | expect(numeric.is(NUMERIC)).toBe(true); 12 | expect(numeric.note).toEqual(6); 13 | }); 14 | 15 | it('clones a numeric', () => { 16 | const numeric = Note.parse(6); 17 | const clone = numeric.toNumeric(); 18 | 19 | expect(clone).not.toBe(numeric); 20 | expect(clone.note).toEqual(6); 21 | }); 22 | 23 | it('errors for other note types', () => { 24 | const chordSymbol = Note.parse('E'); 25 | const chordSolfege = Note.parse('Mi'); 26 | 27 | expect(() => chordSymbol.toNumeric()).toThrow('Converting a symbol note to numeric is not supported'); 28 | expect(() => chordSolfege.toNumeric()).toThrow('Converting a solfege note to numeric is not supported'); 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /test/chord_solfege/normalize.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | 3 | describe('Chord', () => { 4 | describe('chord solfege', () => { 5 | describe('normalize', () => { 6 | it('normalizes Mi#', () => { 7 | expect(Chord.parse('Mi#/Mi#')?.normalize().toString()).toEqual('Fa/Fa'); 8 | }); 9 | 10 | it('normalizes Si#', () => { 11 | expect(Chord.parse('Si#/Si#')?.normalize().toString()).toEqual('Do/Do'); 12 | }); 13 | 14 | it('normalizes Dob', () => { 15 | expect(Chord.parse('Dob/Dob')?.normalize().toString()).toEqual('Si/Si'); 16 | }); 17 | 18 | it('normalizes Fab', () => { 19 | expect(Chord.parse('Fab/Fab')?.normalize().toString()).toEqual('Mi/Mi'); 20 | }); 21 | 22 | it('normalizes Mim/La#', () => { 23 | expect(Chord.parse('Mim/La#')?.normalize().toString()).toEqual('Mim/Sib'); 24 | }); 25 | 26 | it('normalizes Re/Fa#', () => { 27 | expect(Chord.parse('Re/Fa#')?.normalize().toString()).toEqual('Re/Fa#'); 28 | }); 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /test/note/to_numeral.test.ts: -------------------------------------------------------------------------------- 1 | import Note from '../../src/note'; 2 | 3 | import { NUMERAL } from '../../src'; 4 | 5 | describe('Note', () => { 6 | describe('#toNumeral', () => { 7 | it('converts from a numeric', () => { 8 | const numeric = Note.parse(5); 9 | const numeral = numeric.toNumeral(); 10 | 11 | expect(numeral.is(NUMERAL)).toBe(true); 12 | expect(numeral.note).toEqual('V'); 13 | }); 14 | 15 | it('clones a numeral', () => { 16 | const numeral = Note.parse('III'); 17 | const clone = numeral.toNumeral(); 18 | 19 | expect(clone).not.toBe(numeral); 20 | expect(clone.note).toEqual('III'); 21 | }); 22 | 23 | it('errors for other note types', () => { 24 | const chordSymbol = Note.parse('E'); 25 | const chordSolfege = Note.parse('Mi'); 26 | 27 | expect(() => chordSymbol.toNumeral()).toThrow('Converting a symbol note to numeral is not supported'); 28 | expect(() => chordSolfege.toNumeral()).toThrow('Converting a solfege note to numeral is not supported'); 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /test/parser/parser_helpers.test.ts: -------------------------------------------------------------------------------- 1 | import { chopFirstWord } from '../../src/parser/parser_helpers'; 2 | import { eachTestCase } from '../utilities'; 3 | 4 | describe('parser helpers', () => { 5 | describe('chopFirstWord', () => { 6 | eachTestCase(` 7 | # | string | outcome | 8 | - | ----------------- | ----------------------- | 9 | 1 | "one" | ["one", null ] | 10 | 2 | " one" | ["", "one" ] | 11 | 3 | "one " | ["one ", null ] | 12 | 4 | " one " | ["", "one " ] | 13 | 5 | "one two" | ["one", "two" ] | 14 | 6 | " one two" | ["", "one two" ] | 15 | 7 | "one two " | ["one", "two " ] | 16 | 8 | " one two " | ["", "one two " ] | 17 | 8 | " one two three" | ["", "one two three" ] | 18 | 8 | " one two three " | ["", "one two three " ] | 19 | `, ({ string, outcome }) => { 20 | expect(chopFirstWord(string)).toEqual(outcome); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /bin/check_deprecated_packages: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echo "🔍 Checking for deprecated packages..." 5 | 6 | packages=$(yarn info --all --json) 7 | 8 | if [[ -z "$packages" ]]; then 9 | echo "❌ Error: Could not retrieve package info." 10 | exit 1 11 | fi 12 | 13 | deprecated_packages=() 14 | deprecated_messages=() 15 | 16 | while IFS= read -r package_info; do 17 | if [[ -n "$package_info" ]]; then 18 | package_name=$(echo "$package_info" | jq -r '.value') 19 | deprecation_msg=$(yarn npm info --json "$package_name" | jq -r '.deprecated // empty' 2>/dev/null) 20 | 21 | if [[ -n "$deprecation_msg" ]]; then 22 | deprecated_packages+=("$package_name") 23 | deprecated_messages+=("$deprecation_msg") 24 | fi 25 | fi 26 | done <<< "$packages" 27 | 28 | if [[ ${#deprecated_packages[@]} -gt 0 ]]; then 29 | echo "⚠️ Deprecated packages found:" 30 | for i in "${!deprecated_packages[@]}"; do 31 | echo " - ${deprecated_packages[$i]}: ${deprecated_messages[$i]}" 32 | done 33 | exit 1 34 | else 35 | echo "✅ No deprecated packages found." 36 | exit 0 37 | fi 38 | -------------------------------------------------------------------------------- /test/numeric_chord/transpose.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | import { NUMERIC } from '../../src'; 3 | 4 | describe('Chord', () => { 5 | describe('numeric', () => { 6 | describe('transpose', () => { 7 | describe('when delta > 0', () => { 8 | it('transposes up', () => { 9 | expect(Chord.parse('b2/#6')?.transpose(5).toString()).toEqual('b5/#2'); 10 | }); 11 | }); 12 | 13 | describe('when delta < 0', () => { 14 | it('transposes down', () => { 15 | expect(Chord.parse('#6/b7')?.transpose(-4).toString()).toEqual('#4/b5'); 16 | }); 17 | }); 18 | 19 | describe('when delta = 0', () => { 20 | it('does not change the chord', () => { 21 | const chord = new Chord({ 22 | base: 7, 23 | modifier: '#', 24 | suffix: null, 25 | bassBase: 1, 26 | bassModifier: 'b', 27 | chordType: NUMERIC, 28 | }); 29 | 30 | expect(chord.transpose(0)).toEqual(chord); 31 | }); 32 | }); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /test/fixtures/ultimate_guitar_chordsheet_expected_chordpro_format.txt: -------------------------------------------------------------------------------- 1 | {start_of_verse: Verse 1} 2 | [C]Lorem [G]ipsum dolor sit [Am]amet, 3 | [C]consectetur [G]adipiscing [F]elit. 4 | {end_of_verse} 5 | 6 | 7 | {start_of_chorus: Chorus} 8 | Tortor [Am]posuere [C]ac ut [F]consequat semper viverra [C]nam. 9 | [C]Non nisi est [G]sit amet facilisis [F]magna etiam tempor orci. 10 | {end_of_chorus} 11 | 12 | 13 | {start_of_verse: Verse 2} 14 | [C]Habitasse [G]platea dictumst [Am]vestibulum rhoncus. 15 | [C]Tristique et [G]egestas quis [F]ipsum. 16 | {end_of_verse} 17 | 18 | 19 | {start_of_chorus: Chorus} 20 | Tortor [Am]posuere [C]ac ut [F]consequat semper viverra [C]nam. 21 | [C]Non nisi est [G]sit amet facilisis [F]magna etiam tempor orci. 22 | {end_of_chorus} 23 | 24 | 25 | {comment: Instrumental} 26 | [F][C][Dm] 27 | 28 | 29 | {comment: Solo} 30 | [C][G][Am] 31 | 32 | 33 | {start_of_chorus: Chorus} 34 | Tortor [Am]posuere [C]ac ut [F]consequat semper viverra [C]nam. 35 | [C]Non nisi est [G]sit amet facilisis [F]magna etiam tempor orci. 36 | {end_of_chorus} 37 | 38 | 39 | {comment: Outro} 40 | [F][C][Dm] 41 | -------------------------------------------------------------------------------- /test/key/is_numeric.test.ts: -------------------------------------------------------------------------------- 1 | import { buildKey } from '../utilities'; 2 | import { NUMERAL, SOLFEGE } from '../../src/constants'; 3 | import { NUMERIC, SYMBOL } from '../../src'; 4 | 5 | describe('Key', () => { 6 | describe('isNumeric', () => { 7 | describe('for a symbol key', () => { 8 | it('returns false', () => { 9 | const key = buildKey('A', SYMBOL, '#'); 10 | 11 | expect(key.isNumeric()).toBe(false); 12 | }); 13 | }); 14 | 15 | describe('for a solfege key', () => { 16 | it('returns false', () => { 17 | const key = buildKey('La', SOLFEGE, '#'); 18 | 19 | expect(key.isNumeric()).toBe(false); 20 | }); 21 | }); 22 | 23 | describe('for a numeric key', () => { 24 | it('returns true', () => { 25 | const key = buildKey(5, NUMERIC, '#'); 26 | 27 | expect(key.isNumeric()).toBe(true); 28 | }); 29 | }); 30 | 31 | describe('for a numeral', () => { 32 | it('returns false', () => { 33 | const key = buildKey('V', NUMERAL, '#'); 34 | 35 | expect(key.isNumeric()).toBe(false); 36 | }); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/parser/parser_warning.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents a parser warning, currently only used by ChordProParser. 3 | */ 4 | class ParserWarning { 5 | /** 6 | * The warning message 7 | * @member 8 | * @type {string} 9 | */ 10 | message: string; 11 | 12 | /** 13 | * The chord sheet line number on which the warning occurred 14 | * @member 15 | * @type {number} 16 | */ 17 | lineNumber: number | null = null; 18 | 19 | /** 20 | * The chord sheet column on which the warning occurred 21 | * @member 22 | * @type {number} 23 | */ 24 | column: number | null = null; 25 | 26 | /** 27 | * @hideconstructor 28 | */ 29 | constructor(message: string, lineNumber: number | null, column: number | null) { 30 | this.message = message; 31 | this.lineNumber = lineNumber; 32 | this.column = column; 33 | } 34 | 35 | /** 36 | * Returns a stringified version of the warning 37 | * @returns {string} The string warning 38 | */ 39 | toString(): string { 40 | return `Warning: ${this.message} on line ${this.lineNumber || '?'} column ${this.column || '?'}`; 41 | } 42 | } 43 | 44 | export default ParserWarning; 45 | -------------------------------------------------------------------------------- /test/key/equals.test.ts: -------------------------------------------------------------------------------- 1 | import { NUMERIC } from '../../src'; 2 | import { buildKey } from '../utilities'; 3 | 4 | describe('Key', () => { 5 | describe('#equals', () => { 6 | it('returns true when both the note and modifier are equal', () => { 7 | const keyA = buildKey(3, NUMERIC, '#', true); 8 | const keyB = buildKey(3, NUMERIC, '#', true); 9 | 10 | expect(keyA.equals(keyB)).toBe(true); 11 | }); 12 | 13 | it('returns false when the note differs', () => { 14 | const keyA = buildKey(3, NUMERIC, '#', true); 15 | const keyB = buildKey(5, NUMERIC, '#', true); 16 | 17 | expect(keyA).not.toEqual(keyB); 18 | }); 19 | 20 | it('returns false when minor differs', () => { 21 | const keyA = buildKey(3, NUMERIC, '#', true); 22 | const keyB = buildKey(3, NUMERIC, '#', false); 23 | 24 | expect(keyA.equals(keyB)).toBe(false); 25 | }); 26 | 27 | it('returns false when the modifier differs', () => { 28 | const keyA = buildKey(3, NUMERIC, '#', true); 29 | const keyB = buildKey(3, NUMERIC, 'b', true); 30 | 31 | expect(keyA.equals(keyB)).toBe(false); 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /test/fixtures/ultimate_guitar_chordsheet.txt: -------------------------------------------------------------------------------- 1 | [Verse 1] 2 | C G Am 3 | Lorem ipsum dolor sit amet, 4 | C G F 5 | consectetur adipiscing elit. 6 | 7 | 8 | [Chorus] 9 | Am C F C 10 | Tortor posuere ac ut consequat semper viverra nam. 11 | C G F 12 | Non nisi est sit amet facilisis magna etiam tempor orci. 13 | 14 | 15 | [Verse 2] 16 | C G Am 17 | Habitasse platea dictumst vestibulum rhoncus. 18 | C G F 19 | Tristique et egestas quis ipsum. 20 | 21 | 22 | [Chorus] 23 | Am C F C 24 | Tortor posuere ac ut consequat semper viverra nam. 25 | C G F 26 | Non nisi est sit amet facilisis magna etiam tempor orci. 27 | 28 | 29 | [Instrumental] 30 | F C Dm 31 | 32 | 33 | [Solo] 34 | C G Am 35 | 36 | 37 | [Chorus] 38 | Am C F C 39 | Tortor posuere ac ut consequat semper viverra nam. 40 | C G F 41 | Non nisi est sit amet facilisis magna etiam tempor orci. 42 | 43 | 44 | [Outro] 45 | F C Dm 46 | -------------------------------------------------------------------------------- /src/formatter/html_div_formatter.ts: -------------------------------------------------------------------------------- 1 | import template from './templates/html_div_formatter'; 2 | 3 | import HtmlFormatter, { CSS, HtmlTemplateCssClasses, Template } from './html_formatter'; 4 | 5 | function defaultCss(cssClasses: HtmlTemplateCssClasses): CSS { 6 | const { 7 | chord, 8 | lyrics, 9 | paragraph, 10 | row, 11 | } = cssClasses; 12 | 13 | return { 14 | [`.${chord}:not(:last-child)`]: { 15 | paddingRight: '10px', 16 | }, 17 | 18 | [`.${paragraph}`]: { 19 | marginBottom: '1em', 20 | }, 21 | 22 | [`.${row}`]: { 23 | display: 'flex', 24 | }, 25 | 26 | [`.${chord}:after`]: { 27 | content: '\'\\200b\'', 28 | }, 29 | 30 | [`.${lyrics}:after`]: { 31 | content: '\'\\200b\'', 32 | }, 33 | }; 34 | } 35 | 36 | /** 37 | * Formats a song into HTML. It uses DIVs to align lyrics with chords, which makes it useful for responsive web pages. 38 | */ 39 | class HtmlDivFormatter extends HtmlFormatter { 40 | get template(): Template { 41 | return template; 42 | } 43 | 44 | get defaultCss(): CSS { 45 | return defaultCss(this.cssClasses); 46 | } 47 | } 48 | 49 | export default HtmlDivFormatter; 50 | -------------------------------------------------------------------------------- /src/template_helpers/when.ts: -------------------------------------------------------------------------------- 1 | import WhenCallback from './when_callback'; 2 | import WhenClause from './when_clause'; 3 | 4 | class When { 5 | condition = false; 6 | 7 | clauses: WhenClause[] = []; 8 | 9 | constructor(condition: any, thenCallback?: WhenCallback) { 10 | this.add(condition, thenCallback); 11 | } 12 | 13 | then(thenCallback: WhenCallback): When { 14 | return this.add(this.condition, thenCallback); 15 | } 16 | 17 | elseWhen(condition: any, callback?: WhenCallback): When { 18 | return this.add(condition, callback); 19 | } 20 | 21 | else(callback: WhenCallback): When { 22 | return this.add(true, callback); 23 | } 24 | 25 | private add(condition: any, callback?: WhenCallback): When { 26 | this.condition = !!condition; 27 | 28 | if (callback) { 29 | this.clauses.push(new WhenClause(condition, callback)); 30 | } 31 | 32 | return this; 33 | } 34 | 35 | toString(): string { 36 | const [firstClause, ...rest] = this.clauses; 37 | 38 | if (firstClause) { 39 | return firstClause.evaluate(rest); 40 | } 41 | 42 | throw new Error('Expected at least one .then() clause'); 43 | } 44 | } 45 | 46 | export default When; 47 | -------------------------------------------------------------------------------- /test/chord_sheet/chord_pro/composite.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Composite, 3 | Literal, 4 | Metadata, 5 | Ternary, 6 | } from '../../../src'; 7 | 8 | describe('Composite', () => { 9 | describe('#evaluate', () => { 10 | it('evaluates string expressions', () => { 11 | const literal = new Literal('Value present'); 12 | const composite = new Composite([literal]); 13 | const metadata = new Metadata(); 14 | 15 | expect(composite.evaluate(metadata, ', ')).toEqual('Value present'); 16 | }); 17 | 18 | it('evaluates ternaries', () => { 19 | const ternary = new Ternary({ variable: 'composer' }); 20 | const composite = new Composite([ternary]); 21 | const metadata = new Metadata({ composer: 'John' }); 22 | 23 | expect(composite.evaluate(metadata, ', ')).toEqual('John'); 24 | }); 25 | 26 | it('evaluates mixed expressions', () => { 27 | const ternary = new Ternary({ variable: 'composer' }); 28 | const literal = new Literal('Composer: '); 29 | const composite = new Composite([literal, ternary]); 30 | const metadata = new Metadata({ composer: 'John' }); 31 | 32 | expect(composite.evaluate(metadata, ', ')).toEqual('Composer: John'); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /test/chord_sheet/font.test.ts: -------------------------------------------------------------------------------- 1 | import Font from '../../src/chord_sheet/font'; 2 | import FontSize from '../../src/chord_sheet/font_size'; 3 | 4 | describe('Font', () => { 5 | describe('#toCssString', () => { 6 | it('serializes colour', () => { 7 | const font = new Font({ colour: 'red' }); 8 | 9 | expect(font.toCssString()).toEqual('color: red'); 10 | }); 11 | 12 | it('serializes font', () => { 13 | const font = new Font({ font: '"Times new Roman", serif' }); 14 | 15 | expect(font.toCssString()).toEqual('font-family: \'Times new Roman\', serif'); 16 | }); 17 | 18 | it('serializes absolute size', () => { 19 | const font = new Font({ size: new FontSize(30, 'px') }); 20 | 21 | expect(font.toCssString()).toEqual('font-size: 30px'); 22 | }); 23 | 24 | it('serializes percentual size', () => { 25 | const font = new Font({ size: new FontSize(30, '%') }); 26 | 27 | expect(font.toCssString()).toEqual('font-size: 30%'); 28 | }); 29 | 30 | it('serializes font with color', () => { 31 | const font = new Font({ colour: 'red', font: 'sans-serif', size: new FontSize(30, '%') }); 32 | 33 | expect(font.toCssString()).toEqual('color: red; font: 30% sans-serif'); 34 | }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /test/chord_sheet/chord_lyrics_pair.test.ts: -------------------------------------------------------------------------------- 1 | import { ChordLyricsPair } from '../../src'; 2 | 3 | describe('ChordLyricsPair', () => { 4 | describe('#clone', () => { 5 | it('returns a clone of the chord lyrics pair', () => { 6 | const chordLyricsPair = new ChordLyricsPair('C', 'Let it'); 7 | const clonedChordLyricsPair = chordLyricsPair.clone(); 8 | 9 | expect(clonedChordLyricsPair.chords).toEqual('C'); 10 | expect(clonedChordLyricsPair.lyrics).toEqual('Let it'); 11 | }); 12 | }); 13 | 14 | describe('#isRenderable', () => { 15 | it('returns true', () => { 16 | const chordLyricsPair = new ChordLyricsPair(); 17 | 18 | expect(chordLyricsPair.isRenderable()).toBe(true); 19 | }); 20 | }); 21 | 22 | describe('#transpose', () => { 23 | it('transposes and normalizes the chord', () => { 24 | const chordLyricsPair = new ChordLyricsPair('F', 'Let it'); 25 | const transposedPair = chordLyricsPair.transpose(1, 'Db'); 26 | 27 | expect(transposedPair.chords).toEqual('Gb'); 28 | }); 29 | 30 | it('can transpose without key', () => { 31 | const chordLyricsPair = new ChordLyricsPair('F', 'Let it'); 32 | const transposedPair = chordLyricsPair.transpose(1); 33 | 34 | expect(transposedPair.chords).toEqual('F#'); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /test/numeric_chord/to_string.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | 3 | describe('Chord', () => { 4 | describe('numeric', () => { 5 | describe('toString', () => { 6 | describe('with bass note', () => { 7 | it('returns the right string representation', () => { 8 | expect(Chord.parse('b1sus/#3')?.toNumericString()).toEqual('b1sus/#3'); 9 | }); 10 | 11 | describe('without bass modifier', () => { 12 | it('returns the right string representation', () => { 13 | expect(Chord.parse('b1sus/3')?.toNumericString()).toEqual('b1sus/3'); 14 | }); 15 | }); 16 | }); 17 | 18 | describe('without bass note', () => { 19 | it('returns the right string representation', () => { 20 | expect(Chord.parse('b1sus')?.toNumericString()).toEqual('b1sus'); 21 | }); 22 | }); 23 | 24 | describe('without modifier', () => { 25 | it('returns the right string representation', () => { 26 | expect(Chord.parse('1sus')?.toNumericString()).toEqual('1sus'); 27 | }); 28 | }); 29 | 30 | describe('without suffix', () => { 31 | it('returns the right string representation', () => { 32 | expect(Chord.parse('b1')?.toNumericString()).toEqual('b1'); 33 | }); 34 | }); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /test/numeral_chord/to_string.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | 3 | describe('Chord', () => { 4 | describe('numeral', () => { 5 | describe('toString', () => { 6 | describe('with bass note', () => { 7 | it('returns the right string representation', () => { 8 | expect(Chord.parse('bisus/#III')?.toNumeralString()).toEqual('bisus/#III'); 9 | }); 10 | 11 | describe('without bass modifier', () => { 12 | it('returns the right string representation', () => { 13 | expect(Chord.parse('bIsus/iii')?.toNumeralString()).toEqual('bIsus/iii'); 14 | }); 15 | }); 16 | }); 17 | 18 | describe('without bass note', () => { 19 | it('returns the right string representation', () => { 20 | expect(Chord.parse('bIsus')?.toNumeralString()).toEqual('bIsus'); 21 | }); 22 | }); 23 | 24 | describe('without modifier', () => { 25 | it('returns the right string representation', () => { 26 | expect(Chord.parse('Isus')?.toNumeralString()).toEqual('Isus'); 27 | }); 28 | }); 29 | 30 | describe('without suffix', () => { 31 | it('returns the right string representation', () => { 32 | expect(Chord.parse('bI')?.toNumeralString()).toEqual('bI'); 33 | }); 34 | }); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /test/note/parse.test.ts: -------------------------------------------------------------------------------- 1 | import Note from '../../src/note'; 2 | 3 | describe('Note', () => { 4 | describe('parse', () => { 5 | it('converts a valid lower case chord letter to upper case', () => { 6 | expect(Note.parse('f').note).toEqual('F'); 7 | }); 8 | 9 | it('uses a valid upper case chord letter as-is', () => { 10 | expect(Note.parse('A').note).toEqual('A'); 11 | }); 12 | 13 | it('converts a valid numeric string chord number to integer', () => { 14 | expect(Note.parse('7').note).toEqual(7); 15 | }); 16 | 17 | it('uses a valid integer chord number as-is', () => { 18 | expect(Note.parse(5).note).toEqual(5); 19 | }); 20 | 21 | it('parses a valid numeral', () => { 22 | expect(Note.parse('VI').note).toEqual('VI'); 23 | }); 24 | 25 | it('raises on an invalid chord letter', () => { 26 | expect(() => Note.parse('J')).toThrow('Invalid note J'); 27 | }); 28 | 29 | it('raises on an invalid chord number', () => { 30 | expect(() => Note.parse(9)).toThrow('Invalid note 9'); 31 | }); 32 | 33 | it('raises on an invalid numeral', () => { 34 | expect(() => Note.parse('IX')).toThrow('Invalid note IX'); 35 | }); 36 | 37 | it('raises on anything else', () => { 38 | expect(() => Note.parse('foobar')).toThrow('Invalid note foobar'); 39 | }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /test/key/to_chord_symbol.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint quote-props: 0 */ 2 | 3 | import Key from '../../src/key'; 4 | 5 | const examples = { 6 | 'C': { 7 | '1': 'C', 8 | 'b1': 'B', 9 | '#1': 'C#', 10 | '2': 'D', 11 | '#2': 'D#', 12 | '7': 'B', 13 | 14 | 'I': 'C', 15 | 'bI': 'B', 16 | '#I': 'C#', 17 | 'II': 'D', 18 | '#II': 'D#', 19 | 'VII': 'B', 20 | }, 21 | 22 | 'C#': { 23 | '2': 'D#', 24 | '#2': 'E', 25 | 'b2': 'D', 26 | 'b5': 'G', 27 | 28 | 'II': 'D#', 29 | '#II': 'E', 30 | 'bII': 'D', 31 | 'bV': 'G', 32 | }, 33 | 34 | 'Eb': { 35 | '2': 'F', 36 | '#2': 'F#', 37 | 'b2': 'E', 38 | 39 | 'II': 'F', 40 | '#II': 'F#', 41 | 'bII': 'E', 42 | }, 43 | }; 44 | 45 | describe('Key', () => { 46 | describe('#toChordSymbol', () => { 47 | Object.entries(examples).forEach(([songKeyString, conversions]) => { 48 | const songKey = Key.parseOrFail(songKeyString); 49 | 50 | Object.entries(conversions).forEach(([numericKey, symbolKey]) => { 51 | it(`converts ${numericKey} to ${symbolKey} (actual key: ${songKey})`, () => { 52 | const key = Key.parseOrFail(numericKey); 53 | const keySymbolString = key.toChordSymbolString(songKey); 54 | expect(keySymbolString).toEqual(symbolKey); 55 | }); 56 | }); 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /test/formatter/chord_pro_formatter.test.ts: -------------------------------------------------------------------------------- 1 | import { ChordProFormatter } from '../../src'; 2 | import { chordLyricsPair, createSongFromAst } from '../utilities'; 3 | import { chordProSheetSolfege, chordProSheetSymbol } from '../fixtures/chord_pro_sheet'; 4 | import { exampleSongSolfege, exampleSongSymbol } from '../fixtures/song'; 5 | 6 | describe('ChordProFormatter', () => { 7 | it('formats a symbol song to a chord pro sheet correctly', () => { 8 | expect(new ChordProFormatter().format(exampleSongSymbol)).toEqual(chordProSheetSymbol); 9 | }); 10 | 11 | it('formats a solfege song to a chord pro sheet correctly', () => { 12 | expect(new ChordProFormatter().format(exampleSongSolfege)).toEqual(chordProSheetSolfege); 13 | }); 14 | 15 | it('allows enabling chord normalization', () => { 16 | const formatter = new ChordProFormatter({ normalizeChords: true }); 17 | 18 | const song = createSongFromAst([ 19 | [chordLyricsPair('Dsus4', 'Let it be')], 20 | ]); 21 | 22 | expect(formatter.format(song)).toEqual('[Dsus]Let it be'); 23 | }); 24 | 25 | it('allows disabling chord normalization', () => { 26 | const formatter = new ChordProFormatter({ normalizeChords: false }); 27 | 28 | const song = createSongFromAst([ 29 | [chordLyricsPair('Dsus4', 'Let it be')], 30 | ]); 31 | 32 | expect(formatter.format(song)).toEqual('[Dsus4]Let it be'); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /test/key/to_chord_solfege.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint quote-props: 0 */ 2 | 3 | import Key from '../../src/key'; 4 | 5 | const examples = { 6 | 'Do': { 7 | '1': 'Do', 8 | 'b1': 'Si', 9 | '#1': 'Do#', 10 | '2': 'Re', 11 | '#2': 'Re#', 12 | '7': 'Si', 13 | 14 | 'I': 'Do', 15 | 'bI': 'Si', 16 | '#I': 'Do#', 17 | 'II': 'Re', 18 | '#II': 'Re#', 19 | 'VII': 'Si', 20 | }, 21 | 22 | 'Do#': { 23 | '2': 'Re#', 24 | '#2': 'Mi', 25 | 'b2': 'Re', 26 | 'b5': 'Sol', 27 | 28 | 'II': 'Re#', 29 | '#II': 'Mi', 30 | 'bII': 'Re', 31 | 'bV': 'Sol', 32 | }, 33 | 34 | 'Mib': { 35 | '2': 'Fa', 36 | '#2': 'Fa#', 37 | 'b2': 'Mi', 38 | 39 | 'II': 'Fa', 40 | '#II': 'Fa#', 41 | 'bII': 'Mi', 42 | }, 43 | }; 44 | 45 | describe('Key', () => { 46 | describe('#toChordSolfege', () => { 47 | Object.entries(examples).forEach(([songKeyString, conversions]) => { 48 | const songKey = Key.parseOrFail(songKeyString); 49 | 50 | Object.entries(conversions).forEach(([numericKey, solfegeKey]) => { 51 | it(`converts ${numericKey} to ${solfegeKey} (actual key: ${songKey})`, () => { 52 | const key = Key.parseOrFail(numericKey); 53 | const keySolfegeString = key.toChordSolfegeString(songKey); 54 | expect(keySolfegeString).toEqual(solfegeKey); 55 | }); 56 | }); 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /test/numeric_chord/normalize-suffix.test.ts: -------------------------------------------------------------------------------- 1 | import { Chord } from '../../src'; 2 | 3 | describe('Chord', () => { 4 | describe('normalize suffix on number chord', () => { 5 | describe('chord without bass', () => { 6 | it('works when no suffix', () => { 7 | const chord = Chord.parseOrFail('1').normalize().toString(); 8 | expect(chord).toBe('1'); 9 | }); 10 | 11 | it('normalizes a suffix', () => { 12 | const chord = Chord.parseOrFail('4sus4').normalize().toString(); 13 | expect(chord).toBe('4sus'); 14 | }); 15 | 16 | it('normalizes a suffix when chord has a modifier', () => { 17 | const chord = Chord.parseOrFail('b5add13').normalize().toString(); 18 | expect(chord).toBe('b5(13)'); 19 | }); 20 | 21 | it('normalizes a suffix with a bass', () => { 22 | const chord = Chord.parseOrFail('513(+9+5)/7').normalize().toString(); 23 | expect(chord).toBe('513(#9#5)/7'); 24 | }); 25 | 26 | it('remove the suffix when the normalize should remove the suffix', () => { 27 | const chord = Chord.parseOrFail('1Majj').normalize().toString(); 28 | expect(chord).toBe('1'); 29 | }); 30 | 31 | it('normalizes a number chord', () => { 32 | const chord = Chord.parseOrFail('1sus4').normalize().toString(); 33 | expect(chord).toBe('1sus'); 34 | }); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /test/note/get_transpose_distance.test.ts: -------------------------------------------------------------------------------- 1 | import Note from '../../src/note'; 2 | 3 | describe('Note', () => { 4 | describe('#getTransposeDistance', () => { 5 | describe('for numbers', () => { 6 | const transposeDistances = { 7 | 1: 0, 8 | 2: 2, 9 | 3: 4, 10 | 4: 5, 11 | 5: 7, 12 | 6: 9, 13 | 7: 11, 14 | }; 15 | 16 | Object.keys(transposeDistances).forEach((chordNumber) => { 17 | const distance = transposeDistances[chordNumber]; 18 | 19 | describe(`for chord ${chordNumber}`, () => { 20 | it(`returns ${distance}`, () => { 21 | expect(Note.parse(chordNumber).getTransposeDistance(false)).toEqual(distance); 22 | }); 23 | }); 24 | }); 25 | }); 26 | 27 | describe('for numerals', () => { 28 | const transposeDistances = { 29 | I: 0, 30 | II: 2, 31 | III: 4, 32 | IV: 5, 33 | V: 7, 34 | VI: 9, 35 | VII: 11, 36 | }; 37 | 38 | Object.keys(transposeDistances).forEach((chordNumber) => { 39 | const distance = transposeDistances[chordNumber]; 40 | 41 | describe(`for chord ${chordNumber}`, () => { 42 | it(`returns ${distance}`, () => { 43 | expect(Note.parse(chordNumber).getTransposeDistance(false)).toEqual(distance); 44 | }); 45 | }); 46 | }); 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /test/chord/parse_suffixes.test.ts: -------------------------------------------------------------------------------- 1 | import Key from '../../src/key'; 2 | import SUFFIX_MAPPING from '../../src/normalize_mappings/suffix-normalize-mapping'; 3 | 4 | import { Chord } from '../../src'; 5 | 6 | const keys: Set = new Set(); 7 | const baseKey = Key.parse('A')!; 8 | 9 | for (let i = 0; i < 12; i += 1) { 10 | keys.add(baseKey.transpose(i).toString()); 11 | keys.add(baseKey.transpose(i).useModifier('#').toString()); 12 | keys.add(baseKey.transpose(i).useModifier('b').toString()); 13 | keys.add(baseKey.transpose(i).toNumeralString(baseKey)); 14 | keys.add(baseKey.transpose(i).useModifier('#').toNumeralString(baseKey)); 15 | keys.add(baseKey.transpose(i).useModifier('b').toNumeralString(baseKey)); 16 | keys.add(baseKey.transpose(i).toNumericString(baseKey)); 17 | keys.add(baseKey.transpose(i).useModifier('#').toNumericString(baseKey)); 18 | keys.add(baseKey.transpose(i).useModifier('b').toNumericString(baseKey)); 19 | } 20 | 21 | describe('Chord', () => { 22 | describe('#parse', () => { 23 | keys.forEach((base) => { 24 | Object 25 | .keys(SUFFIX_MAPPING) 26 | .filter((suffix) => suffix !== '[blank]') 27 | .forEach((suffix) => { 28 | const chord = `${base}${suffix}`; 29 | 30 | it(`parses ${chord}`, () => { 31 | expect(Chord.parseOrFail(chord).toString()).toEqual(chord); 32 | }); 33 | }); 34 | }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /test/numeral_chord/transpose_up.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | 3 | describe('Chord', () => { 4 | describe('transposeUp', () => { 5 | describe('for 1, 2, 4, 5 and 6', () => { 6 | it('returns the # version', () => { 7 | expect(Chord.parse('VI/V')?.transposeUp().toString()).toEqual('#VI/#V'); 8 | }); 9 | }); 10 | 11 | describe('for #1, #2, #4, #5 and #6', () => { 12 | it('returns the next note without #', () => { 13 | expect(Chord.parse('#vi/#v')?.transposeUp().toString()).toEqual('vii/#vi'); 14 | }); 15 | }); 16 | 17 | describe('for 3 and 7', () => { 18 | it('returns the next note', () => { 19 | expect(Chord.parse('iii/VII')?.transposeUp().toString()).toEqual('#iii/I'); 20 | }); 21 | }); 22 | 23 | describe('for b2, b3, b5, b6 and b7', () => { 24 | it('returns the note without b', () => { 25 | expect(Chord.parse('bII/bIII')?.transposeUp().toString()).toEqual('II/III'); 26 | }); 27 | }); 28 | 29 | describe('for b1 and b4', () => { 30 | it('returns the note without b', () => { 31 | expect(Chord.parse('bi/biv')?.transposeUp().toString()).toEqual('i/iv'); 32 | }); 33 | }); 34 | 35 | describe('for #E and #B', () => { 36 | it('returns the next note with #', () => { 37 | expect(Chord.parse('#III/#VII')?.transposeUp().toString()).toEqual('#IV/#I'); 38 | }); 39 | }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /test/chord_symbol/normalize-suffix.test.ts: -------------------------------------------------------------------------------- 1 | import '../matchers'; 2 | 3 | import { Chord } from '../../src'; 4 | 5 | describe('Chord', () => { 6 | describe('normalize suffix', () => { 7 | describe('chord without bass', () => { 8 | it('works when no suffix', () => { 9 | const chord = Chord.parseOrFail('E').normalize().toString(); 10 | expect(chord).toBe('E'); 11 | }); 12 | 13 | it('normalizes a suffix', () => { 14 | const chord = Chord.parseOrFail('Esus4').normalize().toString(); 15 | expect(chord).toBe('Esus'); 16 | }); 17 | 18 | it('normalizes a suffix when chord has a modifier', () => { 19 | const chord = Chord.parseOrFail('F#add13').normalize().toString(); 20 | expect(chord).toBe('F#(13)'); 21 | }); 22 | 23 | it('normalizes a suffix with a bass', () => { 24 | const chord = Chord.parseOrFail('E13(+9+5)/B').normalize().toString(); 25 | expect(chord).toBe('E13(#9#5)/B'); 26 | }); 27 | 28 | it('remove the suffix when the normalize should remove the suffix', () => { 29 | const chord = Chord.parseOrFail('EMajj').normalize().toString(); 30 | expect(chord).toBe('E'); 31 | }); 32 | 33 | it('returns the suffix when it\'s not in the config', () => { 34 | const chord = Chord.parseOrFail('E13(add2)').normalize().toString(); 35 | expect(chord).toBe('E13(add2)'); 36 | }); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/chord_definition/chord_definition_set.ts: -------------------------------------------------------------------------------- 1 | import ChordDefinition from './chord_definition'; 2 | import chordDefinitions from './defaults.json'; 3 | 4 | export type DefinitionSet = Record; 5 | 6 | class ChordDefinitionSet { 7 | definitions: DefinitionSet; 8 | 9 | constructor(definitions?: DefinitionSet) { 10 | this.definitions = definitions || {}; 11 | } 12 | 13 | get(chord: string): ChordDefinition | null { 14 | return this.definitions[chord] || null; 15 | } 16 | 17 | withDefaults() { 18 | const defaultDefinitions: Record = chordDefinitions; 19 | const clone = this.clone(); 20 | 21 | Object.keys(defaultDefinitions).forEach((chord: string) => { 22 | const definition = ChordDefinition.parse(defaultDefinitions[chord]); 23 | 24 | if (!clone.has(chord)) { 25 | clone.add(chord, definition); 26 | } 27 | }); 28 | 29 | return clone; 30 | } 31 | 32 | add(chord: string, definition: ChordDefinition) { 33 | this.definitions[chord] = definition; 34 | } 35 | 36 | has(chord: string): boolean { 37 | return chord in this.definitions; 38 | } 39 | 40 | clone(): ChordDefinitionSet { 41 | const clone = new ChordDefinitionSet(); 42 | 43 | Object.keys(this.definitions).forEach((chord: string) => { 44 | clone.add(chord, this.definitions[chord].clone()); 45 | }); 46 | 47 | return clone; 48 | } 49 | } 50 | 51 | export default ChordDefinitionSet; 52 | -------------------------------------------------------------------------------- /test/chord_solfege/normalize-suffix.test.ts: -------------------------------------------------------------------------------- 1 | import '../matchers'; 2 | 3 | import { Chord } from '../../src'; 4 | 5 | describe('Chord', () => { 6 | describe('normalize suffix', () => { 7 | describe('chord without bass', () => { 8 | it('works when no suffix', () => { 9 | const chord = Chord.parseOrFail('Mi').normalize().toString(); 10 | expect(chord).toBe('Mi'); 11 | }); 12 | 13 | it('normalizes a suffix', () => { 14 | const chord = Chord.parseOrFail('Misus4').normalize().toString(); 15 | expect(chord).toBe('Misus'); 16 | }); 17 | 18 | it('normalizes a suffix when chord has a modifier', () => { 19 | const chord = Chord.parseOrFail('Fa#add13').normalize().toString(); 20 | expect(chord).toBe('Fa#(13)'); 21 | }); 22 | 23 | it('normalizes a suffix with a bass', () => { 24 | const chord = Chord.parseOrFail('Mi13(+9+5)/Si').normalize().toString(); 25 | expect(chord).toBe('Mi13(#9#5)/Si'); 26 | }); 27 | 28 | it('remove the suffix when the normalize should remove the suffix', () => { 29 | const chord = Chord.parseOrFail('MiMajj').normalize().toString(); 30 | expect(chord).toBe('Mi'); 31 | }); 32 | 33 | it('returns the suffix when it\'s not in the config', () => { 34 | const chord = Chord.parseOrFail('Mi13(add2)').normalize().toString(); 35 | expect(chord).toBe('Mi13(add2)'); 36 | }); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | env: 12 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 13 | tag: ${{ github.ref_name }} 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v5 17 | - name: Enable Corepack 18 | run: corepack enable 19 | - name: Build 20 | run: yarn install && yarn build:release 21 | - name: Test 22 | run: cat lib/bundle.js 23 | - name: Create release 24 | run: gh release create "$tag" --title="v${tag#v}" --generate-notes 25 | - name: Upload release assets 26 | run: gh release upload "$tag" lib/bundle.js lib/bundle.min.js 27 | - name: Generate typedoc 28 | run: yarn typedoc 29 | - name: Upload static files as artifact 30 | id: deployment 31 | uses: actions/upload-pages-artifact@v4 32 | with: 33 | path: tmp/docs 34 | 35 | typedoc: 36 | runs-on: ubuntu-latest 37 | needs: build 38 | permissions: 39 | pages: write # to deploy to Pages 40 | id-token: write # to verify the deployment originates from an appropriate source 41 | environment: 42 | name: github-pages 43 | url: ${{ steps.deployment.outputs.page_url }} 44 | steps: 45 | - name: Deploy to GitHub Pages 46 | uses: actions/deploy-pages@v4 47 | id: deployment 48 | -------------------------------------------------------------------------------- /test/numeral_chord/transpose_down.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | 3 | describe('Chord', () => { 4 | describe('transposeUp', () => { 5 | describe('for 2, 3, 5, 6, 7', () => { 6 | it('returns the b version', () => { 7 | expect(Chord.parse('VI/V')?.transposeDown().toString()).toEqual('bVI/bV'); 8 | }); 9 | }); 10 | 11 | describe('for #1, #2, #4, #5 and #6', () => { 12 | it('returns the note without #', () => { 13 | expect(Chord.parse('#vi/#v')?.transposeDown().toString()).toEqual('vi/v'); 14 | }); 15 | }); 16 | 17 | describe('for 4 and 1', () => { 18 | it('returns the previous note', () => { 19 | expect(Chord.parse('iv/I')?.transposeDown().toString()).toEqual('biv/VII'); 20 | }); 21 | }); 22 | 23 | describe('for b2, b3, b5, b6 and b7', () => { 24 | it('returns the previous note without b', () => { 25 | expect(Chord.parse('bII/bIII')?.transposeDown().toString()).toEqual('I/II'); 26 | }); 27 | }); 28 | 29 | describe('for #7 and #3', () => { 30 | it('returns the note without #', () => { 31 | expect(Chord.parse('#VII/#III')?.transposeDown().toString()).toEqual('VII/III'); 32 | }); 33 | }); 34 | 35 | describe('for b4 and b1', () => { 36 | it('returns the previous note with b', () => { 37 | expect(Chord.parse('bIV/bI')?.transposeDown().toString()).toEqual('bIII/bVII'); 38 | }); 39 | }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /test/numeric_chord/transpose_up.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | 3 | describe('Chord', () => { 4 | describe('numeric', () => { 5 | describe('transposeUp', () => { 6 | describe('for 1, 2, 4, 5 and 6', () => { 7 | it('returns the # version', () => { 8 | expect(Chord.parse('6/5')?.transposeUp().toString()).toEqual('#6/#5'); 9 | }); 10 | }); 11 | 12 | describe('for #1, #2, #4, #5 and #6', () => { 13 | it('returns the next note without #', () => { 14 | expect(Chord.parse('#6/#5')?.transposeUp().toString()).toEqual('7/6'); 15 | }); 16 | }); 17 | 18 | describe('for 3 and 7', () => { 19 | it('returns the next note', () => { 20 | expect(Chord.parse('3/7')?.transposeUp().toString()).toEqual('4/1'); 21 | }); 22 | }); 23 | 24 | describe('for b2, b3, b5, b6 and b7', () => { 25 | it('returns the note without b', () => { 26 | expect(Chord.parse('b2/b3')?.transposeUp().toString()).toEqual('2/3'); 27 | }); 28 | }); 29 | 30 | describe('for b1 and b4', () => { 31 | it('returns the note without b', () => { 32 | expect(Chord.parse('b1/b4')?.transposeUp().toString()).toEqual('1/4'); 33 | }); 34 | }); 35 | 36 | describe('for #E and #B', () => { 37 | it('returns the next note with #', () => { 38 | expect(Chord.parse('#3/#7')?.transposeUp().toString()).toEqual('#4/#1'); 39 | }); 40 | }); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /test/chord_symbol/transpose_up.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | 3 | describe('Chord', () => { 4 | describe('chord symbol', () => { 5 | describe('transposeUp', () => { 6 | describe('for C, D, F, G and A', () => { 7 | it('returns the # version', () => { 8 | expect(Chord.parse('A/G')?.transposeUp().toString()).toEqual('A#/G#'); 9 | }); 10 | }); 11 | 12 | describe('for C#, D#, F#, G# and A#', () => { 13 | it('returns the next note without #', () => { 14 | expect(Chord.parse('A#/G#')?.transposeUp().toString()).toEqual('B/A'); 15 | }); 16 | }); 17 | 18 | describe('for E and B', () => { 19 | it('returns the next note', () => { 20 | expect(Chord.parse('E/B')?.transposeUp().toString()).toEqual('F/C'); 21 | }); 22 | }); 23 | 24 | describe('for Db, Eb, Gb, Ab and Bb', () => { 25 | it('returns the note without b', () => { 26 | expect(Chord.parse('Db/Eb')?.transposeUp().toString()).toEqual('D/E'); 27 | }); 28 | }); 29 | 30 | describe('for Cb and Fb', () => { 31 | it('returns the note without b', () => { 32 | expect(Chord.parse('Cb/Fb')?.transposeUp().toString()).toEqual('C/F'); 33 | }); 34 | }); 35 | 36 | describe('for E# and B#', () => { 37 | it('returns the next note with #', () => { 38 | expect(Chord.parse('E#/B#')?.transposeUp().toString()).toEqual('F#/C#'); 39 | }); 40 | }); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /test/chord_symbol/transpose_down.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | 3 | describe('Chord', () => { 4 | describe('chord symbol', () => { 5 | describe('transposeUp', () => { 6 | describe('for D, E, G, A, B', () => { 7 | it('returns the b version', () => { 8 | expect(Chord.parse('A/G')?.transposeDown().toString()).toEqual('Ab/Gb'); 9 | }); 10 | }); 11 | 12 | describe('for C#, D#, F#, G# and A#', () => { 13 | it('returns the note without #', () => { 14 | expect(Chord.parse('A#/G#')?.transposeDown().toString()).toEqual('A/G'); 15 | }); 16 | }); 17 | 18 | describe('for F and C', () => { 19 | it('returns the previous note', () => { 20 | expect(Chord.parse('F/C')?.transposeDown().toString()).toEqual('E/B'); 21 | }); 22 | }); 23 | 24 | describe('for Db, Eb, Gb, Ab and Bb', () => { 25 | it('returns the previous note without b', () => { 26 | expect(Chord.parse('Db/Eb')?.transposeDown().toString()).toEqual('C/D'); 27 | }); 28 | }); 29 | 30 | describe('for B# and E#', () => { 31 | it('returns the note without #', () => { 32 | expect(Chord.parse('B#/E#')?.transposeDown().toString()).toEqual('B/E'); 33 | }); 34 | }); 35 | 36 | describe('for Fb and Cb', () => { 37 | it('returns the previous note with b', () => { 38 | expect(Chord.parse('Fb/Cb')?.transposeDown().toString()).toEqual('Eb/Bb'); 39 | }); 40 | }); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /test/numeric_chord/transpose_down.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | 3 | describe('Chord', () => { 4 | describe('numeric', () => { 5 | describe('transposeUp', () => { 6 | describe('for 2, 3, 5, 6, 7', () => { 7 | it('returns the b version', () => { 8 | expect(Chord.parse('6/5')?.transposeDown().toString()).toEqual('b6/b5'); 9 | }); 10 | }); 11 | 12 | describe('for #1, #2, #4, #5 and #6', () => { 13 | it('returns the note without #', () => { 14 | expect(Chord.parse('#6/#5')?.transposeDown().toString()).toEqual('6/5'); 15 | }); 16 | }); 17 | 18 | describe('for 4 and 1', () => { 19 | it('returns the previous note', () => { 20 | expect(Chord.parse('4/1')?.transposeDown().toString()).toEqual('3/7'); 21 | }); 22 | }); 23 | 24 | describe('for b2, b3, b5, b6 and b7', () => { 25 | it('returns the previous note without b', () => { 26 | expect(Chord.parse('b2/b3')?.transposeDown().toString()).toEqual('1/2'); 27 | }); 28 | }); 29 | 30 | describe('for #7 and #3', () => { 31 | it('returns the note without #', () => { 32 | expect(Chord.parse('#7/#3')?.transposeDown().toString()).toEqual('7/3'); 33 | }); 34 | }); 35 | 36 | describe('for b4 and b1', () => { 37 | it('returns the previous note with b', () => { 38 | expect(Chord.parse('b4/b1')?.transposeDown().toString()).toEqual('b3/b7'); 39 | }); 40 | }); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /test/integration/chordpro_to_chords_over_words.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | import { heredoc } from '../utilities'; 4 | import { normalizeLineEndings } from '../../src/utilities'; 5 | import { ChordProParser, ChordsOverWordsFormatter } from '../../src'; 6 | 7 | describe('chordpro to chords over words', () => { 8 | it('correctly parses and converts the song structure', () => { 9 | const chordpro = heredoc` 10 | {title: Honey In The Rock} 11 | 12 | {comment: Verse 1} 13 | [D] Praying[Dsus] for a miracle,[D] thirsty[Dsus]`; 14 | 15 | const expectedChordOverWords = heredoc` 16 | title: Honey In The Rock 17 | 18 | Verse 1 19 | D Dsus D Dsus 20 | Praying for a miracle, thirsty`; 21 | 22 | const song = new ChordProParser().parse(chordpro); 23 | const actualChordsOverWords = new ChordsOverWordsFormatter().format(song); 24 | 25 | expect(actualChordsOverWords).toEqual(expectedChordOverWords); 26 | }); 27 | 28 | test('correctly parses and converts a complicated chart', () => { 29 | const chordpro = fs.readFileSync('./test/fixtures/kingdom_chordpro.txt', 'utf8'); 30 | 31 | const expectedChordOverWords = normalizeLineEndings( 32 | fs.readFileSync('./test/fixtures/kingdom_chords_over_words.txt', 'utf8'), 33 | ); 34 | 35 | const song = new ChordProParser().parse(chordpro); 36 | const actualChordsOverWords = new ChordsOverWordsFormatter().format(song); 37 | 38 | expect(actualChordsOverWords).toEqual(expectedChordOverWords); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /script/check_trailing_whitespace.ts: -------------------------------------------------------------------------------- 1 | import { EOL } from 'node:os'; 2 | import fs from 'node:fs'; 3 | 4 | const filesWithTrailingWhitespace: Record = {}; 5 | 6 | const files = 7 | fs 8 | .readFileSync(0, 'utf8') 9 | .split(EOL) 10 | .filter((f) => f.length > 0); 11 | 12 | console.log(`Check ${files.length} files for trailing whitespace`); 13 | 14 | files.forEach((filename) => { 15 | const contents = fs.readFileSync(filename, 'utf8'); 16 | const lines = contents.split(EOL); 17 | 18 | lines.forEach((line, index) => { 19 | if (line.endsWith(' ')) { 20 | if (!(filename in filesWithTrailingWhitespace)) { 21 | filesWithTrailingWhitespace[filename] = []; 22 | } 23 | 24 | filesWithTrailingWhitespace[filename].push(index + 1); 25 | } 26 | }); 27 | }); 28 | 29 | const fileNamesWithTrailingWhitespace = Object.keys(filesWithTrailingWhitespace); 30 | const fileCount = fileNamesWithTrailingWhitespace.length; 31 | 32 | if (fileCount === 0) { 33 | console.log('No files with trailing whitespace'); 34 | process.exit(0); 35 | } 36 | 37 | const errorMessage = 38 | fileNamesWithTrailingWhitespace 39 | .map((filename) => { 40 | const specification = 41 | filesWithTrailingWhitespace[filename] 42 | .map((lineNumber) => ` ${filename}:${lineNumber}`) 43 | .join(EOL); 44 | 45 | return ` ${filename}:${EOL}${specification}`; 46 | }) 47 | .join(EOL); 48 | 49 | console.error(`Found ${fileCount} files with trailing whitespace:${EOL}${errorMessage}`); 50 | process.exit(1); 51 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | # For info about .codeclimate.yml structure see: 2 | # https://docs.codeclimate.com/docs/advanced-configuration 3 | 4 | version: "2" # required to adjust maintainability checks 5 | 6 | # See: https://docs.codeclimate.com/docs/maintainability#section-checks 7 | checks: 8 | argument-count: 9 | config: 10 | threshold: 4 11 | complex-logic: 12 | config: 13 | threshold: 4 14 | file-lines: 15 | config: 16 | threshold: 250 17 | method-complexity: 18 | config: 19 | threshold: 5 20 | method-count: 21 | config: 22 | threshold: 20 23 | method-lines: 24 | config: 25 | threshold: 25 26 | nested-control-flow: 27 | config: 28 | threshold: 4 29 | return-statements: 30 | config: 31 | threshold: 4 32 | similar-code: 33 | config: 34 | threshold: # language-specific defaults. an override will affect all languages. 35 | identical-code: 36 | config: 37 | threshold: # language-specific defaults. an override will affect all languages. 38 | 39 | # See: https://docs.codeclimate.com/docs/advanced-configuration#section-plugins 40 | # and https://docs.codeclimate.com/docs/list-of-engines 41 | plugins: 42 | duplication: 43 | enabled: true 44 | exclude_patterns: 45 | - "test/" 46 | - "src/formatter/html_*_formatter.js" 47 | structure: 48 | enabled: true 49 | exclude_patterns: 50 | - "test/" 51 | fixme: 52 | enabled: true 53 | nodesecurity: 54 | enabled: true 55 | 56 | exclude_paths: 57 | - node_modules/ 58 | - lib/ 59 | - .idea/ 60 | -------------------------------------------------------------------------------- /src/formatter/html_table_formatter.ts: -------------------------------------------------------------------------------- 1 | import template from './templates/html_table_formatter'; 2 | 3 | import HtmlFormatter, { CSS, HtmlTemplateCssClasses, Template } from './html_formatter'; 4 | 5 | /* eslint-disable-next-line max-lines-per-function */ 6 | function defaultCss(cssClasses: HtmlTemplateCssClasses): CSS { 7 | const { 8 | annotation, 9 | chord, 10 | comment, 11 | labelWrapper, 12 | line, 13 | literal, 14 | literalContents, 15 | lyrics, 16 | paragraph, 17 | row, 18 | subtitle, 19 | title, 20 | } = cssClasses; 21 | 22 | return { 23 | [`.${title}`]: { 24 | fontSize: '1.5em', 25 | }, 26 | [`.${subtitle}`]: { 27 | fontSize: '1.1em', 28 | }, 29 | [`.${row}, .${line}, .${literal}`]: { 30 | borderSpacing: '0', 31 | color: 'inherit', 32 | }, 33 | [`.${annotation}, .${chord}, .${comment}, .${literalContents}, .${labelWrapper}, .${literal}, .${lyrics}`]: { 34 | padding: '3px 0', 35 | }, 36 | [`.${chord}:not(:last-child)`]: { 37 | paddingRight: '10px', 38 | }, 39 | [`.${paragraph}`]: { 40 | marginBottom: '1em', 41 | }, 42 | }; 43 | } 44 | 45 | /** 46 | * Formats a song into HTML. It uses TABLEs to align lyrics with chords, which makes the HTML for things like 47 | * PDF conversion. 48 | */ 49 | class HtmlTableFormatter extends HtmlFormatter { 50 | get template(): Template { 51 | return template; 52 | } 53 | 54 | get defaultCss(): CSS { 55 | return defaultCss(this.cssClasses); 56 | } 57 | } 58 | 59 | export default HtmlTableFormatter; 60 | -------------------------------------------------------------------------------- /test/chord_solfege/transpose_up.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | 3 | describe('Chord', () => { 4 | describe('chord solfege', () => { 5 | describe('transposeUp', () => { 6 | describe('for Do, Re, Fa, Sol and La', () => { 7 | it('returns the # version', () => { 8 | expect(Chord.parse('La/Sol')?.transposeUp().toString()).toEqual('La#/Sol#'); 9 | }); 10 | }); 11 | 12 | describe('for Do#, Re#, Fa#, Sol# and La#', () => { 13 | it('returns the next note without #', () => { 14 | expect(Chord.parse('La#/Sol#')?.transposeUp().toString()).toEqual('Si/La'); 15 | }); 16 | }); 17 | 18 | describe('for Mi and Si', () => { 19 | it('returns the next note', () => { 20 | expect(Chord.parse('Mi/Si')?.transposeUp().toString()).toEqual('Fa/Do'); 21 | }); 22 | }); 23 | 24 | describe('for Reb, Mib, Solb, Lab and Sib', () => { 25 | it('returns the note without b', () => { 26 | expect(Chord.parse('Reb/Mib')?.transposeUp().toString()).toEqual('Re/Mi'); 27 | }); 28 | }); 29 | 30 | describe('for Dob and Fab', () => { 31 | it('returns the note without b', () => { 32 | expect(Chord.parse('Dob/Fab')?.transposeUp().toString()).toEqual('Do/Fa'); 33 | }); 34 | }); 35 | 36 | describe('for Mi# and Si#', () => { 37 | it('returns the next note with #', () => { 38 | expect(Chord.parse('Mi#/Si#')?.transposeUp().toString()).toEqual('Fa#/Do#'); 39 | }); 40 | }); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /test/key/wrap.test.ts: -------------------------------------------------------------------------------- 1 | import { buildKey } from '../utilities'; 2 | import { Key, SOLFEGE, SYMBOL } from '../../src'; 3 | 4 | describe('Key', () => { 5 | describe('wrap symbol', () => { 6 | describe('when an key object is passed', () => { 7 | it('returns the key object', () => { 8 | const key = buildKey('A', SYMBOL, 'b'); 9 | const wrappedKey = Key.wrap(key); 10 | 11 | expect(key).toBe(wrappedKey); 12 | }); 13 | }); 14 | 15 | describe('when an key string is passed', () => { 16 | it('returns the parsed key', () => { 17 | const wrappedKey = Key.wrap('Ab'); 18 | 19 | expect(wrappedKey).toMatchObject({ 20 | referenceKeyGrade: 8, 21 | grade: 0, 22 | modifier: 'b', 23 | type: SYMBOL, 24 | minor: false, 25 | }); 26 | }); 27 | }); 28 | }); 29 | 30 | describe('wrap solfege', () => { 31 | describe('when an key object is passed', () => { 32 | it('returns the key object', () => { 33 | const key = buildKey('La', SOLFEGE, 'b'); 34 | const wrappedKey = Key.wrap(key); 35 | 36 | expect(key).toBe(wrappedKey); 37 | }); 38 | }); 39 | 40 | describe('when an key string is passed', () => { 41 | it('returns the parsed key', () => { 42 | const wrappedKey = Key.wrap('Lab'); 43 | 44 | expect(wrappedKey).toMatchObject({ 45 | referenceKeyGrade: 8, 46 | grade: 0, 47 | modifier: 'b', 48 | type: SOLFEGE, 49 | minor: false, 50 | }); 51 | }); 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /test/chord_solfege/transpose_down.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | 3 | describe('Chord', () => { 4 | describe('chord solfege', () => { 5 | describe('transposeUp', () => { 6 | describe('for Re, Mi, Sol, La, Si', () => { 7 | it('returns the b version', () => { 8 | expect(Chord.parse('La/Sol')?.transposeDown().toString()).toEqual('Lab/Solb'); 9 | }); 10 | }); 11 | 12 | describe('for Do#, Re#, Fa#, Sol# and La#', () => { 13 | it('returns the note without #', () => { 14 | expect(Chord.parse('La#/Sol#')?.transposeDown().toString()).toEqual('La/Sol'); 15 | }); 16 | }); 17 | 18 | describe('for Fa and Do', () => { 19 | it('returns the previous note', () => { 20 | expect(Chord.parse('Fa/Do')?.transposeDown().toString()).toEqual('Mi/Si'); 21 | }); 22 | }); 23 | 24 | describe('for Reb, Mib, Solb, Lab and Sib', () => { 25 | it('returns the previous note without b', () => { 26 | expect(Chord.parse('Reb/Mib')?.transposeDown().toString()).toEqual('Do/Re'); 27 | }); 28 | }); 29 | 30 | describe('for Si# and Mi#', () => { 31 | it('returns the note without #', () => { 32 | expect(Chord.parse('Si#/Mi#')?.transposeDown().toString()).toEqual('Si/Mi'); 33 | }); 34 | }); 35 | 36 | describe('for Fab and Dob', () => { 37 | it('returns the previous note with b', () => { 38 | expect(Chord.parse('Fab/Dob')?.transposeDown().toString()).toEqual('Mib/Sib'); 39 | }); 40 | }); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/chord_sheet/metadata_accessors.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ALBUM, 3 | ARTIST, 4 | CAPO, 5 | COMPOSER, 6 | COPYRIGHT, 7 | DURATION, 8 | KEY, 9 | LYRICIST, 10 | SUBTITLE, 11 | TEMPO, 12 | TIME, 13 | TITLE, 14 | YEAR, 15 | } from './tags'; 16 | 17 | abstract class MetadataAccessors { 18 | abstract getMetadataValue(_name: string): string | string[] | null; 19 | 20 | abstract getSingleMetadataValue(_name: string): string | null; 21 | 22 | get key(): string | null { return this.getSingleMetadataValue(KEY); } 23 | 24 | get title(): string | null { return this.getSingleMetadataValue(TITLE); } 25 | 26 | get subtitle(): string | null { return this.getSingleMetadataValue(SUBTITLE); } 27 | 28 | get capo(): string | string[] | null { return this.getMetadataValue(CAPO); } 29 | 30 | get duration(): string | null { return this.getSingleMetadataValue(DURATION); } 31 | 32 | get tempo(): string | null { return this.getSingleMetadataValue(TEMPO); } 33 | 34 | get time(): string | string[] | null { return this.getMetadataValue(TIME); } 35 | 36 | get year(): string | null { return this.getSingleMetadataValue(YEAR); } 37 | 38 | get album(): string | string[] | null { return this.getMetadataValue(ALBUM); } 39 | 40 | get copyright(): string | null { return this.getSingleMetadataValue(COPYRIGHT); } 41 | 42 | get lyricist(): string | string[] | null { return this.getMetadataValue(LYRICIST); } 43 | 44 | get artist(): string | string[] | null { return this.getMetadataValue(ARTIST); } 45 | 46 | get composer(): string | string[] | null { return this.getMetadataValue(COMPOSER); } 47 | } 48 | 49 | export default MetadataAccessors; 50 | -------------------------------------------------------------------------------- /src/formatter/formatter.ts: -------------------------------------------------------------------------------- 1 | import Configuration, { ConfigurationProperties, configure } from './configuration'; 2 | 3 | /** 4 | * Base class for all formatters, taking care of receiving a configuration wrapping that inside a Configuration object 5 | */ 6 | class Formatter { 7 | configuration: Configuration; 8 | 9 | /** 10 | * Instantiate 11 | * @param {Object} [configuration={}] options 12 | * @param {boolean} [configuration.evaluate=false] Whether or not to evaluate meta expressions. 13 | * For more info about meta expressions, see: https://bit.ly/2SC9c2u 14 | * @param {object} [configuration.metadata={}] 15 | * @param {string} [configuration.metadata.separator=", "] The separator to be used when rendering a 16 | * metadata value that has multiple values. See: https://bit.ly/2SC9c2u 17 | * @param {Key|string} [configuration.key=null] The key to use for rendering. The chord sheet will be 18 | * transposed from the song's original key (as indicated by the `{key}` directive) to the specified key. 19 | * Note that transposing will only work if the original song key is set. 20 | * @param {boolean} [configuration.expandChorusDirective=false] Whether or not to expand `{chorus}` directives 21 | * by rendering the last defined chorus inline after the directive. 22 | * @param {boolean} [configuration.useUnicodeModifiers=false] Whether or not to use unicode flat and sharp 23 | * symbols. 24 | * @param {boolean} [configuration.normalizeChords=true] Whether or not to automatically normalize chords 25 | */ 26 | constructor(configuration: ConfigurationProperties = {}) { 27 | this.configuration = configure(configuration); 28 | } 29 | } 30 | 31 | export default Formatter; 32 | -------------------------------------------------------------------------------- /test/note/up.test.ts: -------------------------------------------------------------------------------- 1 | import Note from '../../src/note'; 2 | 3 | describe('Note', () => { 4 | describe('#up', () => { 5 | describe('note chord letters', () => { 6 | const notes = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'A']; 7 | 8 | for (let i = 0; i < 7; i += 1) { 9 | const from = notes[i]; 10 | const to = notes[i + 1]; 11 | 12 | it(`converts ${from} to ${to}`, () => { 13 | expect(Note.parse(from).up().note).toEqual(to); 14 | }); 15 | } 16 | }); 17 | 18 | describe('chord numbers', () => { 19 | const notes = [1, 2, 3, 4, 5, 6, 7, 1]; 20 | 21 | for (let i = 0; i < 7; i += 1) { 22 | const from = notes[i]; 23 | const to = notes[i + 1]; 24 | 25 | it(`converts ${from} to ${to}`, () => { 26 | expect(Note.parse(from).up().note).toEqual(to); 27 | }); 28 | } 29 | }); 30 | 31 | describe('major numerals', () => { 32 | const notes = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'I']; 33 | 34 | for (let i = 0; i < 7; i += 1) { 35 | const from = notes[i]; 36 | const to = notes[i + 1]; 37 | 38 | it(`converts ${from} to ${to}`, () => { 39 | expect(Note.parse(from).up().note).toEqual(to); 40 | }); 41 | } 42 | }); 43 | 44 | describe('minor numerals', () => { 45 | const notes = ['i', 'ii', 'iii', 'iv', 'v', 'vi', 'vii', 'i']; 46 | 47 | for (let i = 0; i < 7; i += 1) { 48 | const from = notes[i]; 49 | const to = notes[i + 1]; 50 | 51 | it(`converts ${from} to ${to}`, () => { 52 | expect(Note.parse(from).up().note).toEqual(to); 53 | }); 54 | } 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /test/note/down.test.ts: -------------------------------------------------------------------------------- 1 | import Note from '../../src/note'; 2 | 3 | describe('Note', () => { 4 | describe('#down', () => { 5 | describe('note chord letters', () => { 6 | const notes = ['G', 'F', 'E', 'D', 'C', 'B', 'A', 'G']; 7 | 8 | for (let i = 0; i < 7; i += 1) { 9 | const from = notes[i]; 10 | const to = notes[i + 1]; 11 | 12 | it(`converts ${from} to ${to}`, () => { 13 | expect(Note.parse(from).down().note).toEqual(to); 14 | }); 15 | } 16 | }); 17 | 18 | describe('chord numbers', () => { 19 | const notes = [7, 6, 5, 4, 3, 2, 1, 7]; 20 | 21 | for (let i = 0; i < 7; i += 1) { 22 | const from = notes[i]; 23 | const to = notes[i + 1]; 24 | 25 | it(`converts ${from} to ${to}`, () => { 26 | expect(Note.parse(from).down().note).toEqual(to); 27 | }); 28 | } 29 | }); 30 | 31 | describe('major numerals', () => { 32 | const notes = ['VII', 'VI', 'V', 'IV', 'III', 'II', 'I', 'VII']; 33 | 34 | for (let i = 0; i < 7; i += 1) { 35 | const from = notes[i]; 36 | const to = notes[i + 1]; 37 | 38 | it(`converts ${from} to ${to}`, () => { 39 | expect(Note.parse(from).down().note).toEqual(to); 40 | }); 41 | } 42 | }); 43 | 44 | describe('minor numerals', () => { 45 | const notes = ['vii', 'vi', 'v', 'iv', 'iii', 'ii', 'i', 'vii']; 46 | 47 | for (let i = 0; i < 7; i += 1) { 48 | const from = notes[i]; 49 | const to = notes[i + 1]; 50 | 51 | it(`converts ${from} to ${to}`, () => { 52 | expect(Note.parse(from).down().note).toEqual(to); 53 | }); 54 | } 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/chord_sheet/song_mapper.ts: -------------------------------------------------------------------------------- 1 | import Item from './item'; 2 | import Line from './line'; 3 | import Song from './song'; 4 | import SongBuilder from '../song_builder'; 5 | 6 | export type MapItemsCallback = (_item: Item) => Item | Item[] | null; 7 | 8 | class SongMapper { 9 | private song: Song; 10 | 11 | private clonedSong: Song; 12 | 13 | private builder: SongBuilder; 14 | 15 | private addedLine = false; 16 | 17 | constructor(song: Song) { 18 | this.song = song; 19 | this.clonedSong = new Song(); 20 | this.builder = new SongBuilder(this.clonedSong); 21 | } 22 | 23 | mapItems(func: MapItemsCallback): Song { 24 | this.song.lines.forEach((line) => { 25 | this.mapLineItems(line, func); 26 | }); 27 | 28 | return this.clonedSong; 29 | } 30 | 31 | private mapLineItems(line: Line, func: MapItemsCallback) { 32 | line.items.forEach((item) => { 33 | this.mapItem(func, item); 34 | }); 35 | 36 | if (line.isEmpty()) { 37 | this.ensureLine(); 38 | } 39 | 40 | this.addedLine = false; 41 | } 42 | 43 | private mapItem(func: MapItemsCallback, item: Item) { 44 | const changedItem = func(item); 45 | 46 | if (changedItem === null) { 47 | return; 48 | } 49 | 50 | const isArray = Array.isArray(changedItem); 51 | 52 | if (!isArray || changedItem.length > 0) { 53 | this.ensureLine(); 54 | } 55 | 56 | if (isArray) { 57 | changedItem.forEach((i) => this.builder.addItem(i)); 58 | } else { 59 | this.builder.addItem(changedItem); 60 | } 61 | } 62 | 63 | ensureLine() { 64 | if (!this.addedLine) { 65 | this.builder.addLine(); 66 | this.addedLine = true; 67 | } 68 | } 69 | } 70 | 71 | export default SongMapper; 72 | -------------------------------------------------------------------------------- /src/parser/chord_definition/grammar.pegjs: -------------------------------------------------------------------------------- 1 | ChordDefinitionValue 2 | = name:ChordDefinitionName _ baseFret:BaseFret? "frets" frets:FretWithLeadingSpace+ fingers:ChordFingersDefinition? { 3 | return { 4 | name, 5 | baseFret: baseFret || 1, 6 | frets, 7 | fingers, 8 | text: text() 9 | }; 10 | } 11 | 12 | ChordDefinitionName 13 | = $(ChordDefinitionNameBase bass:ChordDefinitionNameBass?) 14 | 15 | ChordDefinitionNameBase 16 | = $(ChordDefinitionNote ChordDefinitionSuffix?) 17 | 18 | ChordDefinitionNameBass 19 | = $("/" ChordDefinitionNote) 20 | 21 | ChordDefinitionNote 22 | = $([A-Ga-g]([b#♭♯] / "es" / "s" / "is")?) 23 | 24 | ChordDefinitionSuffix 25 | = $([a-zA-Z0-9#♯b♭\(\)\+\-\/øΔ−]+) 26 | 27 | BaseFret 28 | = "base-fret" __ baseFret:FretNumber __ { 29 | return baseFret; 30 | } 31 | 32 | ChordFingersDefinition 33 | = __ "fingers" fingers:FingerWithLeadingSpace+ { 34 | return fingers; 35 | } 36 | 37 | FingerWithLeadingSpace 38 | = __ finger:Finger { 39 | return finger; 40 | } 41 | 42 | Finger 43 | = FingerNumber / FingerLetter / NoFingerSetting 44 | 45 | FingerNumber 46 | = number:[0-9] { 47 | return parseInt(number, 10); 48 | } 49 | 50 | FingerLetter 51 | = [a-zA-Z] 52 | 53 | NoFingerSetting 54 | = "-" / "x" / "X" / "n" / "N" 55 | 56 | FretWithLeadingSpace 57 | = __ fret:Fret { 58 | return fret; 59 | } 60 | 61 | Fret 62 | = _ fret:(FretNumber / OpenFret / NonSoundingString) { 63 | return fret; 64 | } 65 | 66 | FretNumber 67 | = number:[0-9] { 68 | return parseInt(number, 10); 69 | } 70 | 71 | OpenFret 72 | = "0" { 73 | return 0; 74 | } 75 | 76 | NonSoundingString 77 | = "-1" / "n" / "N" / "x" / "X" 78 | -------------------------------------------------------------------------------- /test/key/to_numeral.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint quote-props: 0 */ 2 | 3 | import { buildKey } from '../utilities'; 4 | import { Key, NUMERAL } from '../../src'; 5 | 6 | const examples = { 7 | 'C': { 8 | 'C': 'I', 9 | 'C#': '#I', 10 | 'D': 'II', 11 | 'D#': '#II', 12 | 'B': 'VII', 13 | 14 | '1': 'I', 15 | '#1': '#I', 16 | '2': 'II', 17 | '#2': '#II', 18 | '7': 'VII', 19 | }, 20 | 21 | 'C#': { 22 | 'D#': 'II', 23 | 'E': '#II', 24 | 'D': '#I', 25 | 'G': '#IV', 26 | 27 | '2': 'II', 28 | '#2': '#II', 29 | '#1': '#I', 30 | '#4': '#IV', 31 | }, 32 | 33 | 'Eb': { 34 | 'F': 'II', 35 | 'Gb': 'bIII', 36 | 'E': 'bII', 37 | 38 | '2': 'II', 39 | 'b3': 'bIII', 40 | 'b2': 'bII', 41 | }, 42 | 43 | 'B': { 44 | 'F#': 'V', 45 | 'A#': 'VII', 46 | 47 | '5': 'V', 48 | '7': 'VII', 49 | }, 50 | }; 51 | 52 | describe('Key', () => { 53 | describe('toNumeral', () => { 54 | Object.entries(examples).forEach(([songKeyString, conversions]) => { 55 | const songKey = Key.parse(songKeyString); 56 | 57 | Object.entries(conversions).forEach(([symbolKey, numeralKey]) => { 58 | it(`converts ${symbolKey} to ${numeralKey} (actual key: ${songKey})`, () => { 59 | const key = Key.parseOrFail(symbolKey); 60 | const numeralString = key.toNumeralString(songKey); 61 | expect(numeralString).toEqual(numeralKey); 62 | }); 63 | }); 64 | }); 65 | 66 | it('returns a clone when the key is already numeral', () => { 67 | const key = buildKey(5, NUMERAL, '#'); 68 | const numeralKey = key.toNumeral(); 69 | 70 | expect(numeralKey).toEqual(key); 71 | expect(numeralKey).not.toBe(key); 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /test/key/to_string.test.ts: -------------------------------------------------------------------------------- 1 | import { buildKey } from '../utilities'; 2 | import { NUMERAL, SOLFEGE } from '../../src/constants'; 3 | import { NUMERIC, SYMBOL } from '../../src'; 4 | 5 | describe('Key', () => { 6 | describe('toString', () => { 7 | it('converts a major numeral key to a string', () => { 8 | const key = buildKey('II', NUMERAL, 'b'); 9 | 10 | expect(key.toString()).toEqual('bII'); 11 | }); 12 | 13 | it('converts a minor numeral key to a string', () => { 14 | const key = buildKey('ii', NUMERAL, 'b', false); 15 | 16 | expect(key.toString()).toEqual('bii'); 17 | }); 18 | 19 | it('converts a major numeric key to a string', () => { 20 | const key = buildKey(2, NUMERIC, 'b'); 21 | 22 | expect(key.toString()).toEqual('b2'); 23 | }); 24 | 25 | it('converts a minor numeric key to a string', () => { 26 | const key = buildKey(2, NUMERIC, 'b', true); 27 | 28 | expect(key.toString()).toEqual('b2'); 29 | }); 30 | 31 | it('converts a major chord symbol key to a string', () => { 32 | const key = buildKey('A', SYMBOL, 'b'); 33 | 34 | expect(key.toString()).toEqual('Ab'); 35 | }); 36 | 37 | it('converts a minor chord symbol key to a string', () => { 38 | const key = buildKey('A', SYMBOL, 'b', true); 39 | 40 | expect(key.toString()).toEqual('Abm'); 41 | }); 42 | 43 | it('converts a major chord solfege key to a string', () => { 44 | const key = buildKey('La', SOLFEGE, 'b'); 45 | 46 | expect(key.toString()).toEqual('Lab'); 47 | }); 48 | 49 | it('converts a minor chord solfege key to a string', () => { 50 | const key = buildKey('La', SOLFEGE, 'b', true); 51 | 52 | expect(key.toString()).toEqual('Labm'); 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /test/chord_definition/chord_definition_set.test.ts: -------------------------------------------------------------------------------- 1 | import ChordDefinition from '../../src/chord_definition/chord_definition'; 2 | import ChordDefinitionSet from '../../src/chord_definition/chord_definition_set'; 3 | 4 | describe('ChordDefinitionSet', () => { 5 | describe('#get', () => { 6 | it('returns a chord definition when present for the chord', () => { 7 | const chordDefinition = ChordDefinition.parse(' D7 base-fret 3 frets x 3 2 3 1 x '); 8 | 9 | const definitionSet = new ChordDefinitionSet({ 10 | 'D7': chordDefinition, 11 | }); 12 | 13 | expect(definitionSet.get('D7')).toEqual(chordDefinition); 14 | }); 15 | 16 | it('returns null when there is no definition for the chord', () => { 17 | const definitionSet = new ChordDefinitionSet(); 18 | 19 | expect(definitionSet.get('A')).toBeNull(); 20 | }); 21 | }); 22 | 23 | describe('#withDefaults', () => { 24 | it('loads the defaults for missing definitions', () => { 25 | const d7OpenGm = ChordDefinition.parse('D7 base-fret 0 frets 0 2 4 4 2 4'); 26 | const am = ChordDefinition.parse('Am base-fret 0 frets x 0 2 2 1 0'); 27 | 28 | const chordDefinitionSet = 29 | new ChordDefinitionSet({ 30 | 'D7': d7OpenGm, 31 | 'Am': am, 32 | }) 33 | .withDefaults(); 34 | 35 | expect(chordDefinitionSet.get('D7')).toEqual(d7OpenGm); 36 | expect(chordDefinitionSet.get('Am')).toEqual(am); 37 | }); 38 | }); 39 | 40 | describe('#add', () => { 41 | it('adds a chord definition for a tuning', () => { 42 | const definition = ChordDefinition.parse('A base-fret 0 frets 0 1 2 2 2 0'); 43 | const library = new ChordDefinitionSet(); 44 | 45 | library.add('A', definition); 46 | 47 | expect(library.get('A')).toEqual(definition); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/parser/chord_pro_parser.ts: -------------------------------------------------------------------------------- 1 | import ChordSheetSerializer from '../chord_sheet_serializer'; 2 | import NullTracer from './null_tracer'; 3 | import ParserWarning from './parser_warning'; 4 | import Song from '../chord_sheet/song'; 5 | 6 | import { normalizeLineEndings } from '../utilities'; 7 | import { ParseOptions, parse } from './chord_pro/peg_parser'; 8 | 9 | export type ChordProParserOptions = ParseOptions & { 10 | softLineBreaks?: boolean; 11 | chopFirstWord?: boolean; 12 | }; 13 | 14 | /** 15 | * Parses a ChordPro chord sheet 16 | */ 17 | class ChordProParser { 18 | song?: Song; 19 | 20 | /** 21 | * All warnings raised during parsing the chord sheet 22 | * @member 23 | * @type {ParserWarning[]} 24 | */ 25 | get warnings(): ParserWarning[] { 26 | return this.song?.warnings || []; 27 | } 28 | 29 | /** 30 | * Parses a ChordPro chord sheet into a song 31 | * @param {string} chordSheet the ChordPro chord sheet 32 | * @param {ChordProParserOptions} options Parser options. 33 | * @param {ChordProParserOptions.softLineBreaks} options.softLineBreaks=false If true, a backslash 34 | * followed by * a space is treated as a soft line break 35 | * @param {ChordProParserOptions.chopFirstWord} options.chopFirstWord=true If true, only the first lyric 36 | * word is paired with the chord, the rest of the lyric is put in a separate chord lyric pair 37 | * @see https://peggyjs.org/documentation.html#using-the-parser 38 | * @returns {Song} The parsed song 39 | */ 40 | parse(chordSheet: string, options?: ChordProParserOptions): Song { 41 | const ast = parse( 42 | normalizeLineEndings(chordSheet), 43 | { tracer: new NullTracer(), ...options }, 44 | ); 45 | 46 | this.song = new ChordSheetSerializer().deserialize(ast); 47 | return this.song; 48 | } 49 | } 50 | 51 | export default ChordProParser; 52 | -------------------------------------------------------------------------------- /test/integration/setting_key.test.ts: -------------------------------------------------------------------------------- 1 | import { heredoc } from '../utilities'; 2 | import { ChordProFormatter, ChordProParser } from '../../src'; 3 | 4 | describe('setting the key of an existing song', () => { 5 | it('updates the key directive', () => { 6 | const chordpro = heredoc` 7 | {key: C} 8 | Let it [Am]be, let it [C/G]be, let it [F]be, let it [C]be`; 9 | 10 | const changedSheet = heredoc` 11 | {key: D} 12 | Let it [Am]be, let it [C/G]be, let it [F]be, let it [C]be`; 13 | 14 | const song = new ChordProParser().parse(chordpro); 15 | const updatedSong = song.setKey('D'); 16 | 17 | expect(updatedSong.key).toEqual('D'); 18 | expect(new ChordProFormatter().format(updatedSong)).toEqual(changedSheet); 19 | }); 20 | 21 | it('adds the key directive', () => { 22 | const chordpro = heredoc` 23 | Let it [Am]be, let it [C/G]be, let it [F]be, let it [C]be`; 24 | 25 | const changedSheet = heredoc` 26 | {key: D} 27 | Let it [Am]be, let it [C/G]be, let it [F]be, let it [C]be`; 28 | 29 | const song = new ChordProParser().parse(chordpro); 30 | const updatedSong = song.setKey('D'); 31 | 32 | expect(updatedSong.key).toEqual('D'); 33 | expect(new ChordProFormatter().format(updatedSong)).toEqual(changedSheet); 34 | }); 35 | 36 | it('removes the key directive when passing null', () => { 37 | const chordpro = heredoc` 38 | {key: C} 39 | Let it [Am]be, let it [C/G]be, let it [F]be, let it [C]be`; 40 | 41 | const changedSheet = heredoc` 42 | Let it [Am]be, let it [C/G]be, let it [F]be, let it [C]be`; 43 | 44 | const song = new ChordProParser().parse(chordpro); 45 | const updatedSong = song.setKey(null); 46 | 47 | expect(updatedSong.key).toBeNull(); 48 | expect(new ChordProFormatter().format(updatedSong)).toEqual(changedSheet); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/chord_sheet/line_expander.ts: -------------------------------------------------------------------------------- 1 | import Item from './item'; 2 | import Line from './line'; 3 | import Song from './song'; 4 | import Tag from './tag'; 5 | 6 | import { CHORUS } from '../constants'; 7 | import { END_OF_CHORUS, START_OF_CHORUS } from './tags'; 8 | 9 | class LineExpander { 10 | line: Line; 11 | 12 | song: Song; 13 | 14 | static expand(line: Line, song: Song): Line[] { 15 | return new LineExpander(line, song).expand(); 16 | } 17 | 18 | constructor(line: Line, song: Song) { 19 | this.line = line; 20 | this.song = song; 21 | } 22 | 23 | expand(): Line[] { 24 | const expandedLines = this.line.items.flatMap((item: Item) => { 25 | if (item instanceof Tag && item.name === CHORUS) { 26 | return this.getLastChorusBefore(this.line.lineNumber); 27 | } 28 | 29 | return []; 30 | }); 31 | 32 | return [this.line, ...expandedLines]; 33 | } 34 | 35 | private getLastChorusBefore(lineNumber: number | null): Line[] { 36 | const lines: Line[] = []; 37 | 38 | if (!lineNumber) { 39 | return lines; 40 | } 41 | 42 | for (let i = lineNumber - 1; i >= 0; i -= 1) { 43 | const line = this.song.lines[i]; 44 | 45 | if (line.type !== CHORUS && lines.length > 0) { 46 | break; 47 | } 48 | 49 | if (line.type === CHORUS && (line.isEmpty() || this.lineHasMoreThanChorusDirectives(line))) { 50 | lines.unshift(line); 51 | } 52 | } 53 | 54 | return lines; 55 | } 56 | 57 | private lineHasMoreThanChorusDirectives(line: Line): boolean { 58 | return line.items.some((item: Item) => { 59 | if (item instanceof Tag) { 60 | if (item.name === START_OF_CHORUS || item.name === END_OF_CHORUS) { 61 | return false; 62 | } 63 | } 64 | 65 | return true; 66 | }); 67 | } 68 | } 69 | 70 | export default LineExpander; 71 | -------------------------------------------------------------------------------- /test/key/to_numeric.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint quote-props: 0 */ 2 | 3 | import { FLAT, SHARP } from '../../src/constants'; 4 | import { Key, NUMERIC } from '../../src'; 5 | 6 | const examples = { 7 | 'C': { 8 | 'C': '1', 9 | 'C#': '#1', 10 | 'D': '2', 11 | 'D#': '#2', 12 | 'B': '7', 13 | 14 | 'I': '1', 15 | '#I': '#1', 16 | 'II': '2', 17 | '#II': '#2', 18 | 'VII': '7', 19 | }, 20 | 21 | 'C#': { 22 | 'D#': '2', 23 | 'E': '#2', 24 | 'D': '#1', 25 | 'G': '#4', 26 | 27 | 'II': '2', 28 | '#II': '#2', 29 | '#I': '#1', 30 | '#IV': '#4', 31 | }, 32 | 33 | 'Eb': { 34 | 'F': '2', 35 | 'Gb': 'b3', 36 | 'E': 'b2', 37 | 38 | 'II': '2', 39 | 'bIII': 'b3', 40 | 'bII': 'b2', 41 | }, 42 | 43 | 'B': { 44 | 'F#': '5', 45 | 'A#': '7', 46 | 47 | 'V': '5', 48 | 'VII': '7', 49 | }, 50 | }; 51 | 52 | describe('Key', () => { 53 | describe('toNumeric', () => { 54 | Object.entries(examples).forEach(([songKeyString, conversions]) => { 55 | const songKey = Key.parse(songKeyString); 56 | 57 | Object.entries(conversions).forEach(([symbolKey, numericKey]) => { 58 | it(`converts ${symbolKey} to ${numericKey} (actual key: ${songKey})`, () => { 59 | const key = Key.parseOrFail(symbolKey); 60 | const numericString = key.toNumericString(songKey); 61 | expect(numericString).toEqual(numericKey); 62 | }); 63 | }); 64 | }); 65 | 66 | it('returns a clone when the key is already numeric', () => { 67 | const key = new Key({ 68 | grade: 5, 69 | type: NUMERIC, 70 | modifier: SHARP, 71 | preferredModifier: FLAT, 72 | minor: false, 73 | }); 74 | 75 | const numericKey = key.toNumeric(); 76 | 77 | expect(numericKey).toEqual(key); 78 | expect(numericKey).not.toBe(key); 79 | }); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /src/chord_sheet/font_size.ts: -------------------------------------------------------------------------------- 1 | type Size = 'px' | '%'; 2 | 3 | class FontSize { 4 | /** 5 | * The size unit, either `"px"` or `"%"` 6 | * @member {string} 7 | */ 8 | unit: Size; 9 | 10 | /** 11 | * The font size 12 | * @member {number} 13 | */ 14 | fontSize: number; 15 | 16 | constructor(fontSize: number, kind: Size) { 17 | this.fontSize = fontSize; 18 | this.unit = kind; 19 | } 20 | 21 | clone() { 22 | return new FontSize(this.fontSize, this.unit); 23 | } 24 | 25 | multiply(percentage): FontSize { 26 | return new FontSize((this.fontSize * percentage) / 100, this.unit); 27 | } 28 | 29 | /** 30 | * Stringifies the font size by concatenating size and unit 31 | * 32 | * @example 33 | * // Returns "30px" 34 | * new FontSize(30, 'px').toString() 35 | * @example 36 | * // Returns "120%" 37 | * new FontSize(120, '%').toString() 38 | * 39 | * @return {string} The font size 40 | */ 41 | toString() { 42 | return `${this.fontSize}${this.unit}`; 43 | } 44 | 45 | static parse(fontSize: string, parent: FontSize | null) { 46 | const trimmed = fontSize.trim(); 47 | const parsedFontSize = parseFloat(trimmed); 48 | 49 | if (Number.isNaN(parsedFontSize)) { 50 | return this.parseNotANumber(parent); 51 | } 52 | 53 | if (trimmed.slice(-1) === '%') { 54 | return this.parsePercentage(parsedFontSize, parent); 55 | } 56 | 57 | return new FontSize(parsedFontSize, 'px'); 58 | } 59 | 60 | static parseNotANumber(parent: FontSize | null) { 61 | if (parent) { 62 | return parent.clone(); 63 | } 64 | 65 | return new FontSize(100, '%'); 66 | } 67 | 68 | static parsePercentage(parsedFontSize: number, parent: FontSize | null) { 69 | if (parent) { 70 | return parent.multiply(parsedFontSize); 71 | } 72 | 73 | return new FontSize(parsedFontSize, '%'); 74 | } 75 | } 76 | 77 | export default FontSize; 78 | -------------------------------------------------------------------------------- /script/helpers/peggy_online.ts: -------------------------------------------------------------------------------- 1 | import process from 'process'; 2 | import puppeteer, { Browser, Page } from 'puppeteer'; 3 | 4 | class PeggyOnline { 5 | parserSource: string; 6 | 7 | static open(parserSource: string): Promise { 8 | return new PeggyOnline(parserSource).open(); 9 | } 10 | 11 | constructor(parserSource: string) { 12 | this.parserSource = parserSource; 13 | } 14 | 15 | async open(): Promise { 16 | const browser = await this.launchBrowser(); 17 | 18 | async function shutdownHandler() { 19 | await browser.close(); 20 | } 21 | 22 | this.attachShutdownHandler(shutdownHandler); 23 | const page = await this.openPage(browser); 24 | await this.addGrammar(page); 25 | } 26 | 27 | async launchBrowser() { 28 | return puppeteer.launch({ 29 | args: ['--start-maximized'], 30 | defaultViewport: null, 31 | headless: false, 32 | }); 33 | } 34 | 35 | async openPage(browser: Browser): Promise { 36 | const [page] = await browser.pages(); 37 | await page.setViewport({ width: 0, height: 0 }); 38 | await page.goto('https://peggyjs.org/online.html'); 39 | return page; 40 | } 41 | 42 | async addGrammar(page: Page) { 43 | await page.evaluate((grammar) => { 44 | // eslint-disable-next-line no-undef 45 | const textarea = document.getElementById('grammar'); 46 | if (!textarea) return; 47 | 48 | const editorNode = textarea.nextSibling; 49 | if (!editorNode) return; 50 | 51 | // @ts-expect-error There is no way to validate that the CodeMirror object is present 52 | const editor = editorNode.CodeMirror; 53 | editor.setValue(grammar); 54 | }, this.parserSource); 55 | } 56 | 57 | attachShutdownHandler(shutdownHandler: (...args: any[]) => void) { 58 | ['exit', 'SIGINT', 'SIGUSR1', 'SIGUSR2', 'uncaughtException', 'SIGTERM'].forEach((event) => { 59 | process.on(event, shutdownHandler); 60 | }); 61 | } 62 | } 63 | 64 | export default PeggyOnline; 65 | -------------------------------------------------------------------------------- /script/build_chord_pro_section_grammar.ts: -------------------------------------------------------------------------------- 1 | import sections from '../data/sections'; 2 | 3 | interface BuildOptions { 4 | force: boolean; 5 | release: boolean; 6 | } 7 | 8 | function capitalize(string: string) { 9 | return `${string.charAt(0).toUpperCase() + string.slice(1).toLowerCase()}`; 10 | } 11 | 12 | function sectionRuleName(sectionName: string) { 13 | return `${capitalize(sectionName)}Section`; 14 | } 15 | 16 | function sectionShortTag(sectionName: string, type: 'start' | 'end'): string { 17 | return `${type[0]}o${sectionName[0]}`; 18 | } 19 | 20 | function sectionTags(sectionName: string, shortTag: boolean, type: 'start' | 'end') { 21 | const tags = [`${type}_of_${sectionName}`]; 22 | if (shortTag) tags.push(sectionShortTag(sectionName, type)); 23 | return tags.map((tag) => `"${tag}"`).join(' / '); 24 | } 25 | 26 | export default function buildChordProSectionGrammar(_: BuildOptions, _data: string): string { 27 | const sectionsGrammars = sections.map(([name, shortTags]) => { 28 | const sectionName = capitalize(name); 29 | const startTag = sectionTags(name, shortTags, 'start'); 30 | const endTag = sectionTags(name, shortTags, 'end'); 31 | 32 | return ` 33 | ${sectionName}Section 34 | = startTag:${sectionName}StartTag 35 | NewLine 36 | content:$(!${sectionName}EndTag SectionCharacter)* 37 | endTag:${sectionName}EndTag 38 | { 39 | return helpers.buildSection(startTag, endTag, content); 40 | } 41 | 42 | ${sectionName}StartTag 43 | = "{" _ tagName:(${startTag}) selector:TagSelector? _ tagColonWithValue:TagColonWithValue? _ "}" { 44 | return helpers.buildTag(tagName, tagColonWithValue, selector, location()); 45 | } 46 | 47 | ${sectionName}EndTag 48 | = "{" _ tagName:(${endTag}) _ "}" { 49 | return helpers.buildTag(tagName, null, null, location()); 50 | } 51 | `; 52 | }); 53 | 54 | return ` 55 | Section 56 | = ${sections.map(([name, _shortTags]) => sectionRuleName(name)).join(' / ')} 57 | 58 | ${sectionsGrammars.join('\n\n')} 59 | 60 | SectionCharacter 61 | = . 62 | `.substring(1); 63 | } 64 | -------------------------------------------------------------------------------- /test/integration/chord_pro_to_chord_pro.test.ts: -------------------------------------------------------------------------------- 1 | import { heredoc } from '../utilities'; 2 | import { ChordProFormatter, ChordProParser } from '../../src'; 3 | 4 | describe('chordpro e2e', () => { 5 | it('correctly parses and evaluates meta expressions', () => { 6 | const chordSheet = heredoc` 7 | {title: A} 8 | {artist: B} 9 | %{title} 10 | %{artist|%{}} 11 | %{artist=X|artist is X|artist is not X} 12 | %{c|c is set|c is unset} 13 | %{artist|artist is %{}|artist is unset} 14 | %{title|title is set and c is %{c|set|unset}|title is unset}`; 15 | 16 | const expectedEvaluation = heredoc` 17 | {title: A} 18 | {artist: B} 19 | A 20 | B 21 | artist is not X 22 | c is unset 23 | artist is B 24 | title is set and c is unset`; 25 | 26 | const song = new ChordProParser().parse(chordSheet); 27 | const formatted = new ChordProFormatter({ evaluate: true }).format(song); 28 | 29 | expect(formatted).toEqual(expectedEvaluation); 30 | }); 31 | 32 | it('correctly parses and formats meta expressions', () => { 33 | const chordSheet = heredoc` 34 | {title: A} 35 | {artist: B} 36 | %{title} 37 | %{artist|%{}} 38 | %{artist=X|artist is X|artist is not X} 39 | %{c|c is set|c is unset} 40 | %{artist|artist is %{}|artist is unset} 41 | %{title|title is set and c is %{c|set|unset}|title is unset}`; 42 | 43 | const song = new ChordProParser().parse(chordSheet); 44 | const formatted = new ChordProFormatter({ evaluate: false }).format(song); 45 | 46 | expect(formatted).toEqual(chordSheet); 47 | }); 48 | 49 | it('does not fail on empty chord sheet', () => { 50 | const song = new ChordProParser().parse(''); 51 | const formatted = new ChordProFormatter().format(song); 52 | 53 | expect(formatted).toEqual(''); 54 | }); 55 | 56 | it('correctly parses and formats meta expressions with errors', () => { 57 | const chordSheet = heredoc` 58 | {key: Numbers} 59 | 60 | [Ab] Hello`; 61 | 62 | const song = new ChordProParser().parse(chordSheet); 63 | const formatted = new ChordProFormatter({ evaluate: false }).format(song); 64 | 65 | expect(formatted).toEqual(chordSheet); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/chord_sheet/font_stack.ts: -------------------------------------------------------------------------------- 1 | import Font from './font'; 2 | import FontSize from './font_size'; 3 | import Tag from './tag'; 4 | 5 | import { 6 | CHORDCOLOUR, 7 | CHORDFONT, 8 | CHORDSIZE, 9 | TEXTCOLOUR, 10 | TEXTFONT, 11 | TEXTSIZE, 12 | } from './tags'; 13 | 14 | class FontStack { 15 | fontAndColourStacks: Record = { 16 | [CHORDCOLOUR]: [], 17 | [CHORDFONT]: [], 18 | [TEXTCOLOUR]: [], 19 | [TEXTFONT]: [], 20 | }; 21 | 22 | sizeStacks: Record = { 23 | [CHORDSIZE]: [], 24 | [TEXTSIZE]: [], 25 | }; 26 | 27 | textFont: Font = new Font(); 28 | 29 | chordFont: Font = new Font(); 30 | 31 | applyTag(tag: Tag) { 32 | switch (tag.name) { 33 | case TEXTFONT: 34 | this.textFont.font = this.pushOrPopTag(tag); 35 | break; 36 | 37 | case TEXTSIZE: 38 | this.textFont.size = this.pushOrPopSizeTag(tag); 39 | break; 40 | 41 | case TEXTCOLOUR: 42 | this.textFont.colour = this.pushOrPopTag(tag); 43 | break; 44 | 45 | case CHORDFONT: 46 | this.chordFont.font = this.pushOrPopTag(tag); 47 | break; 48 | 49 | case CHORDSIZE: 50 | this.chordFont.size = this.pushOrPopSizeTag(tag); 51 | break; 52 | 53 | case CHORDCOLOUR: 54 | this.chordFont.colour = this.pushOrPopTag(tag); 55 | break; 56 | 57 | default: 58 | break; 59 | } 60 | } 61 | 62 | private pushOrPopTag(tag: Tag): string | null { 63 | let { value }: { value: string | null } = tag; 64 | 65 | if (tag.hasValue()) { 66 | this.fontAndColourStacks[tag.name].push(value); 67 | } else { 68 | this.fontAndColourStacks[tag.name].pop(); 69 | value = this.fontAndColourStacks[tag.name].slice(-1)[0] || null; 70 | } 71 | 72 | return value; 73 | } 74 | 75 | private pushOrPopSizeTag(tag: Tag): FontSize | null { 76 | const { value }: { value: string | null } = tag; 77 | 78 | if (tag.hasValue()) { 79 | const parent: FontSize | null = this.sizeStacks[tag.name].slice(-1)[0] || null; 80 | const parsedFontSize: FontSize = FontSize.parse(value, parent); 81 | this.sizeStacks[tag.name].push(parsedFontSize); 82 | return parsedFontSize; 83 | } 84 | 85 | this.sizeStacks[tag.name].pop(); 86 | return this.sizeStacks[tag.name].slice(-1)[0] || null; 87 | } 88 | } 89 | 90 | export default FontStack; 91 | -------------------------------------------------------------------------------- /src/parser/chord/base_grammar.pegjs: -------------------------------------------------------------------------------- 1 | Chord 2 | = chord:(Numeral / Numeric / ChordSolfege / ChordSymbol) { 3 | return { type: "chord", ...chord, column: location().start.column }; 4 | } 5 | 6 | ChordModifier 7 | = "#" / "b" 8 | 9 | ChordSymbol 10 | = root:ChordSymbolRoot modifier:ChordModifier? suffix:$(ChordSuffix) bass:ChordSymbolBass? { 11 | return { base: root, modifier, suffix, ...bass, chordType: "symbol" }; 12 | } 13 | / bass:ChordSymbolBass { 14 | return { base: null, modifier: null, suffix: null, ...bass, chordType: "symbol" }; 15 | } 16 | 17 | ChordSymbolRoot 18 | = [A-Ga-g] 19 | 20 | ChordSymbolBass 21 | = "/" root:ChordSymbolRoot modifier:ChordModifier? { 22 | return { bassBase: root, bassModifier: modifier }; 23 | } 24 | 25 | ChordSolfege 26 | = root:ChordSolfegeRoot modifier:ChordModifier? suffix:$(ChordSuffix) bass:ChordSolfegeBass? { 27 | return { base: root, modifier, suffix, ...bass, chordType: "solfege" }; 28 | } 29 | / bass:ChordSolfegeBass { 30 | return { base: null, modifier: null, suffix: null, ...bass, chordType: "solfege" }; 31 | } 32 | 33 | ChordSolfegeRoot 34 | = "Do"i / "Re"i / "Mi"i / "Fa"i / "Sol"i / "La"i / "Si"i 35 | 36 | ChordSolfegeBass 37 | = "/" root:ChordSolfegeRoot modifier:ChordModifier? { 38 | return { bassBase: root, bassModifier: modifier }; 39 | } 40 | 41 | Numeral 42 | = modifier:ChordModifier? root:NumeralRoot suffix:$(ChordSuffix) bass:NumeralBass? { 43 | return { base: root, modifier, suffix, ...bass, chordType: "numeral" }; 44 | } 45 | / bass:NumeralBass { 46 | return { base: null, modifier: null, suffix: null, ...bass, chordType: "numeral" }; 47 | } 48 | 49 | NumeralRoot 50 | = "III"i / "VII"i / "II"i / "IV"i / "VI"i / "I"i / "V"i 51 | 52 | NumeralBass 53 | = "/" modifier:ChordModifier? root:NumeralRoot { 54 | return { bassBase: root, bassModifier: modifier }; 55 | } 56 | 57 | Numeric 58 | = modifier:ChordModifier? root:NumericRoot suffix:$(ChordSuffix) bass:NumericBass? { 59 | return { base: root, modifier, suffix, ...bass, chordType: "numeric" }; 60 | } 61 | / bass:NumericBass { 62 | return { base: null, modifier: null, suffix: null, ...bass, chordType: "numeric" }; 63 | } 64 | 65 | NumericRoot 66 | = [1-7] 67 | 68 | NumericBass 69 | = "/" modifier:ChordModifier? root:NumericRoot { 70 | return { bassBase: root, bassModifier: modifier }; 71 | } 72 | -------------------------------------------------------------------------------- /test/key/normalize.test.ts: -------------------------------------------------------------------------------- 1 | import { buildKey } from '../utilities'; 2 | 3 | import { 4 | NUMERAL, 5 | NUMERIC, 6 | SOLFEGE, 7 | SYMBOL, 8 | } from '../../src'; 9 | 10 | describe('Key', () => { 11 | describe('normalize', () => { 12 | it('normalizes E#', () => { 13 | expect(buildKey('E', SYMBOL, '#').normalize().toString()).toEqual('F'); 14 | }); 15 | 16 | it('normalizes B#', () => { 17 | expect(buildKey('B', SYMBOL, '#').normalize().toString()).toEqual('C'); 18 | }); 19 | 20 | it('normalizes Cb', () => { 21 | expect(buildKey('C', SYMBOL, 'b').normalize().toString()).toEqual('B'); 22 | }); 23 | 24 | it('normalizes Fb', () => { 25 | expect(buildKey('F', SYMBOL, 'b').normalize().toString()).toEqual('E'); 26 | }); 27 | 28 | it('normalizes Mi#', () => { 29 | expect(buildKey('Mi', SOLFEGE, '#').normalize().toString()).toEqual('Fa'); 30 | }); 31 | 32 | it('normalizes Si#', () => { 33 | expect(buildKey('Si', SOLFEGE, '#').normalize().toString()).toEqual('Do'); 34 | }); 35 | 36 | it('normalizes Dob', () => { 37 | expect(buildKey('Do', SOLFEGE, 'b').normalize().toString()).toEqual('Si'); 38 | }); 39 | 40 | it('normalizes Fab', () => { 41 | expect(buildKey('Fa', SOLFEGE, 'b').normalize().toString()).toEqual('Mi'); 42 | }); 43 | 44 | it('normalizes #3', () => { 45 | expect(buildKey(3, NUMERIC, '#').normalize().toString()).toEqual('4'); 46 | }); 47 | 48 | it('normalizes #7', () => { 49 | expect(buildKey('7', NUMERIC, '#').normalize().toString()).toEqual('1'); 50 | }); 51 | 52 | it('normalizes b1', () => { 53 | expect(buildKey(1, NUMERIC, 'b').normalize().toString()).toEqual('7'); 54 | }); 55 | 56 | it('normalizes b4', () => { 57 | expect(buildKey(4, NUMERIC, 'b').normalize().toString()).toEqual('3'); 58 | }); 59 | 60 | it('normalizes #III', () => { 61 | expect(buildKey('III', NUMERAL, '#').normalize().toString()).toEqual('IV'); 62 | }); 63 | 64 | it('normalizes #VII', () => { 65 | expect(buildKey('VII', NUMERAL, '#').normalize().toString()).toEqual('I'); 66 | }); 67 | 68 | it('normalizes bI', () => { 69 | expect(buildKey('I', NUMERAL, 'b').normalize().toString()).toEqual('VII'); 70 | }); 71 | 72 | it('normalizes bIV', () => { 73 | expect(buildKey('IV', NUMERAL, 'b').normalize().toString()).toEqual('III'); 74 | }); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/chord_sheet/font.ts: -------------------------------------------------------------------------------- 1 | import FontSize from './font_size'; 2 | 3 | interface FontProperties { 4 | font?: string | null; 5 | size?: FontSize | null; 6 | colour?: string | null; 7 | } 8 | 9 | class Font { 10 | /** 11 | * The font 12 | * @member {string | null} 13 | */ 14 | font: string | null = null; 15 | 16 | /** 17 | * The font size, expressed in either pixels or percentage. 18 | * @member {FontSize | null} 19 | */ 20 | size: FontSize | null = null; 21 | 22 | /** 23 | * The font color 24 | * @member {string | null} 25 | */ 26 | colour: string | null = null; 27 | 28 | constructor({ font, size, colour }: FontProperties = { font: null, size: null, colour: null }) { 29 | this.font = font ? font.replace(/"/g, '\'') : null; 30 | this.size = size || null; 31 | this.colour = colour || null; 32 | } 33 | 34 | clone() { 35 | return new Font({ 36 | font: this.font, 37 | size: this.size, 38 | colour: this.colour, 39 | }); 40 | } 41 | 42 | /** 43 | * Converts the font, size and color to a CSS string. 44 | * If possible, font and size are combined to the `font` shorthand. 45 | * If `font` contains double quotes (`"`) those will be converted to single quotes (`'`). 46 | * 47 | * @example 48 | * // Returns "font-family: 'Times New Roman'" 49 | * new Font({ font: '"Times New Roman"' }).toCssString() 50 | * @example 51 | * // Returns "color: red; font-family: Verdana" 52 | * new Font({ font: 'Verdana', colour: 'red' }).toCssString() 53 | * @example 54 | * // Returns "font: 30px Verdana" 55 | * new Font({ font: 'Verdana', size: '30' }).toCssString() 56 | * @example 57 | * // Returns "color: blue; font: 30% Verdana" 58 | * new Font({ font: 'Verdana', size: '30%', colour: 'blue' }).toCssString() 59 | * 60 | * @return {string} The CSS string 61 | */ 62 | toCssString(): string { 63 | const properties: Record = {}; 64 | 65 | if (this.colour) { 66 | properties.color = this.colour; 67 | } 68 | 69 | if (this.font && this.size) { 70 | properties.font = `${this.size} ${this.font}`; 71 | } else if (this.font) { 72 | properties['font-family'] = this.font; 73 | } else if (this.size) { 74 | properties['font-size'] = `${this.size}`; 75 | } 76 | 77 | return Object 78 | .keys(properties) 79 | .map((key) => `${key}: ${properties[key]}`) 80 | .join('; '); 81 | } 82 | } 83 | 84 | export default Font; 85 | -------------------------------------------------------------------------------- /data/scales.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-multi-spaces: 0, array-bracket-spacing: 0, key-spacing: 0 */ 2 | 3 | import { 4 | FLAT, 5 | MAJOR, 6 | MINOR, 7 | NO_MODIFIER, 8 | NUMERAL, 9 | NUMERIC, 10 | SHARP, 11 | SOLFEGE, 12 | SYMBOL, 13 | } from '../src/constants'; 14 | 15 | const americanScale = { 16 | [NO_MODIFIER]: ['C', null, 'D', null, 'E', 'F', null, 'G', null, 'A', null, 'B' ], 17 | [SHARP]: ['B#', 'C#', null, 'D#', null, 'E#', 'F#', null, 'G#', null, 'A#', null], 18 | [FLAT]: [null, 'Db', null, 'Eb', 'Fb', null, 'Gb', null, 'Ab', null, 'Bb', 'Cb'], 19 | }; 20 | 21 | const solfegeScale = { 22 | [NO_MODIFIER]: ['Do', null, 'Re', null, 'Mi', 'Fa', null, 'Sol', null, 'La', null, 'Si' ], 23 | [SHARP]: ['Si#', 'Do#', null, 'Re#', null, 'Mi#', 'Fa#', null, 'Sol#', null, 'La#', null], 24 | [FLAT]: [null, 'Reb', null, 'Mib', 'Fab', null, 'Solb', null, 'Lab', null, 'Sib', 'Dob'], 25 | }; 26 | 27 | const SCALES = { 28 | [SYMBOL]: { 29 | [MINOR]: americanScale, 30 | [MAJOR]: americanScale, 31 | }, 32 | [SOLFEGE]: { 33 | [MINOR]: solfegeScale, 34 | [MAJOR]: solfegeScale, 35 | }, 36 | [NUMERIC]: { 37 | [MINOR]: { 38 | [NO_MODIFIER]: ['1', null, '2', '3', null, '4', null, '5', '6', null, '7', null], 39 | [SHARP]: [null, '#1', null, '#2', '#3', null, '#4', null, '#5', '#6', null, '#7'], 40 | [FLAT]: [null, 'b2', 'b3', null, 'b4', null, 'b5', 'b6', null, 'b7', null, 'b1'], 41 | }, 42 | [MAJOR]: { 43 | [NO_MODIFIER]: ['1', null, '2', null, '3', '4', null, '5', null, '6', null, '7' ], 44 | [SHARP]: ['#7', '#1', null, '#2', null, '#3', '#4', null, '#5', null, '#6', null], 45 | [FLAT]: [null, 'b2', null, 'b3', 'b4', null, 'b5', null, 'b6', null, 'b7', 'b1'], 46 | }, 47 | }, 48 | [NUMERAL]: { 49 | [MINOR]: { 50 | [NO_MODIFIER]: ['I', null, 'II', 'III', null, 'IV', null, 'V', 'VI', null, 'VII', null], 51 | [SHARP]: [null, '#I', null, '#II', '#III', null, '#IV', null, '#V', '#VI', null, '#VII'], 52 | [FLAT]: [null, 'bII', 'bIII', null, 'bIV', null, 'bV', 'bVI', null, 'bVII', null, 'bI'], 53 | }, 54 | [MAJOR]: { 55 | [NO_MODIFIER]: ['I', null, 'II', null, 'III', 'IV', null, 'V', null, 'VI', null, 'VII'], 56 | [SHARP]: ['#VII', '#I', null, '#II', null, '#III', '#IV', null, '#V', null, '#VI', null ], 57 | [FLAT]: [null, 'bII', null, 'bIII', 'bIV', null, 'bV', null, 'bVI', null, 'bVII', 'bI' ], 58 | }, 59 | }, 60 | }; 61 | 62 | export default SCALES; 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chordsheetjs", 3 | "author": "Martijn Versluis", 4 | "version": "12.3.1", 5 | "description": "A JavaScript library for parsing and formatting chord sheets", 6 | "source": "src/index.ts", 7 | "main": "lib/index.js", 8 | "module": "lib/module.js", 9 | "types": "lib/main.d.ts", 10 | "files": [ 11 | "/lib" 12 | ], 13 | "bundle": { 14 | "default": "lib/bundle.js", 15 | "minified": "lib/bundle.min.js", 16 | "globalName": "ChordSheetJS" 17 | }, 18 | "license": "GPL-2.0-only", 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/martijnversluis/ChordSheetJS.git" 22 | }, 23 | "bugs": { 24 | "url": "https://github.com/martijnversluis/ChordSheetJS/issues" 25 | }, 26 | "homepage": "https://github.com/martijnversluis/ChordSheetJS", 27 | "engines": { 28 | "node": ">=16" 29 | }, 30 | "devDependencies": { 31 | "@eslint/core": "^0.16.0", 32 | "@eslint/js": "^9.11.0", 33 | "@martijnversluis/unibuild": "^2.0.3", 34 | "@parcel/packager-ts": "^2.15.4", 35 | "@parcel/transformer-typescript-types": "^2.15.4", 36 | "@types/jest": "^30.0.0", 37 | "@types/node": "^24.0.0", 38 | "esbuild": "^0.25.0", 39 | "eslint": "^9.11.0", 40 | "eslint-config-airbnb": "^19.0.4", 41 | "eslint-plugin-jest": "^29.0.0", 42 | "globals": "^16.0.0", 43 | "jest": "^30.0.0", 44 | "parcel": "^2.15.4", 45 | "peggy": "^5.0.2", 46 | "pegjs-backtrace": "^0.2.1", 47 | "print": "^1.2.0", 48 | "puppeteer": "^24.0.0", 49 | "theredoc": "^1.0.0", 50 | "ts-jest": "^29.2.3", 51 | "ts-node": "^10.9.2", 52 | "ts-pegjs": "^3.0.0", 53 | "tsx": "^4.10.5", 54 | "typedoc": "^0.28.0", 55 | "typescript": "^5.7.3", 56 | "typescript-eslint": "^8.6.0" 57 | }, 58 | "scripts": { 59 | "build": "yarn unibuild", 60 | "build:release": "yarn unibuild --force --release", 61 | "ci": "yarn install && yarn unibuild ci", 62 | "debug:chord": "yarn build && tsx script/debug_parser.ts chord", 63 | "debug:chordpro": "yarn build && tsx script/debug_parser.ts chord_pro", 64 | "debug:chords-over-words": "yarn build && tsx script/debug_parser.ts chords_over_words --include-chord-grammar", 65 | "eslint": "node_modules/.bin/eslint", 66 | "lint": "yarn unibuild lint", 67 | "lint:fix": "yarn unibuild lint --fix", 68 | "postversion": "yarn build:release", 69 | "prepare": "yarn install && yarn build", 70 | "release": "yarn unibuild release", 71 | "test": "yarn unibuild lint && yarn unibuild test" 72 | }, 73 | "packageManager": "yarn@4.2.2" 74 | } 75 | -------------------------------------------------------------------------------- /src/formatter/configuration.ts: -------------------------------------------------------------------------------- 1 | import Key from '../key'; 2 | 3 | import { ContentType } from '../serialized_types'; 4 | 5 | export type Delegate = (_string: string) => string; 6 | export const defaultDelegate: Delegate = (string: string) => string; 7 | 8 | interface MetadataConfiguration { 9 | separator: string; 10 | } 11 | 12 | interface InstrumentConfiguration { 13 | type?: string; 14 | description?: string; 15 | } 16 | 17 | interface UserConfigurationProperties { 18 | name?: string; 19 | fullname?: string; 20 | } 21 | 22 | const defaultMetadataConfiguration: MetadataConfiguration = { 23 | separator: ',', 24 | }; 25 | 26 | interface DelegatesConfiguration { 27 | abc: Delegate; 28 | ly: Delegate; 29 | tab: Delegate; 30 | grid: Delegate; 31 | } 32 | 33 | const defaultDelegatesConfiguration: DelegatesConfiguration = { 34 | abc: defaultDelegate, 35 | ly: defaultDelegate, 36 | tab: defaultDelegate, 37 | grid: defaultDelegate, 38 | }; 39 | 40 | type Configuration = Record & { 41 | decapo: boolean; 42 | delegates: Partial>; 43 | evaluate: boolean, 44 | expandChorusDirective: boolean, 45 | instrument: InstrumentConfiguration | null; 46 | key: Key | null, 47 | metadata: MetadataConfiguration, 48 | normalizeChords: boolean, 49 | useUnicodeModifiers: boolean, 50 | user: UserConfigurationProperties | null; 51 | }; 52 | 53 | export type ConfigurationProperties = Record & Partial<{ 54 | decapo: boolean; 55 | delegates: Partial, 56 | evaluate: boolean, 57 | expandChorusDirective: boolean, 58 | instrument: Partial, 59 | key: Key | string | null, 60 | metadata: Partial, 61 | normalizeChords: boolean, 62 | useUnicodeModifiers: boolean, 63 | user: Partial, 64 | }>; 65 | 66 | const defaultConfiguration: Configuration = { 67 | decapo: false, 68 | delegates: defaultDelegatesConfiguration, 69 | evaluate: false, 70 | expandChorusDirective: false, 71 | instrument: null, 72 | key: null, 73 | metadata: defaultMetadataConfiguration, 74 | normalizeChords: true, 75 | useUnicodeModifiers: false, 76 | user: null, 77 | }; 78 | 79 | export function configure(configuration: ConfigurationProperties): Configuration { 80 | return { 81 | ...defaultConfiguration, 82 | ...configuration, 83 | metadata: { ...defaultMetadataConfiguration, ...configuration.metadata }, 84 | delegates: { ...defaultDelegatesConfiguration, ...configuration.delegates }, 85 | key: configuration.key ? Key.wrap(configuration.key) : null, 86 | }; 87 | } 88 | 89 | export default Configuration; 90 | -------------------------------------------------------------------------------- /test/chord_definition/chord_definition.test.ts: -------------------------------------------------------------------------------- 1 | import { ChordDefinition } from '../../src'; 2 | import { Fret } from '../../src/constants'; 3 | import SUFFIX_MAPPING from '../../src/normalize_mappings/suffix-normalize-mapping'; 4 | 5 | describe('ChordDefinition', () => { 6 | describe('#clone', () => { 7 | it('returns a deep copy', () => { 8 | const frets: Fret[] = ['x', 3, 2, 3, 1, 'x']; 9 | const fingers: number[] = [1, 2, 3, 4, 5, 6]; 10 | 11 | const chordDefinition = new ChordDefinition('C', 1, frets, fingers); 12 | const clone = chordDefinition.clone(); 13 | 14 | expect(clone.name).toEqual('C'); 15 | expect(clone.baseFret).toEqual(1); 16 | expect(clone.frets).toEqual(frets); 17 | expect(clone.fingers).toEqual(fingers); 18 | 19 | expect(clone.frets).not.toBe(frets); 20 | expect(clone.fingers).not.toBe(fingers); 21 | }); 22 | }); 23 | 24 | describe('::parse', () => { 25 | it('parses a chord definition', () => { 26 | const chordDefinition = ChordDefinition.parse(' D7 base-fret 3 frets x 3 2 3 1 x '); 27 | 28 | expect(chordDefinition.name).toEqual('D7'); 29 | expect(chordDefinition.baseFret).toEqual(3); 30 | expect(chordDefinition.frets).toEqual(['x', 3, 2, 3, 1, 'x']); 31 | expect(chordDefinition.fingers).toEqual([]); 32 | }); 33 | 34 | it('parses a chord definition with fingers', () => { 35 | const chordDefinition = ChordDefinition.parse('D7 base-fret 3 frets x 3 2 3 1 x fingers 1 2 3 4 5 6'); 36 | 37 | expect(chordDefinition.name).toEqual('D7'); 38 | expect(chordDefinition.baseFret).toEqual(3); 39 | expect(chordDefinition.frets).toEqual(['x', 3, 2, 3, 1, 'x']); 40 | expect(chordDefinition.fingers).toEqual([1, 2, 3, 4, 5, 6]); 41 | }); 42 | 43 | it('parses a chord definition without base-fret', () => { 44 | const chordDefinition = ChordDefinition.parse('D7 frets x 3 2 3 1 x fingers 1 2 3 4 5 6'); 45 | 46 | expect(chordDefinition.name).toEqual('D7'); 47 | expect(chordDefinition.baseFret).toEqual(1); 48 | expect(chordDefinition.frets).toEqual(['x', 3, 2, 3, 1, 'x']); 49 | expect(chordDefinition.fingers).toEqual([1, 2, 3, 4, 5, 6]); 50 | }); 51 | 52 | Object 53 | .keys(SUFFIX_MAPPING) 54 | .filter((suffix) => suffix !== '[blank]') 55 | .forEach((suffix) => { 56 | it(`can parse a chord definition with suffix ${suffix}`, () => { 57 | const chord = `Db${suffix}/A#`; 58 | const chordDefinition = ChordDefinition.parse(`${chord} base-fret 3 frets x 3 2 3 1 x`); 59 | expect(chordDefinition.name).toEqual(chord); 60 | }); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | I love receiving pull requests from everyone! Please read this short document before you start, 4 | 5 | ## ⚠️ Gotchas 6 | 7 | ### `README.md` 8 | 9 | Are you trying to make changes to `README.md`? Wait! `README.md` is a auto-generated file. 10 | - to make changes in the first part, go to [INTRO.md](INTRO.md) 11 | - the api docs are generated from JSdoc comment embedded in the code, so changing those 12 | comments will result in API doc changes. 13 | 14 | When your changes are complete, be sure to run `yarn readme` to regenerate `README.md` and commit the updated `README.md` _together_ with the `INTRO.md` changes and/or API doc changes. 15 | 16 | ## Pull request guidelines 17 | 18 | N.B. I do not expect you to have all required knowledge and experience to meet these guidelines; 19 | I'm happy to help you out! ❤️ 20 | However, the better your PR meets these guidelines the sooner it will get merged. 21 | 22 | - try to use a code style that is consistent with the existing code 23 | - code changes go hand in hand with tests. 24 | - if possible, write a test that proves the bug before writing/changing code to fix it 25 | - if new code you contribute is expected to be public API (called directly by users instead of only used within ChordSheetJS), 26 | you'd make me really happy by adding JSdoc comments. 27 | - write a [good commit message][commit]. If your PR resolves an issue you can [link it to your commit][link_issue]. 28 | 29 | [commit]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html 30 | [link_issue]: https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword 31 | 32 | ## Get started 33 | 34 | Ensure your have NodeJS and Yarn on your machine. The [CI workflow][ci_workflow] lists the NodeJS versions that 35 | are expected to work. 36 | 37 | [ci_workflow]: https://github.com/martijnversluis/ChordSheetJS/blob/master/.github/workflows/ci.yml#L17 38 | 39 | Fork, then clone the repo: 40 | 41 | git clone git@github.com:your-username/ChordSheetJS.git 42 | 43 | ChordSheetJS uses Yarn 4. For that to work, Corepack need to be enabled: 44 | 45 | corepack enable 46 | 47 | ⚠️ NB: In my experience this only guaranteed to work when using Node's Yarn. 48 | Yarn installed by an external package manager (like Homebrew) will/might not work. 49 | 50 | Install the required node modules: 51 | 52 | yarn install 53 | 54 | Make sure the tests pass: 55 | 56 | yarn test 57 | 58 | Make your change. Add tests for your change. Make the tests pass: 59 | 60 | yarn test 61 | 62 | Push to your fork and [submit a pull request][pr]. 63 | 64 | [pr]: https://github.com/martijnversluis/ChordSheetJS/compare/ 65 | -------------------------------------------------------------------------------- /test/fixtures/chord_pro_sheet.ts: -------------------------------------------------------------------------------- 1 | import { heredoc } from '../utilities'; 2 | 3 | export const chordProSheetSymbol = heredoc` 4 | {title: Let it be} 5 | {subtitle: ChordSheetJS example version} 6 | {key: C} 7 | {x_some_setting} 8 | {composer: John Lennon} 9 | {composer: Paul McCartney} 10 | #This is my favorite song 11 | 12 | Written by: %{composer|%{}|No composer defined for %{title|%{}|Untitled song}} 13 | 14 | {start_of_verse: Verse 1} 15 | Let it [Am]be, \\ let it [C/G]be, let it [F]be, let it [C]be 16 | {transpose: 2} 17 | [C]Whisper [*strong]words of [F]wis[G]dom, let it [F]be [C/E] [Dm] [C] 18 | {end_of_verse} 19 | 20 | {start_of_chorus} 21 | {comment: Breakdown} 22 | {transpose: G} 23 | [Am]Whisper words of [Bb]wisdom, let it [F]be [C] 24 | {end_of_chorus} 25 | 26 | {start_of_chorus: label="Chorus 2"} 27 | [C]Whisper words of [Bb]wisdom, let it [F]be [C] 28 | {end_of_chorus} 29 | 30 | {start_of_solo: Solo 1} 31 | [C]Solo line 1 32 | [F]Solo line 2 33 | {end_of_solo} 34 | 35 | {start_of_tab: label="Tab 1"} 36 | Tab line 1 37 | Tab line 2 38 | {end_of_tab} 39 | 40 | {start_of_abc: ABC 1} 41 | ABC line 1 42 | ABC line 2 43 | {end_of_abc} 44 | 45 | {start_of_ly: LY 1} 46 | LY line 1 47 | LY line 2 48 | {end_of_ly} 49 | 50 | {start_of_bridge: Bridge 1} 51 | Bridge line 52 | {end_of_bridge} 53 | 54 | {start_of_grid: Grid 1} 55 | Grid line 1 56 | Grid line 2 57 | {end_of_grid}`; 58 | 59 | export const chordProSheetSolfege = heredoc` 60 | {title: Let it be} 61 | {subtitle: ChordSheetJS example version} 62 | {key: Do} 63 | {x_some_setting} 64 | {composer: John Lennon} 65 | {composer: Paul McCartney} 66 | #This is my favorite song 67 | 68 | Written by: %{composer|%{}|No composer defined for %{title|%{}|Untitled song}} 69 | 70 | {start_of_verse: Verse 1} 71 | Let it [Lam]be, let it [Do/Sol]be, let it [Fa]be, let it [Do]be 72 | {transpose: 2} 73 | [Do]Whisper [*strong]words of [Fa]wis[Sol]dom, let it [Fa]be [Do/Mi] [Rem] [Do] 74 | {end_of_verse} 75 | 76 | {start_of_chorus} 77 | {comment: Breakdown} 78 | {transpose: Sol} 79 | [Lam]Whisper words of [Sib]wisdom, let it [Fa]be [Do] 80 | {end_of_chorus} 81 | 82 | {start_of_chorus: label="Chorus 2"} 83 | [Lam]Whisper words of [Sib]wisdom, let it [Fa]be [Do] 84 | {end_of_chorus} 85 | 86 | {start_of_solo: Solo 1} 87 | [Do]Solo line 1 88 | [Fa]Solo line 2 89 | {end_of_solo} 90 | 91 | {start_of_tab: Tab 1} 92 | Tab line 1 93 | Tab line 2 94 | {end_of_tab} 95 | 96 | {start_of_abc: ABC 1} 97 | ABC line 1 98 | ABC line 2 99 | {end_of_abc} 100 | 101 | {start_of_ly: LY 1} 102 | LY line 1 103 | LY line 2 104 | {end_of_ly} 105 | 106 | {start_of_bridge: Bridge 1} 107 | Bridge line 108 | {end_of_bridge} 109 | 110 | {start_of_grid: Grid 1} 111 | Grid line 1 112 | Grid line 2 113 | {end_of_grid}`; 114 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Used to mark a paragraph as bridge 3 | * @constant 4 | * @type {string} 5 | */ 6 | export const BRIDGE = 'bridge'; 7 | 8 | /** 9 | * Used to mark a paragraph as chorus 10 | * @constant 11 | * @type {string} 12 | */ 13 | export const CHORUS = 'chorus'; 14 | 15 | /** 16 | * Used to mark a paragraph as grid 17 | * @constant 18 | * @type {string} 19 | */ 20 | export const GRID = 'grid'; 21 | 22 | /** 23 | * Used to mark a paragraph as containing lines with both verse and chorus type 24 | * @constant 25 | * @type {string} 26 | */ 27 | export const INDETERMINATE = 'indeterminate'; 28 | 29 | /** 30 | * Used to mark a paragraph as not containing a line marked with a type 31 | * @constant 32 | * @type {string} 33 | */ 34 | export const NONE = 'none'; 35 | 36 | /** 37 | * Used to mark a paragraph as tab 38 | * @constant 39 | * @type {string} 40 | */ 41 | export const TAB = 'tab'; 42 | 43 | /** 44 | * Used to mark a paragraph as verse 45 | * @constant 46 | * @type {string} 47 | */ 48 | export const VERSE = 'verse'; 49 | 50 | /** 51 | * Used to mark a paragraph as part 52 | * @constant 53 | * @type {string} 54 | */ 55 | export const PART = 'part'; 56 | 57 | /** 58 | * Used to mark a section as Lilypond notation 59 | * @constant 60 | * @type {string} 61 | */ 62 | export const LILYPOND = 'ly'; 63 | 64 | /** 65 | * Used to mark a section as ABC music notation 66 | * @constant 67 | * @type {string} 68 | */ 69 | export const ABC = 'abc'; 70 | 71 | export type ParagraphType = 72 | 'abc' | 73 | 'bridge' | 74 | 'chorus' | 75 | 'grid' | 76 | 'indeterminate' | 77 | 'ly' | 78 | 'none' | 79 | 'tab' | 80 | 'verse' | 81 | 'part' | 82 | string; 83 | 84 | export const SYMBOL = 'symbol'; 85 | export const NUMERIC = 'numeric'; 86 | export const NUMERAL = 'numeral'; 87 | export const SOLFEGE = 'solfege'; 88 | 89 | export const ROMAN_NUMERALS = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII']; 90 | 91 | export const FLAT = 'b'; 92 | export const SHARP = '#'; 93 | export type Modifier = '#' | 'b'; 94 | export const NO_MODIFIER = 'NM'; 95 | export type NoModifier = 'NM'; 96 | export type ModifierMaybe = Modifier | NoModifier; 97 | 98 | export type ChordType = 'symbol' | 'solfege' | 'numeric' | 'numeral'; 99 | export type ChordStyle = 'symbol' | 'solfege' | 'number' | 'numeral'; 100 | export type NullableChordStyle = ChordStyle | null; 101 | 102 | export const MINOR = 'm'; 103 | export const MAJOR = 'M'; 104 | 105 | export type Mode = 'M' | 'm'; 106 | 107 | type FretNumber = number; 108 | type OpenFret = '0'; 109 | type NonSoundingString = '-1' | 'N' | 'x'; 110 | 111 | export type Fret = FretNumber | OpenFret | NonSoundingString; 112 | 113 | export const START_TAG = 'start_tag'; 114 | export const END_TAG = 'end_tag'; 115 | export const AUTO = 'auto'; 116 | -------------------------------------------------------------------------------- /src/serialized_types.ts: -------------------------------------------------------------------------------- 1 | import { ChordType, Fret, Modifier } from './constants'; 2 | 3 | export interface SerializedTraceInfo { 4 | location?: { 5 | offset: number | null, 6 | line: number | null, 7 | column: number | null, 8 | }, 9 | } 10 | 11 | export interface SerializedChord { 12 | type: 'chord', 13 | base: string, 14 | modifier: Modifier | null, 15 | suffix: string | null, 16 | bassBase: string | null, 17 | bassModifier: Modifier | null, 18 | chordType: ChordType, 19 | } 20 | 21 | export interface SerializedChordLyricsPair { 22 | type: 'chordLyricsPair', 23 | chord?: SerializedChord | null, 24 | chords: string, 25 | lyrics: string | null, 26 | annotation?: string | null, 27 | } 28 | 29 | export interface SerializedChordDefinition { 30 | name: string, 31 | baseFret: number, 32 | frets: Fret[], 33 | fingers?: number[], 34 | } 35 | 36 | export type SerializedTag = SerializedTraceInfo & { 37 | type: 'tag', 38 | name: string, 39 | value: string, 40 | chordDefinition?: SerializedChordDefinition, 41 | attributes?: Record, 42 | selector?: string | null, 43 | isNegated?: boolean, 44 | }; 45 | 46 | export interface SerializedComment { 47 | type: 'comment', 48 | comment: string, 49 | } 50 | 51 | export type ContentType = 'tab' | 'abc' | 'ly' | 'grid'; 52 | 53 | export type PartTypes = 'part' | 'intro' | 'instrumental' | 'tag' | 'end'; 54 | 55 | export interface SerializedSection { 56 | type: 'section', 57 | sectionType: ContentType, 58 | content: string[], 59 | startTag: SerializedTag, 60 | endTag: SerializedTag, 61 | } 62 | 63 | export type SerializedLiteral = string; 64 | 65 | export interface SerializedTernary extends SerializedTraceInfo { 66 | type: 'ternary', 67 | variable: string | null, 68 | valueTest: string | null, 69 | trueExpression: (SerializedLiteral | SerializedTernary)[], 70 | falseExpression: (SerializedLiteral | SerializedTernary)[], 71 | } 72 | 73 | export type SerializedComposite = (SerializedLiteral | SerializedTernary)[]; 74 | 75 | export interface SerializedSoftLineBreak { 76 | type: 'softLineBreak', 77 | } 78 | 79 | export type SerializedItem = 80 | SerializedChordLyricsPair | 81 | SerializedComment | 82 | SerializedLiteral | 83 | SerializedSoftLineBreak | 84 | SerializedTag | 85 | SerializedTernary; 86 | 87 | export interface SerializedLine { 88 | type: 'line', 89 | items: SerializedItem[], 90 | } 91 | 92 | export interface SerializedSong { 93 | type: 'chordSheet', 94 | lines: SerializedLine[], 95 | } 96 | 97 | export type SerializedComponent = 98 | SerializedLine | 99 | SerializedSong | 100 | SerializedChordLyricsPair | 101 | SerializedTag | 102 | SerializedComment | 103 | SerializedTernary | 104 | SerializedLiteral | 105 | SerializedSection | 106 | SerializedSoftLineBreak; 107 | -------------------------------------------------------------------------------- /test/chord_symbol/to_string.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | import { SYMBOL } from '../../src'; 3 | 4 | describe('Chord', () => { 5 | describe('chord symbol', () => { 6 | describe('toString', () => { 7 | it('returns the right string representation', () => { 8 | const chord = new Chord({ 9 | base: 'E', 10 | modifier: 'b', 11 | suffix: 'sus', 12 | bassBase: 'G', 13 | bassModifier: '#', 14 | chordType: SYMBOL, 15 | }); 16 | 17 | expect(chord.toString()).toEqual('Ebsus/G#'); 18 | }); 19 | 20 | describe('without bass modifier', () => { 21 | it('returns the right string representation', () => { 22 | const chord = new Chord({ 23 | base: 'E', 24 | modifier: 'b', 25 | suffix: 'sus', 26 | bassBase: 'G', 27 | chordType: SYMBOL, 28 | }); 29 | 30 | expect(chord.toString()).toEqual('Ebsus/G'); 31 | }); 32 | }); 33 | 34 | describe('without bass note', () => { 35 | it('returns the right string representation', () => { 36 | const chord = new Chord({ 37 | base: 'E', 38 | modifier: 'b', 39 | suffix: 'sus', 40 | chordType: SYMBOL, 41 | }); 42 | 43 | expect(chord.toString()).toEqual('Ebsus'); 44 | }); 45 | }); 46 | 47 | describe('without modifier', () => { 48 | it('returns the right string representation', () => { 49 | const chord = new Chord({ 50 | base: 'E', 51 | suffix: 'sus', 52 | chordType: SYMBOL, 53 | }); 54 | 55 | expect(chord.toString()).toEqual('Esus'); 56 | }); 57 | }); 58 | 59 | describe('without suffix', () => { 60 | it('returns the right string representation', () => { 61 | const chord = new Chord({ 62 | base: 'E', 63 | modifier: 'b', 64 | chordType: SYMBOL, 65 | }); 66 | 67 | expect(chord.toString()).toEqual('Eb'); 68 | }); 69 | }); 70 | 71 | describe('with option unicodeModifer:true', () => { 72 | it('returns the right string representation with flat symbol', () => { 73 | const chord = new Chord({ 74 | base: 'E', 75 | modifier: 'b', 76 | chordType: SYMBOL, 77 | }); 78 | 79 | expect(chord.toString({ useUnicodeModifier: true })).toEqual('E♭'); 80 | }); 81 | 82 | it('returns the right string representation with sharp symbol', () => { 83 | const chord = new Chord({ 84 | base: 'F', 85 | modifier: '#', 86 | chordType: SYMBOL, 87 | }); 88 | 89 | expect(chord.toString({ useUnicodeModifier: true })).toEqual('F♯'); 90 | }); 91 | }); 92 | }); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /test/parser/chord_pro/helpers.test.ts: -------------------------------------------------------------------------------- 1 | import { SerializedChordLyricsPair, SerializedSoftLineBreak } from '../../../src/serialized_types'; 2 | import { breakChordLyricsPairOnSoftLineBreak, stringSplitReplace } from '../../../src/parser/chord_pro/helpers'; 3 | 4 | describe('stringSplitReplace', () => { 5 | it('should replace all instances of a match', () => { 6 | const testString = 'I am a barber'; 7 | 8 | const result = 9 | stringSplitReplace( 10 | testString, 11 | 'a', 12 | (_match) => 'BREAK', 13 | ); 14 | 15 | expect(result).toEqual(['I ', 'BREAK', 'm ', 'BREAK', ' b', 'BREAK', 'rber']); 16 | }); 17 | 18 | it('should replace all instances of a match and the rest of the string', () => { 19 | const testString = 'ai am a barber'; 20 | 21 | const result = 22 | stringSplitReplace( 23 | testString, 24 | 'a', 25 | (_match) => 'BREAK', 26 | (match) => match.toUpperCase(), 27 | ); 28 | 29 | expect(result).toEqual(['BREAK', 'I ', 'BREAK', 'M ', 'BREAK', ' B', 'BREAK', 'RBER']); 30 | }); 31 | }); 32 | 33 | describe('breakChordLyricsPairOnSoftLineBreak', () => { 34 | it('supports breaking a pair\'s lyrics on a soft line break', () => { 35 | const chords = 'D/A'; 36 | const lyrics = 'I am \xA0a barber'; 37 | const items: (SerializedChordLyricsPair | SerializedSoftLineBreak)[] = 38 | breakChordLyricsPairOnSoftLineBreak(chords, lyrics); 39 | 40 | expect(items[0]).toEqual({ type: 'chordLyricsPair', chords: 'D/A', lyrics: 'I am ' }); 41 | expect(items[1]).toEqual({ type: 'softLineBreak' }); 42 | expect(items[2]).toEqual({ type: 'chordLyricsPair', chords: '', lyrics: 'a barber' }); 43 | }); 44 | 45 | it('supports a soft line break directly following a chord', () => { 46 | const chords = 'D/A'; 47 | const lyrics = '\xA0a barber'; 48 | const items: (SerializedChordLyricsPair | SerializedSoftLineBreak)[] = 49 | breakChordLyricsPairOnSoftLineBreak(chords, lyrics); 50 | 51 | expect(items[0]).toEqual({ type: 'chordLyricsPair', chords: 'D/A', lyrics: '' }); 52 | expect(items[1]).toEqual({ type: 'softLineBreak' }); 53 | expect(items[2]).toEqual({ type: 'chordLyricsPair', chords: '', lyrics: 'a barber' }); 54 | }); 55 | 56 | it('supports a chord without lyrics', () => { 57 | const chords = 'D/A'; 58 | const lyrics = ''; 59 | const items: (SerializedChordLyricsPair | SerializedSoftLineBreak)[] = 60 | breakChordLyricsPairOnSoftLineBreak(chords, lyrics); 61 | 62 | expect(items[0]).toEqual({ type: 'chordLyricsPair', chords: 'D/A', lyrics: '' }); 63 | }); 64 | 65 | it('returns an empty array when there are no chords or lyrics', () => { 66 | const chords = ''; 67 | const lyrics = ''; 68 | const items: (SerializedChordLyricsPair | SerializedSoftLineBreak)[] = 69 | breakChordLyricsPairOnSoftLineBreak(chords, lyrics); 70 | 71 | expect(items).toEqual([]); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /test/chord_solfege/to_string.test.ts: -------------------------------------------------------------------------------- 1 | import Chord from '../../src/chord'; 2 | import { SOLFEGE } from '../../src'; 3 | 4 | describe('Chord', () => { 5 | describe('chord solfege', () => { 6 | describe('toString', () => { 7 | it('returns the right string representation', () => { 8 | const chord = new Chord({ 9 | base: 'Mi', 10 | modifier: 'b', 11 | suffix: 'sus', 12 | bassBase: 'Sol', 13 | bassModifier: '#', 14 | chordType: SOLFEGE, 15 | }); 16 | 17 | expect(chord.toString()).toEqual('Mibsus/Sol#'); 18 | }); 19 | 20 | describe('without bass modifier', () => { 21 | it('returns the right string representation', () => { 22 | const chord = new Chord({ 23 | base: 'Mi', 24 | modifier: 'b', 25 | suffix: 'sus', 26 | bassBase: 'Sol', 27 | chordType: SOLFEGE, 28 | }); 29 | 30 | expect(chord.toString()).toEqual('Mibsus/Sol'); 31 | }); 32 | }); 33 | 34 | describe('without bass note', () => { 35 | it('returns the right string representation', () => { 36 | const chord = new Chord({ 37 | base: 'Mi', 38 | modifier: 'b', 39 | suffix: 'sus', 40 | chordType: SOLFEGE, 41 | }); 42 | 43 | expect(chord.toString()).toEqual('Mibsus'); 44 | }); 45 | }); 46 | 47 | describe('without modifier', () => { 48 | it('returns the right string representation', () => { 49 | const chord = new Chord({ 50 | base: 'Mi', 51 | suffix: 'sus', 52 | chordType: SOLFEGE, 53 | }); 54 | 55 | expect(chord.toString()).toEqual('Misus'); 56 | }); 57 | }); 58 | 59 | describe('without suffix', () => { 60 | it('returns the right string representation', () => { 61 | const chord = new Chord({ 62 | base: 'Mi', 63 | modifier: 'b', 64 | chordType: SOLFEGE, 65 | }); 66 | 67 | expect(chord.toString()).toEqual('Mib'); 68 | }); 69 | }); 70 | 71 | describe('with option unicodeModifer:true', () => { 72 | it('returns the right string representation with flat solfege', () => { 73 | const chord = new Chord({ 74 | base: 'Mi', 75 | modifier: 'b', 76 | chordType: SOLFEGE, 77 | }); 78 | 79 | expect(chord.toString({ useUnicodeModifier: true })).toEqual('Mi♭'); 80 | }); 81 | 82 | it('returns the right string representation with sharp symbol', () => { 83 | const chord = new Chord({ 84 | base: 'Fa', 85 | modifier: '#', 86 | chordType: SOLFEGE, 87 | }); 88 | 89 | expect(chord.toString({ useUnicodeModifier: true })).toEqual('Fa♯'); 90 | }); 91 | }); 92 | }); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /src/chord_definition/chord_definition.ts: -------------------------------------------------------------------------------- 1 | import { Fret } from '../constants'; 2 | import { parse } from '../parser/chord_definition/peg_parser'; 3 | 4 | /** 5 | * Represents a chord definition. 6 | * 7 | * Definitions are made using the `{chord}` or `{define}` directive. 8 | * A chord definitions overrides a previous chord definition for the exact same chord. 9 | * 10 | * @see https://chordpro.org/chordpro/directives-define/ 11 | * @see https://chordpro.org/chordpro/directives-chord/ 12 | */ 13 | class ChordDefinition { 14 | /** 15 | * The chord name, e.g. `C`, `Dm`, `G7`. 16 | * @type {string} 17 | */ 18 | name: string; 19 | 20 | /** 21 | * Defines the offset for the chord, which is the position of the topmost finger. 22 | * The offset must be 1 or higher. 23 | * @type {number} 24 | */ 25 | baseFret: number; 26 | 27 | /** 28 | * Defines the string positions. 29 | * Strings are enumerated from left (lowest) to right (highest), as they appear in the chord diagrams. 30 | * Fret positions are relative to the offset minus one, so with base-fret 1 (the default), 31 | * the topmost fret position is 1. With base-fret 3, fret position 1 indicates the 3rd position. 32 | * `0` (zero) denotes an open string. Use `-1`, `N` or `x` to denote a non-sounding string. 33 | * @type {Fret[]} 34 | */ 35 | frets: Fret[]; 36 | 37 | /** 38 | * defines finger settings. This part may be omitted. 39 | * 40 | * For the frets and the fingers positions, there must be exactly as many positions as there are strings, 41 | * which is 6 by default. For the fingers positions, values corresponding to open or damped strings are ignored. 42 | * Finger settings may be numeric (0 .. 9) or uppercase letters (A .. Z). 43 | * Note that the values -, x, X, and N are used to designate a string without finger setting. 44 | * @type {number[]} 45 | */ 46 | fingers: number[]; 47 | 48 | constructor(name: string, baseFret: number, frets: Fret[], fingers?: number[]) { 49 | this.name = name; 50 | this.baseFret = baseFret; 51 | this.frets = frets; 52 | this.fingers = fingers || []; 53 | } 54 | 55 | /** 56 | * Parses a chord definition in the form of: 57 | * - base-fret frets 58 | * - base-fret frets fingers 59 | * @param chordDefinition 60 | * @returns {ChordDefinition} 61 | * @see https://chordpro.org/chordpro/directives-define/#common-usage 62 | */ 63 | static parse(chordDefinition: string): ChordDefinition { 64 | const { 65 | name, 66 | baseFret, 67 | frets, 68 | fingers, 69 | } = parse(chordDefinition.trim()); 70 | 71 | return new ChordDefinition(name, baseFret, frets, fingers); 72 | } 73 | 74 | clone(): ChordDefinition { 75 | return new ChordDefinition(this.name, this.baseFret, [...this.frets], [...this.fingers]); 76 | } 77 | } 78 | 79 | export default ChordDefinition; 80 | -------------------------------------------------------------------------------- /test/chord_sheet/font_size.test.ts: -------------------------------------------------------------------------------- 1 | import FontSize from '../../src/chord_sheet/font_size'; 2 | 3 | describe('FontSize', () => { 4 | describe('constructor', () => { 5 | it('assigns the correct instance variables', () => { 6 | const fontSize = new FontSize(30, 'px'); 7 | 8 | expect(fontSize.fontSize).toEqual(30); 9 | expect(fontSize.unit).toEqual('px'); 10 | }); 11 | }); 12 | 13 | describe('#clone', () => { 14 | it('returns a deep copy', () => { 15 | const fontSize = new FontSize(30, 'px'); 16 | const clone = fontSize.clone(); 17 | 18 | expect(clone.fontSize).toEqual(30); 19 | expect(clone.unit).toEqual('px'); 20 | }); 21 | }); 22 | 23 | describe('multiply', () => { 24 | it('multiplies percentages', () => { 25 | const fontSize = new FontSize(120, '%'); 26 | const multiplied = fontSize.multiply(150); 27 | 28 | expect(multiplied.fontSize).toEqual(180); 29 | expect(multiplied.unit).toEqual('%'); 30 | }); 31 | 32 | it('multiplies pixels', () => { 33 | const fontSize = new FontSize(20, 'px'); 34 | const multiplied = fontSize.multiply(150); 35 | 36 | expect(multiplied.fontSize).toEqual(30); 37 | expect(multiplied.unit).toEqual('px'); 38 | }); 39 | }); 40 | 41 | describe('#toString', () => { 42 | it('returns size and unit combined', () => { 43 | const fontSize = new FontSize(30, 'px'); 44 | 45 | expect(fontSize.toString()).toEqual('30px'); 46 | }); 47 | }); 48 | 49 | describe('::parse', () => { 50 | describe('when the number cannot be parsed', () => { 51 | it('returns the parent when present', () => { 52 | const parent = new FontSize(20, 'px'); 53 | const parsed = FontSize.parse('foobar', parent); 54 | 55 | expect(parsed.fontSize).toEqual(20); 56 | expect(parsed.unit).toEqual('px'); 57 | }); 58 | 59 | it('returns 100% when there is no parent', () => { 60 | const parsed = FontSize.parse('foobar', null); 61 | 62 | expect(parsed.fontSize).toEqual(100); 63 | expect(parsed.unit).toEqual('%'); 64 | }); 65 | }); 66 | 67 | describe('when the number is a percentage', () => { 68 | it('multiplies by the parent size if present', () => { 69 | const parent = new FontSize(20, 'px'); 70 | const parsed = FontSize.parse('120%', parent); 71 | 72 | expect(parsed.fontSize).toEqual(24); 73 | expect(parsed.unit).toEqual('px'); 74 | }); 75 | 76 | it('creates a percentage size when there is no parent', () => { 77 | const parsed = FontSize.parse('120%', null); 78 | 79 | expect(parsed.fontSize).toEqual(120); 80 | expect(parsed.unit).toEqual('%'); 81 | }); 82 | }); 83 | 84 | it('returns a pixel size by default', () => { 85 | const parent = new FontSize(20, 'px'); 86 | const parsed = FontSize.parse('24px', parent); 87 | 88 | expect(parsed.fontSize).toEqual(24); 89 | expect(parsed.unit).toEqual('px'); 90 | }); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /script/helpers/parser_builder.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | import esbuild from 'esbuild'; 4 | 5 | class ParserBuilder { 6 | parserName: string; 7 | 8 | chordDefinitionGrammarFile = './src/parser/chord_definition/grammar.pegjs'; 9 | 10 | chordGrammarFile = './src/parser/chord/base_grammar.pegjs'; 11 | 12 | chordSuffixGrammarFile = './src/parser/chord/suffix_grammar.pegjs'; 13 | 14 | chordSimpleSuffixGrammarFile = './src/parser/chord/simple_suffix_grammar.pegjs'; 15 | 16 | sectionsGrammarFile = './src/parser/chord_pro/sections_grammar.pegjs'; 17 | 18 | whitespaceGrammarFile = './src/parser/whitespace_grammar.pegjs'; 19 | 20 | constructor(parserName: string) { 21 | this.parserName = parserName; 22 | } 23 | 24 | build(): string { return this.parserSource; } 25 | 26 | get parserSource(): string { 27 | return [ 28 | this.transpiledHelpers, 29 | ...this.grammars, 30 | ].join('\n\n'); 31 | } 32 | 33 | get grammars(): (string | Buffer)[] { 34 | switch (this.parserName) { 35 | case 'chord': 36 | return [this.chordGrammar, this.chordSimpleSuffixGrammar]; 37 | case 'chord_pro': 38 | return [this.parserGrammar, this.chordDefinitionGrammar, this.sectionsGrammar, this.whitespaceGrammar]; 39 | case 'chords_over_words': 40 | return [this.parserGrammar, this.chordGrammar, this.chordSuffixGrammar, this.whitespaceGrammar]; 41 | default: 42 | throw new Error(`No configuration for parser ${this.parserName}`); 43 | } 44 | } 45 | 46 | get parserFolder(): string { return `./src/parser/${this.parserName}`; } 47 | 48 | get grammarFile(): string { return `${this.parserFolder}/grammar.pegjs`; } 49 | 50 | get helpersFile(): string { return `${this.parserFolder}/helpers.ts`; } 51 | 52 | get parserGrammar(): string | Buffer { 53 | return fs.readFileSync(this.grammarFile, 'utf8'); 54 | } 55 | 56 | get chordSuffixGrammar(): string | Buffer { 57 | return fs.readFileSync(this.chordSuffixGrammarFile); 58 | } 59 | 60 | get chordSimpleSuffixGrammar(): string | Buffer { 61 | return fs.readFileSync(this.chordSimpleSuffixGrammarFile); 62 | } 63 | 64 | get whitespaceGrammar(): string | Buffer { 65 | return fs.readFileSync(this.whitespaceGrammarFile); 66 | } 67 | 68 | get chordDefinitionGrammar(): string | Buffer { 69 | return fs.readFileSync(this.chordDefinitionGrammarFile); 70 | } 71 | 72 | get sectionsGrammar(): string | Buffer { 73 | return fs.readFileSync(this.sectionsGrammarFile); 74 | } 75 | 76 | get chordGrammar(): string | Buffer { 77 | return fs.readFileSync(this.chordGrammarFile); 78 | } 79 | 80 | get transpiledHelpers(): string { 81 | if (!fs.existsSync(this.helpersFile)) { 82 | return ''; 83 | } 84 | 85 | const result = esbuild.buildSync({ 86 | bundle: true, 87 | entryPoints: [this.helpersFile], 88 | globalName: 'helpers', 89 | write: false, 90 | }); 91 | 92 | return `{\n${result.outputFiles[0].text}\n}`; 93 | } 94 | } 95 | 96 | export default ParserBuilder; 97 | -------------------------------------------------------------------------------- /test/key/transpose.test.ts: -------------------------------------------------------------------------------- 1 | import { buildKey } from '../utilities'; 2 | import { 3 | NUMERAL, 4 | NUMERIC, 5 | SOLFEGE, 6 | SYMBOL, 7 | } from '../../src/constants'; 8 | 9 | describe('Key', () => { 10 | describe('transpose', () => { 11 | describe('chord symbol key', () => { 12 | describe('when delta > 0', () => { 13 | it('transposes up', () => { 14 | expect(buildKey('D', SYMBOL, 'b').transpose(5).toString()).toEqual('Gb'); 15 | }); 16 | }); 17 | 18 | describe('when delta < 0', () => { 19 | it('Does not change the key', () => { 20 | expect(buildKey('A', SYMBOL, '#').transpose(-4).toString()).toEqual('F#'); 21 | }); 22 | }); 23 | 24 | describe('when delta = 0', () => { 25 | it('Does not change the key', () => { 26 | expect(buildKey('B', SYMBOL, '#').transpose(0).toString()).toEqual('B#'); 27 | }); 28 | }); 29 | }); 30 | 31 | describe('chord solfege key', () => { 32 | describe('when delta > 0', () => { 33 | it('transposes up', () => { 34 | expect(buildKey('Re', SOLFEGE, 'b').transpose(5).toString()).toEqual('Solb'); 35 | }); 36 | }); 37 | 38 | describe('when delta < 0', () => { 39 | it('Does not change the key', () => { 40 | expect(buildKey('La', SOLFEGE, '#').transpose(-4).toString()).toEqual('Fa#'); 41 | }); 42 | }); 43 | 44 | describe('when delta = 0', () => { 45 | it('Does not change the key', () => { 46 | expect(buildKey('Si', SOLFEGE, '#').transpose(0).toString()).toEqual('Si#'); 47 | }); 48 | }); 49 | }); 50 | 51 | describe('numeric key', () => { 52 | describe('when delta > 0', () => { 53 | it('transposes up', () => { 54 | expect(buildKey(2, NUMERIC, 'b').transpose(5).toString()).toEqual('b5'); 55 | }); 56 | }); 57 | 58 | describe('when delta < 0', () => { 59 | it('Does not change the key', () => { 60 | expect(buildKey(6, NUMERIC, '#').transpose(-4).toString()).toEqual('#4'); 61 | }); 62 | }); 63 | 64 | describe('when delta = 0', () => { 65 | it('Does not change the key', () => { 66 | expect(buildKey(7, NUMERIC, '#').transpose(0).toString()).toEqual('#7'); 67 | }); 68 | }); 69 | }); 70 | 71 | describe('numeral key', () => { 72 | describe('when delta > 0', () => { 73 | it('transposes up', () => { 74 | expect(buildKey('II', NUMERAL, 'b').transpose(5).toString()).toEqual('bV'); 75 | }); 76 | }); 77 | 78 | describe('when delta < 0', () => { 79 | it('Does not change the key', () => { 80 | expect(buildKey('VI', NUMERAL, '#').transpose(-4).toString()).toEqual('#IV'); 81 | }); 82 | }); 83 | 84 | describe('when delta = 0', () => { 85 | it('Does not change the key', () => { 86 | expect(buildKey('VII', NUMERAL, '#').transpose(0).toString()).toEqual('#VII'); 87 | }); 88 | }); 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /src/parser/chords_over_words_parser.ts: -------------------------------------------------------------------------------- 1 | import ChordSheetSerializer from '../chord_sheet_serializer'; 2 | import NullTracer from './null_tracer'; 3 | import ParserWarning from './parser_warning'; 4 | import Song from '../chord_sheet/song'; 5 | 6 | import { normalizeLineEndings } from '../utilities'; 7 | import { ParseOptions, parse } from './chords_over_words/peg_parser'; 8 | 9 | export type ChordsOverWordsParserOptions = ParseOptions & { 10 | softLineBreaks?: boolean; 11 | chopFirstWord?: boolean; 12 | }; 13 | 14 | /** 15 | * Parses a chords over words sheet into a song 16 | * 17 | * It support "regular" chord sheets: 18 | * 19 | * Am C/G F C 20 | * Let it be, let it be, let it be, let it be 21 | * C G F C/E Dm C 22 | * Whisper words of wisdom, let it be 23 | * 24 | * Additionally, some chordpro features have been "ported back". For example, you can use chordpro directives: 25 | * 26 | * {title: Let it be} 27 | * {key: C} 28 | * Chorus 1: 29 | * Am 30 | * Let it be 31 | * 32 | * For convenience, you can leave out the brackets: 33 | * 34 | * title: Let it be 35 | * Chorus 1: 36 | * Am 37 | * Let it be 38 | * 39 | * You can even use a markdown style frontmatter separator to separate the header from the song: 40 | * 41 | * title: Let it be 42 | * key: C 43 | * --- 44 | * Chorus 1: 45 | * Am C/G F C 46 | * Let it be, let it be, let it be, let it be 47 | * C G F C/E Dm C 48 | * Whisper words of wisdom, let it be 49 | * 50 | * `ChordsOverWordsParser` is the better version of `ChordSheetParser`, which is deprecated. 51 | */ 52 | class ChordsOverWordsParser { 53 | song?: Song; 54 | 55 | /** 56 | * All warnings raised during parsing the chord sheet 57 | * @member 58 | * @type {ParserWarning[]} 59 | */ 60 | get warnings(): ParserWarning[] { 61 | return this.song?.warnings || []; 62 | } 63 | 64 | /** 65 | * Parses a chords over words sheet into a song 66 | * @param {string} chordSheet the chords over words sheet 67 | * @param {ChordsOverWordsParserOptions} options Parser options. 68 | * @param {ChordsOverWordsParserOptions.softLineBreaks} options.softLineBreaks=false If true, a backslash 69 | * followed by a space is treated as a soft line break 70 | * @param {ChordsOverWordsParserOptions.chopFirstWord} options.chopFirstWord=true If true, only the first lyric 71 | * word is paired with the chord, the rest of the lyric is put in a separate chord lyric pair 72 | * @see https://peggyjs.org/documentation.html#using-the-parser 73 | * @returns {Song} The parsed song 74 | */ 75 | parse(chordSheet: string, options?: ChordsOverWordsParserOptions): Song { 76 | const ast = parse( 77 | normalizeLineEndings(chordSheet), 78 | { tracer: new NullTracer(), ...options }, 79 | ); 80 | 81 | this.song = new ChordSheetSerializer().deserialize(ast); 82 | return this.song; 83 | } 84 | } 85 | 86 | export default ChordsOverWordsParser; 87 | -------------------------------------------------------------------------------- /test/note/change.test.ts: -------------------------------------------------------------------------------- 1 | import Note from '../../src/note'; 2 | import { ROMAN_NUMERALS } from '../../src/constants'; 3 | 4 | describe('Note', () => { 5 | describe('#change', () => { 6 | describe('note chord letters', () => { 7 | const octave = ['A', 'B', 'C', 'D', 'E', 'F', 'G']; 8 | const notes = [...octave, ...octave, ...octave]; 9 | const start = octave.length; 10 | 11 | for (let i = 0; i < 7; i += 1) { 12 | for (let delta = -7; delta <= 7; delta += 1) { 13 | const from = notes[start + i]; 14 | const to = notes[start + delta + i]; 15 | 16 | it(`converts ${from} with delta ${delta} to ${to}`, () => { 17 | expect(Note.parse(from).transpose(delta).note).toEqual(to); 18 | }); 19 | } 20 | } 21 | }); 22 | 23 | describe('chord numbers', () => { 24 | const octave = [1, 2, 3, 4, 5, 6, 7]; 25 | const notes = [...octave, ...octave, ...octave]; 26 | const start = octave.length; 27 | 28 | for (let i = 0; i < 7; i += 1) { 29 | for (let delta = -7; delta <= 7; delta += 1) { 30 | const from = notes[start + i]; 31 | const to = notes[start + delta + i]; 32 | 33 | it(`converts ${from} with delta ${delta} to ${to}`, () => { 34 | expect(Note.parse(from).transpose(delta).note).toEqual(to); 35 | }); 36 | } 37 | } 38 | }); 39 | 40 | describe('major numerals', () => { 41 | const octave = ROMAN_NUMERALS; 42 | const notes = [...octave, ...octave, ...octave]; 43 | const start = octave.length; 44 | 45 | for (let i = 0; i < 7; i += 1) { 46 | for (let delta = -7; delta <= 7; delta += 1) { 47 | const from = notes[start + i]; 48 | const to = notes[start + delta + i]; 49 | 50 | it(`converts ${from} with delta ${delta} to ${to}`, () => { 51 | expect(Note.parse(from).transpose(delta).note).toEqual(to); 52 | }); 53 | 54 | it(`converts ${from.toLowerCase()} with delta ${delta} to ${to.toLowerCase()}`, () => { 55 | expect(Note.parse(from.toLowerCase()).transpose(delta).note).toEqual(to.toLowerCase()); 56 | }); 57 | } 58 | } 59 | }); 60 | 61 | describe('minor numerals', () => { 62 | const octave = ROMAN_NUMERALS.map((numeral) => numeral.toLowerCase()); 63 | const notes = [...octave, ...octave, ...octave]; 64 | const start = octave.length; 65 | 66 | for (let i = 0; i < 7; i += 1) { 67 | for (let delta = -7; delta <= 7; delta += 1) { 68 | const from = notes[start + i]; 69 | const to = notes[start + delta + i]; 70 | 71 | it(`converts ${from} with delta ${delta} to ${to}`, () => { 72 | expect(Note.parse(from).transpose(delta).note).toEqual(to); 73 | }); 74 | 75 | it(`converts ${from.toLowerCase()} with delta ${delta} to ${to.toLowerCase()}`, () => { 76 | expect(Note.parse(from.toLowerCase()).transpose(delta).note).toEqual(to.toLowerCase()); 77 | }); 78 | } 79 | } 80 | }); 81 | }); 82 | }); 83 | --------------------------------------------------------------------------------