From ff3fa7fbdd79523fb824670b9b420f0232ccf4f7 Mon Sep 17 00:00:00 2001 From: Piotr Tabor Date: Sun, 28 Dec 2025 21:14:05 +0100 Subject: [PATCH 1/4] Render htmls instead of xhtmls. --- src/html/index_generator.py | 30 +++++---- src/html/song_utils.py | 6 +- src/html/standard_html_converter.py | 97 +++++++++++++++++++++++++---- 3 files changed, 107 insertions(+), 26 deletions(-) diff --git a/src/html/index_generator.py b/src/html/index_generator.py index d3fadaa1..36a2c37e 100644 --- a/src/html/index_generator.py +++ b/src/html/index_generator.py @@ -7,18 +7,24 @@ # def name_of_file(song): # return os.path.splitext(os.path.split(song)[1])[0] -def create_index_xhtml(list_of_songs_meta, target_dir): - tmp_path = 'index.xhtml' +def create_index_html(list_of_songs_meta, target_dir): + tmp_path = 'index.html' out_path = os.path.join(target_dir, tmp_path) - tree = etree.parse(os.path.join(sb.repo_dir(), "./src/html/templates/index.xhtml")) + + # Try to parse as HTML first, fall back to XHTML if needed + template_path = os.path.join(sb.repo_dir(), "./src/html/templates/index.xhtml") + parser = etree.HTMLParser() + tree = etree.parse(template_path, parser) - # List of songs - ul = tree.getroot().find(".//{http://www.w3.org/1999/xhtml}ul[@id='songs']") + # List of songs - try both HTML and XHTML namespaces + ul = tree.getroot().find(".//ul[@id='songs']") + if ul is None: + ul = tree.getroot().find(".//{http://www.w3.org/1999/xhtml}ul[@id='songs']") for i in range(len(list_of_songs_meta)): song = list_of_songs_meta[i] if song.is_alias(): continue - song_html = os.path.join("./songs_html", song.base_file_name() + '.xhtml') + song_html = os.path.join("./songs_html", song.base_file_name() + '.html') li = etree.SubElement(ul, "li") # entire li is clickable and should navigate to the song li.attrib['onclick'] = "location.href='"+song_html+"';" @@ -69,8 +75,10 @@ def create_index_xhtml(list_of_songs_meta, target_dir): artist = etree.SubElement(li, "span", attrib={"class": "artist"}) artist.text = song.artist() - # List of songbooks - ul = tree.getroot().find(".//{http://www.w3.org/1999/xhtml}ul[@id='songbooks']") + # List of songbooks - try both HTML and XHTML namespaces + ul = tree.getroot().find(".//ul[@id='songbooks']") + if ul is None: + ul = tree.getroot().find(".//{http://www.w3.org/1999/xhtml}ul[@id='songbooks']") for songbook in sb.songbooks(): if not songbook.hidden(): li = etree.SubElement(ul, "li", attrib={"id": songbook.id()}) @@ -82,7 +90,7 @@ def create_index_xhtml(list_of_songs_meta, target_dir): a_a4pdf = etree.SubElement(ul, "a", attrib={"class": "pdf", "href": os.path.join("songs_tex", songbook.id()+"_a4.pdf")}) a_a4pdf.text = "PDF (a4)" et = etree.ElementTree(tree.getroot()) - et.write(out_path, pretty_print=True, method='xml', encoding='utf-8', xml_declaration=True) + et.write(out_path, pretty_print=True, method='html', encoding='utf-8') def create_sitemap_xml(list_of_songs_meta, target_dir): @@ -95,7 +103,7 @@ def create_sitemap_xml(list_of_songs_meta, target_dir): # Add HTML version of the song url = etree.SubElement(root, "url") loc = etree.SubElement(url, "loc") - loc.text = os.path.join(base, "songs_html", song.base_file_name() + ".xhtml") + loc.text = os.path.join(base, "songs_html", song.base_file_name() + ".html") lastmod = etree.SubElement(url, "lastmod") lastmod.text = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S+00:00") changefreq = etree.SubElement(url, "changefreq") @@ -137,7 +145,7 @@ def main(): logging.info(f"Generating HTML index in {target_dir} from songbook spec {songbook_file}, song count: {len(songbook.list_of_songs())}") - create_index_xhtml(songbook.list_of_songs(), target_dir) + create_index_html(songbook.list_of_songs(), target_dir) create_sitemap_xml(songbook.list_of_songs(), target_dir) index_js_path =os.path.join(target_dir, "index.js") diff --git a/src/html/song_utils.py b/src/html/song_utils.py index f07f2a59..6a5594d5 100644 --- a/src/html/song_utils.py +++ b/src/html/song_utils.py @@ -17,14 +17,14 @@ def create_all_songs_html_from_dir(converter, path_in, path_out, song_suffix=[]) return create_all_songs_html(converter, create_list_of_songs(path_in), path_out, song_suffix) def create_all_songs_html(converter, list_of_songs, path_out, song_suffix=[], song_prefix=[], song_head=[], substitions={}): - if not os.path.exists(path_out): - os.mkdir(path_out) + # Ensure output directory exists (mkdir -p equivalent) + os.makedirs(path_out, exist_ok=True) for song in list_of_songs: if not song.is_alias(): relative_path = os.path.relpath(song.plik(), start=os.path.join(os.path.dirname(__file__), "../../songs")) converter.xml2html(song.plik(), - os.path.join(path_out, song.base_file_name() + '.xhtml'), + os.path.join(path_out, song.base_file_name() + '.html'), song_suffix=song_suffix, song_prefix=song_prefix, song_head=song_head, diff --git a/src/html/standard_html_converter.py b/src/html/standard_html_converter.py index 2e41f699..64628e41 100644 --- a/src/html/standard_html_converter.py +++ b/src/html/standard_html_converter.py @@ -181,37 +181,110 @@ def _add_blocks(self, song, parent): span_content.text = song.comment - def xml2html(self, src_xml_path, path_out, song_suffix=None, song_prefix=None, song_head=None, substitions={}): - xhtml_namespace = "http://www.w3.org/1999/xhtml" - epub_namespace = "http://www.idpf.org/2007/ops" - xhtml = "{%s}" % xhtml_namespace - nsmap = {None: xhtml_namespace, - "epub": epub_namespace} + def _detect_language(self, song, src_xml_path): + """Detect language from song metadata or XML file, returning (lang, lang_code) tuple""" + lang = "pl-PL" + lang_code = "pl" + + # Check multiple possible attribute names for language + song_lang = None + for attr in ['lang', 'language', 'xml_lang']: + if hasattr(song, attr) and getattr(song, attr): + song_lang = getattr(song, attr) + break + + # If still no lang found, try to read directly from XML file + if not song_lang: + try: + from lxml import etree as xml_etree + xml_tree = xml_etree.parse(src_xml_path) + xml_root = xml_tree.getroot() + song_lang = xml_root.get('lang') or xml_root.get('{http://www.w3.org/XML/1998/namespace}lang') + except: + pass + + if song_lang: + # Map language codes + lang_map = { + 'pl': ('pl-PL', 'pl'), + 'en': ('en-US', 'en'), + 'de': ('de-DE', 'de'), + 'fr': ('fr-FR', 'fr'), + 'es': ('es-ES', 'es'), + 'it': ('it-IT', 'it') + } + mapped = lang_map.get(song_lang.lower(), None) + if mapped: + lang, lang_code = mapped + else: + lang_code = song_lang.lower() + lang = f"{lang_code}-{lang_code.upper()}" + + return lang, lang_code + def xml2html(self, src_xml_path, path_out, song_suffix=None, song_prefix=None, song_head=None, substitions={}): song = rsx.parse_song_xml(src_xml_path) - root_html = etree.Element(xhtml + "html", nsmap=nsmap) - root_html.attrib[etree.QName("lang")] = "pl-PL" + + # Determine language from song metadata or default to Polish + lang, lang_code = self._detect_language(song, src_xml_path) + + root_html = etree.Element("html") + root_html.attrib["lang"] = lang head = etree.SubElement(root_html, "head") + etree.SubElement(head, "meta", attrib={"charset": "utf-8"}) + etree.SubElement(head, "meta", attrib={"name": "viewport", "content": "width=device-width, initial-scale=0.8"}) + + # SEO meta tags + title_text = song.title + if song.artist: + title_text = f"{song.title} - {song.artist}" + + # Localized description text + desc_text = "Tekst i chwyty piosenki" if lang_code == 'pl' else "Lyrics and chords for" + etree.SubElement(head, "meta", attrib={"name": "description", "content": f"{desc_text} {title_text}"}) + etree.SubElement(head, "meta", attrib={"property": "og:title", "content": title_text}) + etree.SubElement(head, "meta", attrib={"property": "og:type", "content": "music.song"}) + + if song.artist: + etree.SubElement(head, "meta", attrib={"name": "author", "content": song.artist}) + + # JSON-LD structured data for rich snippets + script_ld = etree.SubElement(head, "script", attrib={"type": "application/ld+json"}) + ld_json = { + "@context": "https://schema.org", + "@type": "MusicComposition", + "name": song.title, + "inLanguage": lang_code + } + if song.composer: + ld_json["composer"] = {"@type": "Person", "name": song.composer} + if song.text_author: + ld_json["lyricist"] = {"@type": "Person", "name": song.text_author} + if song.artist: + ld_json["author"] = {"@type": "Person", "name": song.artist} + + import json + script_ld.text = json.dumps(ld_json, ensure_ascii=False, indent=2) + etree.SubElement(head, "link", attrib={"rel": "stylesheet", "type": "text/css", "href": "CSS/song_common.css", "media": "all"}) etree.SubElement(head, "link", attrib={"rel": "stylesheet", "type": "text/css", "href": "CSS/song.css", "media": "all"}) - etree.SubElement(head, "meta", attrib={"name": "viewport", "content": "width=device-width, initial-scale=0.8"}) for s in song_head: head.append(interpret(copy.deepcopy(s), substitions)) - body = etree.SubElement(root_html, "body", attrib={"class": "song", etree.QName("http://www.idpf.org/2007/ops", "type"):"bodymatter"}) + body = etree.SubElement(root_html, "body", attrib={"class": "song"}) for s in song_prefix: body.append(interpret(copy.deepcopy(s), substitions)) self._add_blocks(song, root_html) title = etree.SubElement(head, "title") - title.text = song.title + title.text = title_text et = etree.ElementTree(root_html) for s in song_suffix: body.append(interpret(copy.deepcopy(s), substitions)) - et.write(path_out, doctype='', pretty_print=True, method='xml', encoding='utf-8', xml_declaration=True) + et.write(path_out, doctype='', pretty_print=True, method='html', encoding='utf-8') replace_in_file(path_out, path_out, lambda s: s.replace(u'\u200d', '')) From 29a868ca39c77215bb5c5f49944c442c8064e64d Mon Sep 17 00:00:00 2001 From: Piotr Tabor Date: Sun, 28 Dec 2025 21:37:24 +0100 Subject: [PATCH 2/4] Fix css --- src/html/index_generator.py | 42 +++++++++++++++++++++++++++++ src/html/standard_html_converter.py | 22 ++++++++------- src/lib/read_song_xml.py | 14 ++++++++++ 3 files changed, 68 insertions(+), 10 deletions(-) diff --git a/src/html/index_generator.py b/src/html/index_generator.py index 36a2c37e..a141aded 100644 --- a/src/html/index_generator.py +++ b/src/html/index_generator.py @@ -16,6 +16,43 @@ def create_index_html(list_of_songs_meta, target_dir): parser = etree.HTMLParser() tree = etree.parse(template_path, parser) + # Add SEO meta tags to head + head = tree.getroot().find(".//head") + if head is None: + head = tree.getroot().find(".//{http://www.w3.org/1999/xhtml}head") + + if head is not None: + # Add charset and viewport if not present + if head.find(".//meta[@charset]") is None: + etree.SubElement(head, "meta", attrib={"charset": "utf-8"}, nsmap=None).tail = "\n" + + # Add meta description + desc_meta = etree.SubElement(head, "meta", attrib={ + "name": "description", + "content": "Śpiewnik harcerski - teksty i chwyty piosenek harcerskich, biesiadnych i turystycznych" + }, nsmap=None) + desc_meta.tail = "\n" + + # Add Open Graph tags + etree.SubElement(head, "meta", attrib={"property": "og:title", "content": "Spiewaj.com - Śpiewnik harcerski"}, nsmap=None).tail = "\n" + etree.SubElement(head, "meta", attrib={"property": "og:type", "content": "website"}, nsmap=None).tail = "\n" + etree.SubElement(head, "meta", attrib={"property": "og:url", "content": "https://spiewaj.com/"}, nsmap=None).tail = "\n" + etree.SubElement(head, "meta", attrib={"property": "og:description", "content": "Śpiewnik harcerski - teksty i chwyty piosenek"}, nsmap=None).tail = "\n" + + # Add JSON-LD structured data + script_ld = etree.SubElement(head, "script", attrib={"type": "application/ld+json"}, nsmap=None) + import json + ld_json = { + "@context": "https://schema.org", + "@type": "WebSite", + "name": "Spiewaj.com", + "url": "https://spiewaj.com/", + "description": "Śpiewnik harcerski - teksty i chwyty piosenek harcerskich, biesiadnych i turystycznych", + "inLanguage": "pl-PL" + } + script_ld.text = json.dumps(ld_json, ensure_ascii=False, indent=2) + script_ld.tail = "\n" + # List of songs - try both HTML and XHTML namespaces ul = tree.getroot().find(".//ul[@id='songs']") if ul is None: @@ -89,6 +126,11 @@ def create_index_html(list_of_songs_meta, target_dir): a_a5pdf.text = "PDF (a5)" a_a4pdf = etree.SubElement(ul, "a", attrib={"class": "pdf", "href": os.path.join("songs_tex", songbook.id()+"_a4.pdf")}) a_a4pdf.text = "PDF (a4)" + # Update html lang attribute + html_root = tree.getroot() + if html_root.tag == 'html' or html_root.tag.endswith('}html'): + html_root.attrib['lang'] = 'pl-PL' + et = etree.ElementTree(tree.getroot()) et.write(out_path, pretty_print=True, method='html', encoding='utf-8') diff --git a/src/html/standard_html_converter.py b/src/html/standard_html_converter.py index 64628e41..6deeda94 100644 --- a/src/html/standard_html_converter.py +++ b/src/html/standard_html_converter.py @@ -22,7 +22,7 @@ def _add_chunk_group(self, chunks, parent, position): if c.content: content += c.content if chords: # If there are any chords - span_stack = etree.SubElement(parent, "span", attrib={"class": "ch-stack"}) + span_stack = etree.SubElement(parent, "span", attrib={"class": "ch-stack", "aria-hidden": "true"}) for chord in chords: span_ch = etree.SubElement(span_stack, "span", attrib={ "class": "ch", @@ -64,7 +64,7 @@ def _add_lyric(self, row, parent, chords_over_bool): def _add_chords(self, row, parent, class_name): """class chords -> html span ch""" - span_chords = etree.SubElement(parent, "span", attrib={"class": class_name}) + span_chords = etree.SubElement(parent, "span", attrib={"class": class_name, "aria-hidden": "true"}) if row.sidechords: for chunk in row.sidechords.split(" "): span_ch = etree.SubElement(span_chords, "span", attrib={"class": "ch"}) @@ -128,8 +128,8 @@ def _add_creator(self, creator, describe, parent): span_content.text = creator def _add_blocks(self, song, parent): - body_song = parent.find("body") """class song -> html div body # blok z metadanymi o piosence i piosenką""" + body_song = parent.find("body") h1_title = etree.SubElement(body_song, "h1", attrib={"class": "title", "id": "title"}) h1_title.text = song.title if song.original_title: @@ -175,12 +175,6 @@ def _add_blocks(self, song, parent): block_id.text = blockid self._add_block(block, corpse, b_type, verse_cnt) - if song.comment: - div = etree.SubElement(body_song, "div", attrib={"class": "comment"}) - span_content = etree.SubElement(div, "span", attrib={"class": "comment"}) - span_content.text = song.comment - - def _detect_language(self, song, src_xml_path): """Detect language from song metadata or XML file, returning (lang, lang_code) tuple""" lang = "pl-PL" @@ -248,7 +242,10 @@ def xml2html(self, src_xml_path, path_out, song_suffix=None, song_prefix=None, s if song.artist: etree.SubElement(head, "meta", attrib={"name": "author", "content": song.artist}) - # JSON-LD structured data for rich snippets + # Extract plain lyrics for SEO + plain_lyrics = song.extract_plain_lyrics() + + # JSON-LD structured data for rich snippets with lyrics script_ld = etree.SubElement(head, "script", attrib={"type": "application/ld+json"}) ld_json = { "@context": "https://schema.org", @@ -262,6 +259,11 @@ def xml2html(self, src_xml_path, path_out, song_suffix=None, song_prefix=None, s ld_json["lyricist"] = {"@type": "Person", "name": song.text_author} if song.artist: ld_json["author"] = {"@type": "Person", "name": song.artist} + if plain_lyrics: + ld_json["lyrics"] = { + "@type": "CreativeWork", + "text": plain_lyrics + } import json script_ld.text = json.dumps(ld_json, ensure_ascii=False, indent=2) diff --git a/src/lib/read_song_xml.py b/src/lib/read_song_xml.py index ffa6f43b..011e769d 100644 --- a/src/lib/read_song_xml.py +++ b/src/lib/read_song_xml.py @@ -109,6 +109,20 @@ def __init__(self, title='', text_author='', composer='', artist='', original_ti self.metre = metre if metre else '' self.barre = barre if barre else '' + def extract_plain_lyrics(self): + """Extract plain text lyrics without chords for SEO""" + lyrics_lines = [] + for block in self.blocks: + for row in block.rows: + if not row.instr: # Skip instrumental rows + line_text = '' + for chunk in row.chunks: + if chunk.content: + line_text += chunk.content + if line_text.strip(): + lyrics_lines.append(line_text.strip()) + return '\n'.join(lyrics_lines) + @staticmethod def parseDOM(root): # A child of 'lyric' element may either be a text block, a reference to a text block (e.g. to a chorus), or a tablature. From 08896cb5ad3d9364f84b5a4b592666b14c2a47f9 Mon Sep 17 00:00:00 2001 From: Piotr Tabor Date: Mon, 29 Dec 2025 21:21:18 +0100 Subject: [PATCH 3/4] Fix epubs. --- src/html/kindle_html_converter.py | 11 +++++++++++ src/html/song_converter.py | 5 +++++ src/html/song_utils.py | 2 +- src/html/standard_html_converter.py | 4 ++++ 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/html/kindle_html_converter.py b/src/html/kindle_html_converter.py index 6a039cb5..984626c0 100644 --- a/src/html/kindle_html_converter.py +++ b/src/html/kindle_html_converter.py @@ -6,6 +6,10 @@ from .standard_html_converter import StandardHtmlConverter class KindleHtmlConverter(SongConverter): + def extension(self): + """Return 'xhtml' for Kindle output""" + return 'xhtml' + def _add_chunk(self, chunk, parent, position): """class chunk -> html span chunk""" span_chunk = etree.SubElement(parent, "span", attrib={"class": "chunk"}) @@ -158,6 +162,13 @@ def _add_blocks(self, song, parent): span_content = etree.SubElement(div, "span", attrib={"class": "comment"}) span_content.text = song.comment + # Add hidden plain lyrics for Kindle indexing + # display: none text is still indexed for search in many Kindle versions + plain_lyrics = song.extract_plain_lyrics() + if plain_lyrics: + div_hidden = etree.SubElement(body_song, "div", attrib={"style": "display: none;"}) + div_hidden.text = plain_lyrics + def xml2html(self, src_xml_path, path_out, song_suffix=None, song_prefix=None, song_head=None, substitions={}): xhtml_namespace = "http://www.w3.org/1999/xhtml" diff --git a/src/html/song_converter.py b/src/html/song_converter.py index 9333ccdf..0ef933a5 100644 --- a/src/html/song_converter.py +++ b/src/html/song_converter.py @@ -5,3 +5,8 @@ class SongConverter(ABC): def xml2html(self, src_xml_path, path_out, song_suffix=None, song_prefix=None, song_head=None, substitions={}): """Convert song from XML to HTML format""" pass + + @abstractmethod + def extension(self): + """Return the file extension for this converter: 'html' or 'xhtml'""" + pass diff --git a/src/html/song_utils.py b/src/html/song_utils.py index 6a5594d5..10f1bf38 100644 --- a/src/html/song_utils.py +++ b/src/html/song_utils.py @@ -24,7 +24,7 @@ def create_all_songs_html(converter, list_of_songs, path_out, song_suffix=[], so if not song.is_alias(): relative_path = os.path.relpath(song.plik(), start=os.path.join(os.path.dirname(__file__), "../../songs")) converter.xml2html(song.plik(), - os.path.join(path_out, song.base_file_name() + '.html'), + os.path.join(path_out, song.base_file_name() + '.' + converter.extension()), song_suffix=song_suffix, song_prefix=song_prefix, song_head=song_head, diff --git a/src/html/standard_html_converter.py b/src/html/standard_html_converter.py index 6deeda94..98db7d49 100644 --- a/src/html/standard_html_converter.py +++ b/src/html/standard_html_converter.py @@ -9,6 +9,10 @@ class StandardHtmlConverter(SongConverter): + def extension(self): + """Return 'html' for standard HTML output""" + return 'html' + def _add_chunk_group(self, chunks, parent, position): """Add a group of chunks that share the same position in text""" # Debug comment showing chunk group content From ce5701bd2a6efe3e124c837626edc855e6889b78 Mon Sep 17 00:00:00 2001 From: Piotr Tabor Date: Mon, 29 Dec 2025 21:42:12 +0100 Subject: [PATCH 4/4] Fix index page for search indexing. --- src/html/css/index.css | 29 +++++++++---- src/html/index_generator.py | 84 +++++++++++++++++++++++++++++-------- 2 files changed, 88 insertions(+), 25 deletions(-) diff --git a/src/html/css/index.css b/src/html/css/index.css index 0918c9dc..9148e096 100755 --- a/src/html/css/index.css +++ b/src/html/css/index.css @@ -101,15 +101,29 @@ a:hover { #songs li:last-child { border-bottom: none; } #songs li:hover { background-color: #eef2f5; } -#songs li a.title { +/* Main song link wrapper */ +#songs li a.song-link { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5em; + flex: 1; + min-width: 0; + text-decoration: none; + color: inherit; +} + +#songs li a.song-link:hover { + text-decoration: none; +} + +#songs li a.song-link .title { font-size: 1.1em; color: #333; font-weight: 400; - margin-left: 1em; } -#songs li .alias { - margin-left: 0.5em; /* Adds space between the title and the alias */ +#songs li a.song-link .alias { font-size: 0.9em; color: #6c757d; font-style: italic; @@ -123,14 +137,15 @@ a:hover { content: ')'; } -#songs li .artist { +#songs li a.song-link .artist { margin-left: auto; /* This takes up all available space, pushing the element to the end */ padding-left: 1em; /* Ensures some space between the alias and artist on smaller screens */ + font-size: 0.95em; + color: #6c757d; } -#songs li a:hover { - text-decoration: none; +#songs li a.song-link:hover .title { color: #0056b3; } diff --git a/src/html/index_generator.py b/src/html/index_generator.py index a141aded..7229a334 100644 --- a/src/html/index_generator.py +++ b/src/html/index_generator.py @@ -26,29 +26,51 @@ def create_index_html(list_of_songs_meta, target_dir): if head.find(".//meta[@charset]") is None: etree.SubElement(head, "meta", attrib={"charset": "utf-8"}, nsmap=None).tail = "\n" - # Add meta description + # Add canonical URL + etree.SubElement(head, "link", attrib={"rel": "canonical", "href": "https://spiewaj.com/"}, nsmap=None).tail = "\n" + + # Add meta description with keywords desc_meta = etree.SubElement(head, "meta", attrib={ "name": "description", - "content": "Śpiewnik harcerski - teksty i chwyty piosenek harcerskich, biesiadnych i turystycznych" + "content": "Śpiewnik harcerski online - teksty i chwyty gitarowe piosenek harcerskich, biesiadnych, turystycznych i obozowych. Darmowy śpiewnik z akordami do druku (PDF) i na Kindle (EPUB)." }, nsmap=None) desc_meta.tail = "\n" + # Add keywords + etree.SubElement(head, "meta", attrib={ + "name": "keywords", + "content": "śpiewnik harcerski, teksty piosenek, chwyty gitarowe, akordy, piosenki harcerskie, piosenki biesiadne, piosenki turystyczne, śpiewnik pdf, śpiewnik kindle" + }, nsmap=None).tail = "\n" + # Add Open Graph tags - etree.SubElement(head, "meta", attrib={"property": "og:title", "content": "Spiewaj.com - Śpiewnik harcerski"}, nsmap=None).tail = "\n" + etree.SubElement(head, "meta", attrib={"property": "og:title", "content": "Spiewaj.com - Śpiewnik harcerski online"}, nsmap=None).tail = "\n" etree.SubElement(head, "meta", attrib={"property": "og:type", "content": "website"}, nsmap=None).tail = "\n" etree.SubElement(head, "meta", attrib={"property": "og:url", "content": "https://spiewaj.com/"}, nsmap=None).tail = "\n" etree.SubElement(head, "meta", attrib={"property": "og:description", "content": "Śpiewnik harcerski - teksty i chwyty piosenek"}, nsmap=None).tail = "\n" + etree.SubElement(head, "meta", attrib={"property": "og:locale", "content": "pl_PL"}, nsmap=None).tail = "\n" + etree.SubElement(head, "meta", attrib={"property": "og:site_name", "content": "Spiewaj.com"}, nsmap=None).tail = "\n" + + # Add Twitter Card tags + etree.SubElement(head, "meta", attrib={"name": "twitter:card", "content": "summary"}, nsmap=None).tail = "\n" + etree.SubElement(head, "meta", attrib={"name": "twitter:title", "content": "Spiewaj.com - Śpiewnik harcerski online"}, nsmap=None).tail = "\n" + etree.SubElement(head, "meta", attrib={"name": "twitter:description", "content": "Śpiewnik harcerski - teksty i chwyty piosenek"}, nsmap=None).tail = "\n" - # Add JSON-LD structured data + # Add JSON-LD structured data with BreadcrumbList and ItemList script_ld = etree.SubElement(head, "script", attrib={"type": "application/ld+json"}, nsmap=None) import json ld_json = { "@context": "https://schema.org", "@type": "WebSite", "name": "Spiewaj.com", + "alternateName": "Śpiewnik", "url": "https://spiewaj.com/", - "description": "Śpiewnik harcerski - teksty i chwyty piosenek harcerskich, biesiadnych i turystycznych", - "inLanguage": "pl-PL" + "description": "Śpiewnik - teksty i chwyty piosenek popularnych, harcerskich, turystycznych", + "inLanguage": "pl-PL", + "potentialAction": { + "@type": "SearchAction", + "target": "https://spiewaj.com/#songSearch={search_term_string}", + "query-input": "required name=search_term_string" + } } script_ld.text = json.dumps(ld_json, ensure_ascii=False, indent=2) script_ld.tail = "\n" @@ -63,15 +85,27 @@ def create_index_html(list_of_songs_meta, target_dir): continue song_html = os.path.join("./songs_html", song.base_file_name() + '.html') li = etree.SubElement(ul, "li") - # entire li is clickable and should navigate to the song - li.attrib['onclick'] = "location.href='"+song_html+"';" - - # + + # Main anchor wrapping the title - SEO-friendly structure + a_main = etree.SubElement(li, "a") + a_main.attrib['class'] = 'song-link' + a_main.attrib['href'] = song_html + + # Build descriptive title attribute for SEO + title_text = song.effectiveTitle() + if song.artist(): + a_main.attrib['title'] = f"{title_text} - {song.artist()} - tekst i chwyty" + else: + a_main.attrib['title'] = f"{title_text} - tekst i chwyty" + + # Edit button (outside main link for event handling) button = etree.SubElement(li, "button") button.attrib['class'] = 'editicon' button.attrib['onclick'] = "edit('"+os.path.relpath(song.plik(), start=os.path.join(sb.repo_dir(), "songs"))+"');event.stopPropagation();" + button.attrib['aria-label'] = f"Edytuj {title_text}" span = etree.SubElement(button, 'span') span.attrib['class'] = 'material-symbols-outlined' + span.attrib['aria-hidden'] = 'true' span.text = 'edit' # A4 PDF link @@ -79,10 +113,13 @@ def create_index_html(list_of_songs_meta, target_dir): a_a4.attrib['class'] = 'pdflink' a_a4.attrib['href'] = f"./songs_pdf/{song.base_file_name()}.a4.pdf" a_a4.attrib['target'] = '_blank' - a_a4.attrib['title'] = 'Download A4 PDF' + a_a4.attrib['rel'] = 'noopener' + a_a4.attrib['title'] = f"{title_text} - PDF A4" + a_a4.attrib['aria-label'] = f"Pobierz {title_text} PDF A4" a_a4.attrib['onclick'] = 'event.stopPropagation();' span_a4 = etree.SubElement(a_a4, 'span') span_a4.attrib['class'] = 'material-symbols-outlined' + span_a4.attrib['aria-hidden'] = 'true' span_a4.text = 'picture_as_pdf' text_a4 = etree.SubElement(a_a4, 'span') text_a4.attrib['class'] = 'pdf-label' @@ -93,24 +130,35 @@ def create_index_html(list_of_songs_meta, target_dir): a_a5.attrib['class'] = 'pdflink' a_a5.attrib['href'] = f"./songs_pdf/{song.base_file_name()}.a5.pdf" a_a5.attrib['target'] = '_blank' - a_a5.attrib['title'] = 'Download A5 PDF' + a_a5.attrib['rel'] = 'noopener' + a_a5.attrib['title'] = f"{title_text} - PDF A5" + a_a5.attrib['aria-label'] = f"Pobierz {title_text} PDF A5" a_a5.attrib['onclick'] = 'event.stopPropagation();' span_a5 = etree.SubElement(a_a5, 'span') span_a5.attrib['class'] = 'material-symbols-outlined' + span_a5.attrib['aria-hidden'] = 'true' span_a5.text = 'picture_as_pdf' text_a5 = etree.SubElement(a_a5, 'span') text_a5.attrib['class'] = 'pdf-label' text_a5.text = 'A5' - a = etree.SubElement(li, "a") - a.attrib['class'] = 'title' - a.attrib['href'] = song_html - a.text = list_of_songs_meta[i].effectiveTitle() + + # Title span inside main link + title_span = etree.SubElement(a_main, "span") + title_span.attrib['class'] = 'title' + title_span.text = title_text + + # Aliases inside main link for song_alias in song.aliases(): - alias = etree.SubElement(li, "span", attrib={"class": "alias"}) + alias = etree.SubElement(a_main, "span", attrib={"class": "alias"}) alias.text = song_alias + + # Artist inside main link if song.artist(): - artist = etree.SubElement(li, "span", attrib={"class": "artist"}) + artist = etree.SubElement(a_main, "span", attrib={"class": "artist"}) artist.text = song.artist() + + # Add onclick to li for backward compatibility with existing CSS/JS + li.attrib['onclick'] = "if(event.target.tagName.toLowerCase() !== 'a' && event.target.tagName.toLowerCase() !== 'button' && !event.target.closest('a') && !event.target.closest('button')) { location.href='"+song_html+"'; }" # List of songbooks - try both HTML and XHTML namespaces ul = tree.getroot().find(".//ul[@id='songbooks']")