diff --git a/.changes/a11y-icon-button-names.md b/.changes/a11y-icon-button-names.md new file mode 100644 index 000000000..40b4c7aec --- /dev/null +++ b/.changes/a11y-icon-button-names.md @@ -0,0 +1,9 @@ +--- +type: fix +area: a11y +--- + +Screen readers now announce every icon-only button by name, including the +Sources row actions, the playlist details dialog, channel list actions, the +channel details copy button and the release notes arrows. The channel star +reads as "Favorite" and reports whether it is on. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7692558c..807706849 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -416,6 +416,11 @@ jobs: - name: Validate Material icon names run: pnpm run styles:icon-ligatures:validate + # A tooltip is not an accessible name: an icon-only needs + # aria-label, an aria-label binding or aria-labelledby. + - name: Validate icon-only button names + run: pnpm run a11y:icon-buttons:validate + # Nx rejects a non-parallel task with continuous dependencies, and # the Playwright plugin infers serve dependencies only when CI is # unset, so this checks both the local and the CI inference. diff --git a/apps/web-e2e/src/icon-button-names.e2e.ts b/apps/web-e2e/src/icon-button-names.e2e.ts new file mode 100644 index 000000000..18e7b997d --- /dev/null +++ b/apps/web-e2e/src/icon-button-names.e2e.ts @@ -0,0 +1,170 @@ +import type { Locator, Page } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { expect, test } from './fixtures'; +import { CLIP_TITLE, importPlaylist } from './player-settings-panel.fixture'; +import { + addM3uUrlPlaylist, + addXtreamPortal, + interceptPwaProviderRequests, + openSourceEditor, + openSources, + resetPwaMockServers, + sourceRowByTitle, + XTREAM_MOCK_SERVER, +} from './sources-pwa.helpers'; + +/** + * Every icon-only button on the main surfaces has an accessible name: axe's + * `button-name` rule over the whole page on a channel list in the player + * sidebar, the channel details dialog, the Sources list, the playlist info + * dialog and an Xtream catalog search. `pnpm run a11y:icon-buttons:validate` checks the + * templates statically; this checks the rendered names, so a label bound to + * an empty value fails too. + */ + +const AXE_SOURCE = readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8'); +const M3U_TITLE = 'Icon Button Names M3U'; +const XTREAM_TITLE = 'Icon Button Names Xtream'; + +interface ButtonNameAudit { + violations: string[]; + checked: number; +} + +/** Runs axe's `button-name` rule over `scope`. */ +async function auditButtonNames(scope: Locator): Promise { + await scope.page().evaluate(AXE_SOURCE); + return scope.evaluate(async (element) => { + const axe = (window as unknown as { axe: typeof import('axe-core') }) + .axe; + const results = await axe.run(element, { + runOnly: { type: 'rule', values: ['button-name'] }, + resultTypes: ['violations', 'passes'], + }); + const nodes = (kind: 'violations' | 'passes') => + results[kind].flatMap((rule) => rule.nodes); + return { + violations: nodes('violations').map( + (node) => `${node.target.join(' ')}: ${node.html}` + ), + checked: nodes('violations').length + nodes('passes').length, + }; + }); +} + +/** + * No nameless button on the page (dialogs included), which shows at least + * `minimum` buttons. + */ +async function expectNamedButtons(page: Page, minimum: number) { + const audit = await auditButtonNames(page.locator('body')); + expect(audit.violations).toEqual([]); + expect(audit.checked).toBeGreaterThanOrEqual(minimum); +} + +async function closeDialog(page: Page, dialog: Locator) { + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); +} + +test.beforeEach(async ({ page, request }) => { + await resetPwaMockServers(request); + await interceptPwaProviderRequests(page); + await page.goto('/'); +}); + +test('@a11y icon-only buttons are named in a channel list and its details', async ({ + page, +}) => { + await importPlaylist(page); + + // Channel list in the player sidebar: the favorite toggle and the row + // actions. + const channel = page + .locator('app-sidebar app-channel-list-item') + .filter({ hasText: CLIP_TITLE }); + await expect(channel).toBeVisible(); + await channel.hover(); + await expectNamedButtons(page, 5); + + // The favorite toggle keeps one name and reports its state. + const favorite = channel.locator('.favorite-button'); + await expect(favorite).toHaveAccessibleName('Favorite'); + await expect(favorite).toHaveAttribute('aria-pressed', 'false'); + await favorite.click(); + await expect(favorite).toHaveAttribute('aria-pressed', 'true'); + await expect(favorite).toHaveAccessibleName('Favorite'); + + // Channel details dialog: the copy button next to the stream URL. + await channel.locator('.channel-content').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Show channel details' }).click(); + const details = page.locator('mat-dialog-container').last(); + await expect( + details.getByRole('button', { name: 'Copy stream URL' }) + ).toBeVisible(); + await expectNamedButtons(page, 1); + await closeDialog(page, details); +}); + +test('@a11y icon-only buttons are named in Sources and the playlist info dialog', async ({ + page, +}) => { + await addM3uUrlPlaylist(page, M3U_TITLE); + + // Sources list: refresh, details and remove on each row. + await openSources(page); + const row = sourceRowByTitle(page, M3U_TITLE); + await expect(row).toBeVisible({ timeout: 15_000 }); + await row.hover(); + for (const name of [ + 'Refresh playlist', + 'Show playlist details', + 'Remove playlist', + ]) { + await expect(row.getByRole('button', { name })).toBeAttached(); + } + await expectNamedButtons(page, 3); + + // Playlist info dialog, with an EPG source row and its remove button. + const info = await openSourceEditor(page, M3U_TITLE); + await info.getByRole('button', { name: 'Add EPG source' }).click(); + await expect( + info.getByRole('button', { name: 'Remove EPG source' }).last() + ).toBeVisible(); + await expect( + info.getByRole('button', { name: 'Copy URL to clipboard' }) + ).toBeVisible(); + await expectNamedButtons(page, 3); + await closeDialog(page, info); +}); + +test('@a11y icon-only buttons are named in an Xtream catalog search', async ({ + page, + request, +}) => { + await addXtreamPortal(page, XTREAM_TITLE); + + // Search for a word from a movie the portal serves, so the results grid + // renders cards. The PWA searches the catalog it holds in memory, so stay + // in the app instead of reloading into the search route. + const response = await request.get( + `${XTREAM_MOCK_SERVER}/player_api.php?username=user1&password=pass1&action=get_vod_streams` + ); + expect(response.ok()).toBeTruthy(); + const [movie] = (await response.json()) as { name: string }[]; + const term = movie.name + .split(/[^\p{L}\p{N}]+/u) + .reduce((longest, word) => + word.length > longest.length ? word : longest + ); + + await page.getByRole('link', { name: 'Advanced search' }).click(); + await page.waitForURL(/\/search/); + const searchBox = page.getByRole('searchbox', { name: 'Search' }); + await searchBox.fill(term); + await searchBox.press('Enter'); + await expect( + page.locator('app-search-results app-content-card').first() + ).toBeVisible({ timeout: 15_000 }); + await expectNamedButtons(page, 1); +}); diff --git a/apps/web/src/app/settings/app-update-release-notes-dialog.component.ts b/apps/web/src/app/settings/app-update-release-notes-dialog.component.ts index 20e172624..c31bd7f3f 100644 --- a/apps/web/src/app/settings/app-update-release-notes-dialog.component.ts +++ b/apps/web/src/app/settings/app-update-release-notes-dialog.component.ts @@ -88,6 +88,9 @@ function decorateReleaseNotesHtml(html: string): string { mat-icon-button type="button" [disabled]="loading() || !notes()?.hasPrevious" + [attr.aria-label]=" + 'SETTINGS.APP_UPDATE_RELEASE_NOTES_OLDER' | translate + " (click)="load('previous')" data-test-id="release-notes-previous" > @@ -107,6 +110,9 @@ function decorateReleaseNotesHtml(html: string): string { mat-icon-button type="button" [disabled]="loading() || !notes()?.hasNext" + [attr.aria-label]=" + 'SETTINGS.APP_UPDATE_RELEASE_NOTES_NEWER' | translate + " (click)="load('next')" data-test-id="release-notes-next" > diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 5862d6617..bb65bbeda 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1,6 +1,7 @@ { "CANCEL": "إلغاء", "CLEAR": "مسح", + "CLEAR_SEARCH": "مسح البحث", "CLOSE": "إغلاق", "REMOVE": "إزالة", "BACK": "رجوع", @@ -585,6 +586,8 @@ "APP_UPDATE_INSTALL": "إعادة تشغيل التطبيق", "APP_UPDATE_OPEN_RELEASE": "فتح إصدار GitHub", "APP_UPDATE_RELEASE_NOTES": "ما الجديد", + "APP_UPDATE_RELEASE_NOTES_OLDER": "إصدار أقدم", + "APP_UPDATE_RELEASE_NOTES_NEWER": "إصدار أحدث", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "لم يتم العثور على ملاحظات الإصدار {{version}} على GitHub. ربما تمت إزالة الإصدار أو لم يُنشر بعد.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "تعذّر تحميل ملاحظات الإصدار.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "ملاحظات الإصدار غير متاحة في هذه النسخة من التطبيق.", @@ -866,6 +869,7 @@ "UNGROUPED": "غير مصنفة", "UNNAMED_CHANNEL": "قناة بدون اسم", "FAVORITES": "المفضلة", + "FAVORITE": "مفضلة", "NO_FAVORITES": "لا توجد قنوات مفضلة", "USE_STAR_TO_FAVORITE": "استخدم أيقونة النجمة لإضافة قنوات للمفضلة.", "REMOVE_FAVORITE": "إزالة من المفضلة", @@ -901,6 +905,7 @@ "NAME": "الاسم", "CHANNEL_ID": "معرّف القناة", "STREAM_URL": "رابط البث", + "COPY_STREAM_URL": "نسخ رابط البث", "GROUP": "المجموعة", "RADIO": "راديو", "CATCHUP_AVAILABLE": "اللحاق بالبث متاح", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 4ca5a8581..5300b1c07 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1,6 +1,7 @@ { "CANCEL": "إلغاء", "CLEAR": "مسح", + "CLEAR_SEARCH": "مسح البحث", "CLOSE": "سد", "REMOVE": "حيد", "BACK": "رجع", @@ -585,6 +586,8 @@ "APP_UPDATE_INSTALL": "عاود شعل التطبيق", "APP_UPDATE_OPEN_RELEASE": "حل إصدار GitHub", "APP_UPDATE_RELEASE_NOTES": "شنو الجديد", + "APP_UPDATE_RELEASE_NOTES_OLDER": "إصدار أقدم", + "APP_UPDATE_RELEASE_NOTES_NEWER": "إصدار أحدث", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "ما لقيناش ملاحظات الإصدار {{version}} فـ GitHub. يمكن الإصدار تحيد ولا مازال ما تنشرش.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "ما قدرناش نحمّلو ملاحظات الإصدار.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "ملاحظات الإصدار ما متوفراش فهاد النسخة.", @@ -866,6 +869,7 @@ "UNGROUPED": "غير مجمعة", "UNNAMED_CHANNEL": "قناة بدون اسم", "FAVORITES": "المفضلة", + "FAVORITE": "مفضلة", "NO_FAVORITES": "مازال ما كاينش مفضلة", "USE_STAR_TO_FAVORITE": "عفاك استعمل رمز 'النجمة' باش تزيد قنوات للمفضلة ديالك.", "REMOVE_FAVORITE": "حيد من المفضلة", @@ -901,6 +905,7 @@ "NAME": "الاسم", "CHANNEL_ID": "ID ديال القناة", "STREAM_URL": "رابط البث", + "COPY_STREAM_URL": "نسخ رابط البث", "GROUP": "المجموعة", "RADIO": "راديو مباشر", "CATCHUP_AVAILABLE": "إعادة المشاهدة متوفرة", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 7f280cee7..e33cac4eb 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1,6 +1,7 @@ { "CANCEL": "Адмена", "CLEAR": "Ачысціць", + "CLEAR_SEARCH": "Ачысціць пошук", "CLOSE": "Закрыць", "REMOVE": "Выдаліць", "BACK": "Назад", @@ -585,6 +586,8 @@ "APP_UPDATE_INSTALL": "Перазапусціць праграму", "APP_UPDATE_OPEN_RELEASE": "Адкрыць рэліз GitHub", "APP_UPDATE_RELEASE_NOTES": "Што новага", + "APP_UPDATE_RELEASE_NOTES_OLDER": "Старэйшая версія", + "APP_UPDATE_RELEASE_NOTES_NEWER": "Навейшая версія", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Заўвагі да версіі {{version}} на GitHub не знойдзены. Рэліз мог быць выдалены або яшчэ не апублікаваны.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Не ўдалося загрузіць заўвагі да версіі.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Заўвагі да версіі недаступныя ў гэтай зборцы.", @@ -866,6 +869,7 @@ "UNGROUPED": "Незагрупаваныя", "UNNAMED_CHANNEL": "Канал без назвы", "FAVORITES": "Выбраныя", + "FAVORITE": "Выбранае", "NO_FAVORITES": "Выбраных каналаў пакуль няма", "USE_STAR_TO_FAVORITE": "Выкарыстоўвайце значок зоркі, каб дадаваць каналы ў выбранае", "REMOVE_FAVORITE": "Выдаліць са спісу фаварытаў", @@ -901,6 +905,7 @@ "NAME": "Назва", "CHANNEL_ID": "ID канала", "STREAM_URL": "URL патоку", + "COPY_STREAM_URL": "Скапіяваць URL патоку", "GROUP": "Група", "RADIO": "Радыё", "CATCHUP_AVAILABLE": "Catch-up даступны", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 53c98261c..83e50e90c 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1,6 +1,7 @@ { "CANCEL": "Abbrechen", "CLEAR": "Leeren", + "CLEAR_SEARCH": "Suche löschen", "CLOSE": "Schließen", "REMOVE": "Entfernen", "BACK": "Zurück", @@ -585,6 +586,8 @@ "APP_UPDATE_INSTALL": "Anwendung neu starten", "APP_UPDATE_OPEN_RELEASE": "GitHub-Release öffnen", "APP_UPDATE_RELEASE_NOTES": "Was ist neu", + "APP_UPDATE_RELEASE_NOTES_OLDER": "Ältere Version", + "APP_UPDATE_RELEASE_NOTES_NEWER": "Neuere Version", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Für {{version}} wurden auf GitHub keine Release-Notes gefunden. Das Release wurde möglicherweise entfernt oder ist noch nicht veröffentlicht.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Die Release-Notes konnten nicht geladen werden.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Release-Notes sind in diesem Build nicht verfügbar.", @@ -866,6 +869,7 @@ "UNGROUPED": "Ohne Gruppe", "UNNAMED_CHANNEL": "Sender ohne Namen", "FAVORITES": "Favoriten", + "FAVORITE": "Favorit", "NO_FAVORITES": "Keine Favoriten", "USE_STAR_TO_FAVORITE": "Nutzen Sie das Star-Icon um Sender als Favoriten zu markieren", "REMOVE_FAVORITE": "Aus Favoritenliste entfernen", @@ -901,6 +905,7 @@ "NAME": "Name", "CHANNEL_ID": "Sender-ID", "STREAM_URL": "Stream-URL", + "COPY_STREAM_URL": "Stream-URL kopieren", "GROUP": "Gruppe", "RADIO": "Live-Radio", "CATCHUP_AVAILABLE": "Replay verfügbar", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 943d6ad06..35287143e 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1,6 +1,7 @@ { "CANCEL": "Ακύρωση", "CLEAR": "Εκκαθάριση", + "CLEAR_SEARCH": "Καθαρισμός αναζήτησης", "CLOSE": "Κλείσιμο", "REMOVE": "Αφαίρεση", "BACK": "Πίσω", @@ -585,6 +586,8 @@ "APP_UPDATE_INSTALL": "Επανεκκίνηση εφαρμογής", "APP_UPDATE_OPEN_RELEASE": "Άνοιγμα GitHub release", "APP_UPDATE_RELEASE_NOTES": "Τι νέο υπάρχει", + "APP_UPDATE_RELEASE_NOTES_OLDER": "Παλαιότερη έκδοση", + "APP_UPDATE_RELEASE_NOTES_NEWER": "Νεότερη έκδοση", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Δεν βρέθηκαν σημειώσεις έκδοσης για την {{version}} στο GitHub. Η έκδοση μπορεί να αφαιρέθηκε ή να μην έχει δημοσιευτεί ακόμη.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Δεν ήταν δυνατή η φόρτωση των σημειώσεων έκδοσης.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Οι σημειώσεις έκδοσης δεν είναι διαθέσιμες σε αυτή τη δομή.", @@ -866,6 +869,7 @@ "UNGROUPED": "Μη ομαδοποιημένα", "UNNAMED_CHANNEL": "Κανάλι χωρίς όνομα", "FAVORITES": "Αγαπημένα", + "FAVORITE": "Αγαπημένο", "NO_FAVORITES": "Δεν υπάρχουν ακόμα αγαπημένα", "USE_STAR_TO_FAVORITE": "Χρησιμοποιήστε το εικονίδιο με το «αστέρι» για να προσθέσετε κανάλια στα αγαπημένα σας.", "REMOVE_FAVORITE": "Αφαίρεση από τα αγαπημένα", @@ -901,6 +905,7 @@ "NAME": "Όνομα", "CHANNEL_ID": "ID καναλιού", "STREAM_URL": "URL ροής", + "COPY_STREAM_URL": "Αντιγραφή URL ροής", "GROUP": "Ομάδα", "RADIO": "Ζωντανό ραδιόφωνο", "CATCHUP_AVAILABLE": "Διαθέσιμο catch-up", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 5dca04307..100427948 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1,6 +1,7 @@ { "CANCEL": "Cancel", "CLEAR": "Clear", + "CLEAR_SEARCH": "Clear search", "CLOSE": "Close", "REMOVE": "Remove", "BACK": "Back", @@ -585,6 +586,8 @@ "APP_UPDATE_INSTALL": "Restart application", "APP_UPDATE_OPEN_RELEASE": "Open GitHub release", "APP_UPDATE_RELEASE_NOTES": "What's new", + "APP_UPDATE_RELEASE_NOTES_OLDER": "Older release", + "APP_UPDATE_RELEASE_NOTES_NEWER": "Newer release", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "No release notes were found for {{version}} on GitHub. The release may have been removed or is not published yet.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Release notes could not be loaded.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Release notes are not available in this build.", @@ -866,6 +869,7 @@ "UNGROUPED": "Ungrouped", "UNNAMED_CHANNEL": "Unnamed Channel", "FAVORITES": "Favorites", + "FAVORITE": "Favorite", "NO_FAVORITES": "No favorites yet", "USE_STAR_TO_FAVORITE": "Please use 'star' icon to add channels to your favorites.", "REMOVE_FAVORITE": "Remove from favorites", @@ -901,6 +905,7 @@ "NAME": "Name", "CHANNEL_ID": "Channel ID", "STREAM_URL": "Stream URL", + "COPY_STREAM_URL": "Copy stream URL", "GROUP": "Group", "RADIO": "Radio", "CATCHUP_AVAILABLE": "Catchup available", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index f9e0a3bb1..a137e863a 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1,6 +1,7 @@ { "CANCEL": "Cancelar", "CLEAR": "Borrar", + "CLEAR_SEARCH": "Borrar búsqueda", "CLOSE": "Cerrar", "REMOVE": "Quitar", "BACK": "Atrás", @@ -585,6 +586,8 @@ "APP_UPDATE_INSTALL": "Reiniciar aplicación", "APP_UPDATE_OPEN_RELEASE": "Abrir release de GitHub", "APP_UPDATE_RELEASE_NOTES": "Novedades", + "APP_UPDATE_RELEASE_NOTES_OLDER": "Versión más antigua", + "APP_UPDATE_RELEASE_NOTES_NEWER": "Versión más reciente", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "No se encontraron notas de la versión {{version}} en GitHub. Es posible que la versión se haya eliminado o aún no esté publicada.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "No se pudieron cargar las notas de la versión.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Las notas de la versión no están disponibles en esta compilación.", @@ -866,6 +869,7 @@ "UNGROUPED": "Desagrupado", "UNNAMED_CHANNEL": "Canal sin nombre", "FAVORITES": "Favoritos", + "FAVORITE": "Favorito", "NO_FAVORITES": "Sin favoritos", "USE_STAR_TO_FAVORITE": "Por favor, use el ícono de la 'estrella' para añadir canales a la lista.", "REMOVE_FAVORITE": "Quitar de favoritos", @@ -901,6 +905,7 @@ "NAME": "Nombre", "CHANNEL_ID": "ID del canal", "STREAM_URL": "URL de transmisión", + "COPY_STREAM_URL": "Copiar URL del stream", "GROUP": "Grupo", "RADIO": "Radio en directo", "CATCHUP_AVAILABLE": "Catch-up disponible", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 118625c62..221d3814b 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1,6 +1,7 @@ { "CANCEL": "Annuler", "CLEAR": "Effacer", + "CLEAR_SEARCH": "Effacer la recherche", "CLOSE": "Fermer", "REMOVE": "Retirer", "BACK": "Retour", @@ -585,6 +586,8 @@ "APP_UPDATE_INSTALL": "Redémarrer l’application", "APP_UPDATE_OPEN_RELEASE": "Ouvrir la release GitHub", "APP_UPDATE_RELEASE_NOTES": "Nouveautés", + "APP_UPDATE_RELEASE_NOTES_OLDER": "Version plus ancienne", + "APP_UPDATE_RELEASE_NOTES_NEWER": "Version plus récente", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Aucune note de version n'a été trouvée pour {{version}} sur GitHub. La version a peut-être été supprimée ou n'est pas encore publiée.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Impossible de charger les notes de version.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Les notes de version ne sont pas disponibles dans cette compilation.", @@ -866,6 +869,7 @@ "UNGROUPED": "Non-groupé", "UNNAMED_CHANNEL": "Chaîne sans nom", "FAVORITES": "Favoris", + "FAVORITE": "Favori", "NO_FAVORITES": "Aucun favoris", "USE_STAR_TO_FAVORITE": "Veuillez utiliser l'icône 'étoile' pour ajouter une chaîne aux favoris.", "REMOVE_FAVORITE": "Retirer des favoris", @@ -901,6 +905,7 @@ "NAME": "Nom", "CHANNEL_ID": "ID de la chaîne", "STREAM_URL": "URL du flux", + "COPY_STREAM_URL": "Copier l’URL du flux", "GROUP": "Groupe", "RADIO": "Radio en direct", "CATCHUP_AVAILABLE": "Replay disponible", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index e05114768..260727a68 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -1,6 +1,7 @@ { "CANCEL": "Mégse", "CLEAR": "Törlés", + "CLEAR_SEARCH": "Keresés törlése", "CLOSE": "Bezárás", "REMOVE": "Eltávolítás", "BACK": "Vissza", @@ -585,6 +586,8 @@ "APP_UPDATE_INSTALL": "Alkalmazás újraindítása", "APP_UPDATE_OPEN_RELEASE": "GitHub-kiadás megnyitása", "APP_UPDATE_RELEASE_NOTES": "Újdonságok", + "APP_UPDATE_RELEASE_NOTES_OLDER": "Régebbi kiadás", + "APP_UPDATE_RELEASE_NOTES_NEWER": "Újabb kiadás", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "A(z) {{version}} verzióhoz nem található kiadási jegyzet a GitHubon. A kiadást talán eltávolították, vagy még nem tették közzé.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "A kiadási jegyzetek betöltése nem sikerült.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Ebben a buildben nem érhetők el a kiadási jegyzetek.", @@ -866,6 +869,7 @@ "UNGROUPED": "Csoport nélkül", "UNNAMED_CHANNEL": "Névtelen csatorna", "FAVORITES": "Kedvencek", + "FAVORITE": "Kedvenc", "NO_FAVORITES": "Még nincs kedvenc csatorna", "USE_STAR_TO_FAVORITE": "A csatornákat a csillag ikonnal adhatja a kedvencekhez.", "REMOVE_FAVORITE": "Eltávolítás a kedvencek közül", @@ -901,6 +905,7 @@ "NAME": "Név", "CHANNEL_ID": "Csatornaazonosító", "STREAM_URL": "Adatfolyam URL-címe", + "COPY_STREAM_URL": "Adatfolyam URL-jének másolása", "GROUP": "Csoport", "RADIO": "Rádió", "CATCHUP_AVAILABLE": "Visszanézés elérhető", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 6cb587df7..952892477 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1,6 +1,7 @@ { "CANCEL": "Annulla", "CLEAR": "Cancella", + "CLEAR_SEARCH": "Cancella ricerca", "CLOSE": "Chiudi", "REMOVE": "Rimuovi", "BACK": "Indietro", @@ -585,6 +586,8 @@ "APP_UPDATE_INSTALL": "Riavvia applicazione", "APP_UPDATE_OPEN_RELEASE": "Apri release GitHub", "APP_UPDATE_RELEASE_NOTES": "Novità", + "APP_UPDATE_RELEASE_NOTES_OLDER": "Versione meno recente", + "APP_UPDATE_RELEASE_NOTES_NEWER": "Versione più recente", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Nessuna nota di rilascio trovata per {{version}} su GitHub. La versione potrebbe essere stata rimossa o non è ancora pubblicata.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Impossibile caricare le note di rilascio.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Le note di rilascio non sono disponibili in questa build.", @@ -866,6 +869,7 @@ "UNGROUPED": "Non raggruppati", "UNNAMED_CHANNEL": "Canale senza nome", "FAVORITES": "Preferiti", + "FAVORITE": "Preferito", "NO_FAVORITES": "Ancora nessun preferito", "USE_STAR_TO_FAVORITE": "Usa l'icona 'stella' per aggiungere canali ai preferiti.", "REMOVE_FAVORITE": "Rimuovi dai preferiti", @@ -901,6 +905,7 @@ "NAME": "Nome", "CHANNEL_ID": "ID canale", "STREAM_URL": "URL stream", + "COPY_STREAM_URL": "Copia URL dello stream", "GROUP": "Gruppo", "RADIO": "Radio live", "CATCHUP_AVAILABLE": "Catch-up disponibile", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index d320a35ab..d93db9bd4 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1,6 +1,7 @@ { "CANCEL": "キャンセル", "CLEAR": "クリア", + "CLEAR_SEARCH": "検索をクリア", "CLOSE": "閉じる", "REMOVE": "削除", "BACK": "戻る", @@ -585,6 +586,8 @@ "APP_UPDATE_INSTALL": "アプリを再起動", "APP_UPDATE_OPEN_RELEASE": "GitHub リリースを開く", "APP_UPDATE_RELEASE_NOTES": "更新内容", + "APP_UPDATE_RELEASE_NOTES_OLDER": "古いリリース", + "APP_UPDATE_RELEASE_NOTES_NEWER": "新しいリリース", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "{{version}} のリリースノートが GitHub に見つかりません。リリースが削除されたか、まだ公開されていない可能性があります。", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "リリースノートを読み込めませんでした。", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "このビルドではリリースノートを利用できません。", @@ -866,6 +869,7 @@ "UNGROUPED": "グループなし", "UNNAMED_CHANNEL": "名前のないチャンネル", "FAVORITES": "お気に入り", + "FAVORITE": "お気に入り", "NO_FAVORITES": "まだお気に入りがありません", "USE_STAR_TO_FAVORITE": "'スター'アイコンを使用してチャンネルをお気に入りに追加してください。", "REMOVE_FAVORITE": "お気に入りから削除", @@ -901,6 +905,7 @@ "NAME": "名前", "CHANNEL_ID": "チャンネルID", "STREAM_URL": "ストリームURL", + "COPY_STREAM_URL": "ストリーム URL をコピー", "GROUP": "グループ", "RADIO": "ラジオ", "CATCHUP_AVAILABLE": "見逃し配信に対応", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 76a4435f0..424f49caa 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1,6 +1,7 @@ { "CANCEL": "취소", "CLEAR": "지우기", + "CLEAR_SEARCH": "검색 지우기", "CLOSE": "닫기", "REMOVE": "제거", "BACK": "뒤로", @@ -585,6 +586,8 @@ "APP_UPDATE_INSTALL": "애플리케이션 다시 시작", "APP_UPDATE_OPEN_RELEASE": "GitHub 릴리스 열기", "APP_UPDATE_RELEASE_NOTES": "새로운 내용", + "APP_UPDATE_RELEASE_NOTES_OLDER": "이전 릴리스", + "APP_UPDATE_RELEASE_NOTES_NEWER": "이후 릴리스", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "GitHub에서 {{version}}의 릴리스 노트를 찾을 수 없습니다. 릴리스가 삭제되었거나 아직 게시되지 않았을 수 있습니다.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "릴리스 노트를 불러올 수 없습니다.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "이 빌드에서는 릴리스 노트를 사용할 수 없습니다.", @@ -866,6 +869,7 @@ "UNGROUPED": "그룹 해제", "UNNAMED_CHANNEL": "이름없는 채널", "FAVORITES": "즐겨찾기", + "FAVORITE": "즐겨찾기", "NO_FAVORITES": "즐겨찾기가 아직 없습니다", "USE_STAR_TO_FAVORITE": "'Star'아이콘을 사용하여 이 목록에 채널을 추가하십시오.", "REMOVE_FAVORITE": "즐겨찾기에서 삭제하기", @@ -901,6 +905,7 @@ "NAME": "이름", "CHANNEL_ID": "채널 ID", "STREAM_URL": "스트림 URL", + "COPY_STREAM_URL": "스트림 URL 복사", "GROUP": "그룹", "RADIO": "라디오", "CATCHUP_AVAILABLE": "다시보기 지원", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 885116ffd..375422f3a 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1,6 +1,7 @@ { "CANCEL": "Annuleren", "CLEAR": "Wissen", + "CLEAR_SEARCH": "Zoekopdracht wissen", "CLOSE": "Afsluiten", "REMOVE": "Verwijderen", "BACK": "Terug", @@ -585,6 +586,8 @@ "APP_UPDATE_INSTALL": "Applicatie opnieuw starten", "APP_UPDATE_OPEN_RELEASE": "GitHub-release openen", "APP_UPDATE_RELEASE_NOTES": "Wat is nieuw", + "APP_UPDATE_RELEASE_NOTES_OLDER": "Oudere versie", + "APP_UPDATE_RELEASE_NOTES_NEWER": "Nieuwere versie", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Er zijn geen releasenotes voor {{version}} gevonden op GitHub. De release is mogelijk verwijderd of nog niet gepubliceerd.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "De releasenotes konden niet worden geladen.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Releasenotes zijn niet beschikbaar in deze build.", @@ -866,6 +869,7 @@ "UNGROUPED": "Niet gegroepeerd", "UNNAMED_CHANNEL": "Naamloos kanaal", "FAVORITES": "Favorieten", + "FAVORITE": "Favoriet", "NO_FAVORITES": "Nog geen favorieten", "USE_STAR_TO_FAVORITE": "Gebruik het ster-icoon om kanalen toe te voegen aan je favorieten.", "REMOVE_FAVORITE": "Verwijder uit favorieten", @@ -901,6 +905,7 @@ "NAME": "Naam", "CHANNEL_ID": "Kanaal-ID", "STREAM_URL": "Stream-URL", + "COPY_STREAM_URL": "Stream-URL kopiëren", "GROUP": "Groep", "RADIO": "Radiozender", "CATCHUP_AVAILABLE": "Terugkijken beschikbaar", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 78bf0aad8..c95253de5 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1,6 +1,7 @@ { "CANCEL": "Anuluj", "CLEAR": "Wyczyść", + "CLEAR_SEARCH": "Wyczyść wyszukiwanie", "CLOSE": "Zamknij", "REMOVE": "Usuń", "BACK": "Wstecz", @@ -585,6 +586,8 @@ "APP_UPDATE_INSTALL": "Uruchom aplikację ponownie", "APP_UPDATE_OPEN_RELEASE": "Otwórz wydanie w GitHub", "APP_UPDATE_RELEASE_NOTES": "Co nowego", + "APP_UPDATE_RELEASE_NOTES_OLDER": "Starsze wydanie", + "APP_UPDATE_RELEASE_NOTES_NEWER": "Nowsze wydanie", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Nie znaleziono informacji o wydaniu {{version}} w GitHub. Wydanie mogło zostać usunięte lub nie zostało jeszcze opublikowane.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Nie udało się wczytać informacji o wydaniu.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Informacje o wydaniu nie są dostępne w tej kompilacji.", @@ -866,6 +869,7 @@ "UNGROUPED": "Niegrupowane", "UNNAMED_CHANNEL": "Nienazwany kanał", "FAVORITES": "Ulubione", + "FAVORITE": "Ulubiony", "NO_FAVORITES": "Brak ulubionych", "USE_STAR_TO_FAVORITE": "Użyj ikony „gwiazdki”, aby dodać kanały do ulubionych.", "REMOVE_FAVORITE": "Usuń z ulubionych", @@ -901,6 +905,7 @@ "NAME": "Nazwa", "CHANNEL_ID": "ID kanału", "STREAM_URL": "URL strumienia", + "COPY_STREAM_URL": "Kopiuj URL strumienia", "GROUP": "Grupa", "RADIO": "Radio internetowe", "CATCHUP_AVAILABLE": "Archiwum TV dostępne", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 79a1fe512..055472192 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1,6 +1,7 @@ { "CANCEL": "Cancelar", "CLEAR": "Limpar", + "CLEAR_SEARCH": "Limpar pesquisa", "CLOSE": "Fechar", "REMOVE": "Remover", "BACK": "Voltar", @@ -585,6 +586,8 @@ "APP_UPDATE_INSTALL": "Reiniciar aplicativo", "APP_UPDATE_OPEN_RELEASE": "Abrir release no GitHub", "APP_UPDATE_RELEASE_NOTES": "Novidades", + "APP_UPDATE_RELEASE_NOTES_OLDER": "Versão mais antiga", + "APP_UPDATE_RELEASE_NOTES_NEWER": "Versão mais recente", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Nenhuma nota de versão foi encontrada para {{version}} no GitHub. A versão pode ter sido removida ou ainda não foi publicada.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Não foi possível carregar as notas da versão.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "As notas da versão não estão disponíveis nesta compilação.", @@ -866,6 +869,7 @@ "UNGROUPED": "Não agrupados", "UNNAMED_CHANNEL": "Canal sem nome", "FAVORITES": "Favoritos", + "FAVORITE": "Favorito", "NO_FAVORITES": "Nenhum favorito ainda", "USE_STAR_TO_FAVORITE": "Use o ícone 'estrela' para adicionar canais aos seus favoritos.", "REMOVE_FAVORITE": "Remover dos favoritos", @@ -901,6 +905,7 @@ "NAME": "Nome", "CHANNEL_ID": "ID do canal", "STREAM_URL": "URL do stream", + "COPY_STREAM_URL": "Copiar URL do stream", "GROUP": "Grupo", "RADIO": "Rádio", "CATCHUP_AVAILABLE": "Catch-up disponível", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 97d505aaa..1461bf5ae 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1,6 +1,7 @@ { "CANCEL": "Отмена", "CLEAR": "Очистить", + "CLEAR_SEARCH": "Очистить поиск", "CLOSE": "Закрыть", "REMOVE": "Удалить", "BACK": "Назад", @@ -585,6 +586,8 @@ "APP_UPDATE_INSTALL": "Перезапустить приложение", "APP_UPDATE_OPEN_RELEASE": "Открыть релиз на GitHub", "APP_UPDATE_RELEASE_NOTES": "Что нового", + "APP_UPDATE_RELEASE_NOTES_OLDER": "Более ранняя версия", + "APP_UPDATE_RELEASE_NOTES_NEWER": "Более поздняя версия", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Заметки к версии {{version}} на GitHub не найдены. Релиз мог быть удалён или ещё не опубликован.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Не удалось загрузить заметки к версии.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Заметки к версии недоступны в этой сборке.", @@ -866,6 +869,7 @@ "UNGROUPED": "Негруппированные", "UNNAMED_CHANNEL": "Канал без названия", "FAVORITES": "Избранные", + "FAVORITE": "Избранное", "NO_FAVORITES": "Избранных каналов пока нет", "USE_STAR_TO_FAVORITE": "Используйте иконку звезды, чтобы добавлять каналы в избранное", "REMOVE_FAVORITE": "Удалить из списка фаворитов", @@ -901,6 +905,7 @@ "NAME": "Название", "CHANNEL_ID": "ID канала", "STREAM_URL": "URL потока", + "COPY_STREAM_URL": "Скопировать URL потока", "GROUP": "Группа", "RADIO": "Радио", "CATCHUP_AVAILABLE": "Архив доступен", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 8d3f60f3c..ad0f228a0 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1,6 +1,7 @@ { "CANCEL": "İptal", "CLEAR": "Temizle", + "CLEAR_SEARCH": "Aramayı temizle", "CLOSE": "Kapat", "REMOVE": "Kaldır", "BACK": "Geri", @@ -585,6 +586,8 @@ "APP_UPDATE_INSTALL": "Uygulamayı yeniden başlat", "APP_UPDATE_OPEN_RELEASE": "GitHub sürümünü aç", "APP_UPDATE_RELEASE_NOTES": "Yenilikler", + "APP_UPDATE_RELEASE_NOTES_OLDER": "Daha eski sürüm", + "APP_UPDATE_RELEASE_NOTES_NEWER": "Daha yeni sürüm", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "GitHub'da {{version}} için sürüm notu bulunamadı. Sürüm kaldırılmış ya da henüz yayınlanmamış olabilir.", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Sürüm notları yüklenemedi.", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Sürüm notları bu derlemede kullanılamıyor.", @@ -866,6 +869,7 @@ "UNGROUPED": "Grupsuz", "UNNAMED_CHANNEL": "İsimsiz kanal", "FAVORITES": "Favoriler", + "FAVORITE": "Favori", "NO_FAVORITES": "Henüz favori yok", "USE_STAR_TO_FAVORITE": "Lütfen favorilere kanal eklemek için 'yıldız' simgesini kullanın.", "REMOVE_FAVORITE": "Favorilerden kaldır", @@ -901,6 +905,7 @@ "NAME": "Ad", "CHANNEL_ID": "Kanal ID", "STREAM_URL": "Yayın URL'si", + "COPY_STREAM_URL": "Akış URL’sini kopyala", "GROUP": "Grup", "RADIO": "Canlı radyo", "CATCHUP_AVAILABLE": "Geri izleme mevcut", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 059939cba..4f853bccc 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1,6 +1,7 @@ { "CANCEL": "取消", "CLEAR": "清除", + "CLEAR_SEARCH": "清除搜索", "CLOSE": "关闭", "REMOVE": "移除", "BACK": "返回", @@ -585,6 +586,8 @@ "APP_UPDATE_INSTALL": "重启应用", "APP_UPDATE_OPEN_RELEASE": "打开 GitHub 发布页", "APP_UPDATE_RELEASE_NOTES": "更新内容", + "APP_UPDATE_RELEASE_NOTES_OLDER": "较旧版本", + "APP_UPDATE_RELEASE_NOTES_NEWER": "较新版本", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "在 GitHub 上未找到 {{version}} 的更新说明。该版本可能已被移除或尚未发布。", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "无法加载更新说明。", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "此构建中没有可用的更新说明。", @@ -866,6 +869,7 @@ "UNGROUPED": "未分组", "UNNAMED_CHANNEL": "未命名频道", "FAVORITES": "收藏", + "FAVORITE": "收藏", "NO_FAVORITES": "还没有收藏夹", "USE_STAR_TO_FAVORITE": "使用“☆”图标将频道添加到此列表中", "REMOVE_FAVORITE": "从收藏夹中删除", @@ -901,6 +905,7 @@ "NAME": "名称", "CHANNEL_ID": "频道 ID", "STREAM_URL": "流 URL", + "COPY_STREAM_URL": "复制流 URL", "GROUP": "分组", "RADIO": "电台", "CATCHUP_AVAILABLE": "支持回看", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index f9fa34ce8..2f07560b7 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1,6 +1,7 @@ { "CANCEL": "取消", "CLEAR": "清除", + "CLEAR_SEARCH": "清除搜尋", "CLOSE": "關閉", "REMOVE": "移除", "BACK": "返回", @@ -585,6 +586,8 @@ "APP_UPDATE_INSTALL": "重新啟動應用程式", "APP_UPDATE_OPEN_RELEASE": "開啟 GitHub 發布頁", "APP_UPDATE_RELEASE_NOTES": "更新內容", + "APP_UPDATE_RELEASE_NOTES_OLDER": "較舊版本", + "APP_UPDATE_RELEASE_NOTES_NEWER": "較新版本", "APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "在 GitHub 上找不到 {{version}} 的更新說明。該版本可能已被移除或尚未發布。", "APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "無法載入更新說明。", "APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "此版本未提供更新說明。", @@ -866,6 +869,7 @@ "UNGROUPED": "未分組", "UNNAMED_CHANNEL": "未命名頻道", "FAVORITES": "我的最愛", + "FAVORITE": "我的最愛", "NO_FAVORITES": "尚無最愛", "USE_STAR_TO_FAVORITE": "請使用“星星”圖示將頻道新增至此列表。", "REMOVE_FAVORITE": "從我的最愛中移除", @@ -901,6 +905,7 @@ "NAME": "名稱", "CHANNEL_ID": "頻道 ID", "STREAM_URL": "串流網址", + "COPY_STREAM_URL": "複製串流網址", "GROUP": "群組", "RADIO": "廣播", "CATCHUP_AVAILABLE": "支援回看", diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 51c34a12b..ae262cca8 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -1046,6 +1046,23 @@ the string results of its bindings, `icon` / `*Icon` inputs, and TypeScript values named `icon`, `*Icon`, `*_ICON`, `*Icons` or `*_ICONS`. Name a member that feeds an icon that way, so the check can see its values. +## Icon-Only Buttons + +A `` whose only content is a `` needs an accessible name: +the ligature text is hidden from assistive technology, and a `matTooltip` +only adds a description that appears on hover or focus. Give it a translated +`aria-label`, usually `[attr.aria-label]` bound to the same key as its +tooltip, or `aria-labelledby`. A toggle keeps one label and reports its state +with `aria-pressed` (the channel row's star is "Favorite", pressed or not), +as the password visibility toggle does; its tooltip may still name the next +action. `pnpm run a11y:icon-buttons:validate` (CI) checks `.html` and inline +templates, looking through control flow, `ng-container` and spinners; content +it cannot see into (`ng-content`, another component, an image) counts as +named, and text under `aria-hidden="true"` never does. +`icon-button-names.e2e.ts` in `web-e2e` runs axe's `button-name` rule +on a channel list, the channel details dialog, Sources, the playlist info +dialog and an Xtream search. + ## Phone Layout `640px` is the phone breakpoint. Use `@media (max-width: 640px)` rather than diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html index 5d073931a..d8ef6dbde 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html @@ -83,7 +83,9 @@ @if (searchQuery()) { close diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.html b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.html index 5a6b678f2..7c5698207 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.html +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.html @@ -167,6 +167,9 @@ [matTooltip]=" 'HOME.PLAYLISTS.INFO_DIALOG.COPY_URL' | translate " + [attr.aria-label]=" + 'HOME.PLAYLISTS.INFO_DIALOG.COPY_URL' | translate + " > content_copy @@ -273,6 +276,9 @@ [matTooltip]=" 'SETTINGS.REFRESH_EPG' | translate " + [attr.aria-label]=" + 'SETTINGS.REFRESH_EPG' | translate + " (click)="refreshPlaylistEpgSource(epgUrl)" > refresh @@ -284,6 +290,9 @@ [matTooltip]=" 'SETTINGS.ADD_EPG_SOURCE' | translate " + [attr.aria-label]=" + 'SETTINGS.ADD_EPG_SOURCE' | translate + " (click)="addPlaylistEpgSourceToSettings(epgUrl)" > playlist_add @@ -294,6 +303,9 @@ [matTooltip]=" 'SETTINGS.REMOVE_EPG_SOURCE' | translate " + [attr.aria-label]=" + 'SETTINGS.REMOVE_EPG_SOURCE' | translate + " (click)="removePlaylistEpgSource(epgUrl)" > delete @@ -352,6 +364,9 @@ [matTooltip]=" 'SETTINGS.REMOVE_EPG_SOURCE' | translate " + [attr.aria-label]=" + 'SETTINGS.REMOVE_EPG_SOURCE' | translate + " (click)=" removePlaylistEpgSourceInput($index) " diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html index 2ad7b14bc..2037eb452 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html @@ -187,6 +187,9 @@ [matTooltip]=" 'WORKSPACE.SHELL.XTREAM_IMPORT_CANCEL' | translate " + [attr.aria-label]=" + 'WORKSPACE.SHELL.XTREAM_IMPORT_CANCEL' | translate + " > close @@ -200,6 +203,7 @@ " [disabled]="isBusy()" [matTooltip]="'HOME.PLAYLISTS.REFRESH' | translate" + [attr.aria-label]="'HOME.PLAYLISTS.REFRESH' | translate" > @if (isRefreshing()) { @if (isRefreshing()) { edit @@ -252,6 +260,7 @@ (click)="$event.stopPropagation(); removeClicked.emit(item._id)" [disabled]="isBusy()" [matTooltip]="'HOME.PLAYLISTS.REMOVE' | translate" + [attr.aria-label]="'HOME.PLAYLISTS.REMOVE' | translate" > @if (isDeleting()) { @if (searchTerm()) { - + close } diff --git a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.html b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.html index ae9eaf798..55efad6b2 100644 --- a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.html +++ b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.html @@ -25,7 +25,12 @@ } @if (showCloseButton()) { - + close } diff --git a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.html b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.html index 11630c282..24b0bcb1b 100644 --- a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.html +++ b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.html @@ -41,7 +41,12 @@ } @if (showGlobalCloseButton) { - + close } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.html b/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.html index d50563d69..128ed7ef5 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.html @@ -89,8 +89,12 @@ {{ channel.url }} content_copy diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html index 1dc13f901..3a77c63c7 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html @@ -101,6 +101,8 @@ : 'CHANNELS.ADD_FAVORITE' ) | translate " + [attr.aria-label]="'CHANNELS.FAVORITE' | translate" + [attr.aria-pressed]="isFavorite()" (click)="onFavoriteClick($event)" > {{ @@ -113,6 +115,7 @@ mat-icon-button class="favorite-button" [matTooltip]="auxActionTooltip()" + [attr.aria-label]="auxActionTooltip()" (click)="onAuxActionClick($event)" > {{ auxActionIcon() }} @@ -130,6 +133,9 @@ [matTooltip]=" 'EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS' | translate " + [attr.aria-label]=" + 'EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS' | translate + " (click)="onProgramInfoClick($event)" > info diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts index a012539e4..eec2eaceb 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts @@ -179,6 +179,40 @@ describe('ChannelListItemComponent', () => { expect(programmeDialog.open).not.toHaveBeenCalled(); }); + it('keeps one name on the favorite toggle and reports its state', () => { + fixture.componentRef.setInput('name', 'Starred'); + fixture.componentRef.setInput('showFavoriteButton', true); + fixture.detectChanges(); + const toggle = fixture.nativeElement.querySelector('.favorite-button'); + expect(toggle.getAttribute('aria-label')).toBe('CHANNELS.FAVORITE'); + expect(toggle.getAttribute('aria-pressed')).toBe('false'); + + fixture.componentRef.setInput('isFavorite', true); + fixture.detectChanges(); + expect(toggle.getAttribute('aria-label')).toBe('CHANNELS.FAVORITE'); + expect(toggle.getAttribute('aria-pressed')).toBe('true'); + }); + + it('names the icon-only row actions after their tooltips', () => { + fixture.componentRef.setInput('name', 'Recent'); + fixture.componentRef.setInput('showAuxActionButton', true); + fixture.componentRef.setInput( + 'auxActionTooltip', + 'Remove from history' + ); + fixture.detectChanges(); + + const buttons: HTMLButtonElement[] = Array.from( + fixture.nativeElement.querySelectorAll('.action-buttons button') + ); + expect( + buttons.map((button) => button.getAttribute('aria-label')) + ).toEqual([ + 'Remove from history', + 'EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS', + ]); + }); + it('renders the catch-up badge only when catch-up is available', () => { fixture.componentRef.setInput('name', 'Archive Channel'); fixture.detectChanges(); diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.html b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.html index 30e604276..c42b8e54c 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.html +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.html @@ -84,6 +84,9 @@ replay diff --git a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html index 2484b133d..6707bb45e 100644 --- a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html +++ b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html @@ -4,6 +4,7 @@ mat-icon-button routerLink="/" [matTooltip]="'SETTINGS.BACK_TO_HOME' | translate" + [attr.aria-label]="'SETTINGS.BACK_TO_HOME' | translate" class="home-btn" > home diff --git a/package.json b/package.json index 3c6b94f49..cf1aa8256 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,9 @@ "styles:icon-ligatures:test": "node --test tools/nx/check-icon-ligatures.test.mjs", "styles:icon-ligatures:check": "node tools/nx/check-icon-ligatures.mjs", "styles:icon-ligatures:validate": "pnpm run styles:icon-ligatures:test && pnpm run styles:icon-ligatures:check", + "a11y:icon-buttons:test": "node --test tools/nx/check-icon-button-names.test.mjs", + "a11y:icon-buttons:check": "node tools/nx/check-icon-button-names.mjs", + "a11y:icon-buttons:validate": "pnpm run a11y:icon-buttons:test && pnpm run a11y:icon-buttons:check", "e2e:task-graphs:test": "node --test tools/nx/check-e2e-task-graphs.test.mjs", "e2e:task-graphs:check": "node tools/nx/check-e2e-task-graphs.mjs", "e2e:task-graphs:validate": "pnpm run e2e:task-graphs:test && pnpm run e2e:task-graphs:check", diff --git a/tools/nx/check-icon-button-names.mjs b/tools/nx/check-icon-button-names.mjs new file mode 100644 index 000000000..387b49806 --- /dev/null +++ b/tools/nx/check-icon-button-names.mjs @@ -0,0 +1,247 @@ +import { execFileSync } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + parseTemplate, + TmplAstBoundText, + TmplAstElement, + TmplAstRecursiveVisitor, + TmplAstText, + tmplAstVisitAll, +} from '@angular/compiler'; + +import { inlineTemplates } from './inline-templates.mjs'; + +/** + * A `` whose only content is a `` has no accessible name: + * the icon font's ligature text is hidden from assistive technology, and a + * `matTooltip` only adds a description that appears on hover or focus. This + * guard requires such a button to carry `aria-label`, an `aria-label` + * binding or `aria-labelledby`. + */ + +/** Angular renderer sources, whose templates are standalone or inline. */ +const SCANNED_PATHSPECS = [ + 'apps/web/*.html', + 'apps/web/*.ts', + 'apps/remote-control-web/*.html', + 'apps/remote-control-web/*.ts', + 'libs/*.html', + 'libs/*.ts', +]; + +/** Attributes or bindings that give a button its accessible name. */ +const NAME_ATTRIBUTES = new Set(['aria-label', 'aria-labelledby', 'ariaLabel']); + +/** Visual-only content that never names a button. */ +const DECORATIVE_ELEMENTS = new Set(['mat-spinner', 'mat-progress-spinner']); + +function isScanned(file) { + return !/\.(spec|test)\.[cm]?[jt]s$/.test(file); +} + +function staticAttribute(element, name) { + return element.attributes.find((attribute) => attribute.name === name) + ?.value; +} + +function hasName(element) { + const named = element.attributes.some( + (attribute) => + NAME_ATTRIBUTES.has(attribute.name) && attribute.value.trim() !== '' + ); + return ( + named || element.inputs.some((input) => NAME_ATTRIBUTES.has(input.name)) + ); +} + +/** + * What a button's content contributes to its name: icons, text, or + * something this guard cannot see into (projected content, another + * component, an image), which is assumed to name it. Content under a static + * `aria-hidden="true"` is hidden from assistive technology, so it adds only + * its icons, never a name. + */ +class ContentVisitor extends TmplAstRecursiveVisitor { + icons = []; + text = false; + opaque = false; + + constructor(hidden = false) { + super(); + this.hidden = hidden; + } + + visitElement(element) { + if (element.name === 'mat-icon') { + this.icons.push(element); + } else if (this.hidden) { + super.visitElement(element); + } else if (staticAttribute(element, 'aria-hidden') === 'true') { + const hidden = new ContentVisitor(true); + tmplAstVisitAll(hidden, element.children); + this.icons.push(...hidden.icons); + } else if (DECORATIVE_ELEMENTS.has(element.name)) { + // A spinner beside the icon: still no name. + } else if ( + (element.name.includes('-') && element.name !== 'ng-container') || + element.name === 'img' + ) { + this.opaque = true; + } else { + super.visitElement(element); + } + } + + visitText(text) { + if (!this.hidden && text.value.trim() !== '') { + this.text = true; + } + } + + visitBoundText() { + this.text ||= !this.hidden; + } + + visitIcu() { + this.text ||= !this.hidden; + } + + visitContent() { + this.opaque ||= !this.hidden; + } + + visitComponent() { + this.opaque ||= !this.hidden; + } +} + +function iconOnly(element) { + const content = new ContentVisitor(); + tmplAstVisitAll(content, element.children); + return content.icons.length > 0 && !content.text && !content.opaque + ? content.icons + : null; +} + +function iconLabel(icon) { + const text = icon.children + .map((child) => + child instanceof TmplAstText + ? child.value + : child instanceof TmplAstBoundText + ? child.value.source + : '' + ) + .join('') + .trim(); + return text.replace(/\s+/g, ' ') || 'mat-icon'; +} + +class ButtonVisitor extends TmplAstRecursiveVisitor { + constructor(lineAt) { + super(); + this.lineAt = lineAt; + this.found = []; + } + + visitElement(element) { + if ( + element instanceof TmplAstElement && + element.name === 'button' && + staticAttribute(element, 'aria-hidden') !== 'true' && + !hasName(element) + ) { + const icons = iconOnly(element); + if (icons) { + this.found.push({ + line: this.lineAt(element.startSourceSpan.start.offset) + 1, + icon: icons.map(iconLabel).join(' / '), + }); + } + } + super.visitElement(element); + } +} + +/** + * Icon-only buttons without an accessible name in an Angular template. + * `lineAt` maps a template offset to its zero-based source line. + */ +export function namelessIconButtonsInTemplate( + template, + file, + lineAt = (offset) => template.slice(0, offset).split('\n').length - 1 +) { + const parsed = parseTemplate(template, file, { + preserveWhitespaces: true, + }); + if (parsed.errors?.length) { + throw new Error( + `${file}: cannot parse template: ${parsed.errors[0].msg}` + ); + } + const visitor = new ButtonVisitor(lineAt); + tmplAstVisitAll(visitor, parsed.nodes); + return visitor.found; +} + +/** Nameless icon-only buttons in one source file. */ +export function findNamelessIconButtons(file, source) { + const templates = file.endsWith('.html') + ? [{ template: source, lineAt: undefined }] + : inlineTemplates(source, file); + return templates.flatMap(({ template, lineAt }) => + namelessIconButtonsInTemplate(template, file, lineAt).map((entry) => ({ + file, + ...entry, + })) + ); +} + +/** Tracked renderer files the guard reads, at any depth. */ +export function listScannedFiles(rootDir) { + // No shell: `cmd.exe` treats single quotes as literal characters, so a + // POSIX-quoted pathspec reaches git intact on Windows and matches nothing. + return execFileSync('git', ['ls-files', ...SCANNED_PATHSPECS], { + cwd: rootDir, + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, + }) + .trim() + .split('\n') + .filter(Boolean) + .filter(isScanned); +} + +const isMain = + process.argv[1] && + path.resolve(process.argv[1]) === + path.resolve(fileURLToPath(import.meta.url)); + +if (isMain) { + const rootDir = process.cwd(); + const files = listScannedFiles(rootDir); + + const findings = []; + for (const file of files) { + const source = await readFile(path.join(rootDir, file), 'utf8'); + findings.push(...findNamelessIconButtons(file, source)); + } + + if (findings.length > 0) { + console.error( + 'Icon-only buttons without an accessible name. Add a translated aria-label (or [attr.aria-label] bound to the tooltip key) or aria-labelledby; a matTooltip alone does not name a button:' + ); + for (const { file, line, icon } of findings) { + console.error(`- ${file}:${line} with ${icon}`); + } + process.exitCode = 1; + } else { + console.log( + `Checked ${files.length} renderer files; every icon-only has an accessible name.` + ); + } +} diff --git a/tools/nx/check-icon-button-names.test.mjs b/tools/nx/check-icon-button-names.test.mjs new file mode 100644 index 000000000..466e8a932 --- /dev/null +++ b/tools/nx/check-icon-button-names.test.mjs @@ -0,0 +1,218 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; + +import { + findNamelessIconButtons, + listScannedFiles, +} from './check-icon-button-names.mjs'; + +const nameless = (file, source) => + findNamelessIconButtons(file, source).map(({ line, icon }) => ({ + line, + icon, + })); + +test('reports an icon-only button on the line its tag opens', () => { + const template = [ + '', + ' ', + ' close', + ' ', + '', + ].join('\n'); + + assert.deepEqual(nameless('a.html', template), [ + { line: 2, icon: 'close' }, + ]); +}); + +test('does not accept a tooltip or a title as the name', () => { + const template = [ + '', + ' close', + '', + '', + ' close', + '', + 'close', + ].join('\n'); + + assert.deepEqual(nameless('b.html', template), [ + { line: 1, icon: 'close' }, + { line: 4, icon: 'close' }, + { line: 7, icon: 'close' }, + ]); +}); + +test('accepts aria-label, its bindings and aria-labelledby', () => { + const template = [ + 'close', + 'close', + 'close', + 'close', + 'close', + 'close', + ].join('\n'); + + assert.deepEqual(nameless('c.html', template), []); +}); + +test('rejects an empty static aria-label', () => { + const template = + 'close'; + + assert.deepEqual(nameless('d.html', template), [ + { line: 1, icon: 'close' }, + ]); +}); + +test('accepts a button whose content includes text', () => { + const template = [ + 'replay Watch', + "replay{{ 'WATCH' | translate }}", + 'addAdd', + '@if (busy) { sync } @else { Refresh }', + ].join('\n'); + + assert.deepEqual(nameless('e.html', template), []); +}); + +test('ignores text and components hidden from assistive technology', () => { + const template = [ + 'closeClose', + '', + ' ', + ' star', + " {{ 'FAVORITE' | translate }}", + ' ', + ' ', + '', + 'Only hidden text', + 'addAdd', + ].join('\n'); + + assert.deepEqual(nameless('e2.html', template), [ + { line: 1, icon: 'close' }, + { line: 2, icon: 'star' }, + ]); +}); + +test('looks through control flow, wrappers and spinners', () => { + const template = [ + '', + ' @if (busy) {', + ' ', + ' } @else {', + ' sync', + ' }', + '', + '', + ' @switch (state) {', + ' @case (1) { play_arrow }', + ' @default { pause }', + ' }', + '', + 'star', + '{{ icon() }}', + ].join('\n'); + + assert.deepEqual(nameless('f.html', template), [ + { line: 1, icon: 'sync' }, + { line: 8, icon: 'play_arrow / pause' }, + { line: 14, icon: 'star' }, + { line: 15, icon: '{{ icon() }}' }, + ]); +}); + +test('leaves content it cannot see into to the content', () => { + const template = [ + '', + 'tv', + 'tv', + '', + 'tv', + 'home', + ].join('\n'); + + assert.deepEqual(nameless('g.html', template), []); +}); + +test('finds buttons nested in other elements and blocks', () => { + const template = [ + '@for (row of rows; track row.id) {', + ' ', + ' delete', + ' ', + '}', + '@defer { info }', + ].join('\n'); + + assert.deepEqual(nameless('h.html', template), [ + { line: 3, icon: 'delete' }, + { line: 6, icon: 'info' }, + ]); +}); + +test('checks inline component templates on their source lines', () => { + const source = [ + "import { Component } from '@angular/core';", + '@Component({', + " selector: 'app-a',", + ' template: `', + ' Release', + ' ', + ' chevron_left', + ' ', + ' `,', + '})', + 'export class A {}', + "@Component({ selector: 'b', template: 'x' })", + 'export class B {}', + '@Component({ selector: "c", template: `${icon}` })', + "const notAComponent = { template: 'x' };", + ].join('\n'); + + assert.deepEqual(nameless('a.component.ts', source), [ + { line: 6, icon: 'chevron_left' }, + { line: 12, icon: 'x' }, + ]); +}); + +test('selects tracked Angular app and lib sources, but not specs', async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), 'button-guard-')); + const git = (...args) => + execFileSync('git', args, { cwd: rootDir, stdio: 'pipe' }); + try { + git('init', '-q'); + const files = { + 'libs/ui/feature/src/lib/deep/panel.component.html': '', + 'libs/ui/feature/src/lib/deep/panel.component.ts': '', + 'libs/ui/feature/src/lib/deep/panel.component.spec.ts': '', + 'apps/web/src/app/app.component.html': '', + 'apps/remote-control-web/src/app/app.ts': '', + 'apps/website/src/page.html': '', + 'apps/electron-backend/src/main.ts': '', + 'tools/outside.ts': '', + }; + for (const [file, content] of Object.entries(files)) { + await mkdir(path.dirname(path.join(rootDir, file)), { + recursive: true, + }); + await writeFile(path.join(rootDir, file), content); + } + git('add', '.'); + + assert.deepEqual(listScannedFiles(rootDir).sort(), [ + 'apps/remote-control-web/src/app/app.ts', + 'apps/web/src/app/app.component.html', + 'libs/ui/feature/src/lib/deep/panel.component.html', + 'libs/ui/feature/src/lib/deep/panel.component.ts', + ]); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } +}); diff --git a/tools/nx/icon-ligature-sources.mjs b/tools/nx/icon-ligature-sources.mjs index 01f952fa5..2f4852a2e 100644 --- a/tools/nx/icon-ligature-sources.mjs +++ b/tools/nx/icon-ligature-sources.mjs @@ -14,6 +14,7 @@ import { tmplAstVisitAll, } from '@angular/compiler'; import ts from 'typescript'; +import { isInlineTemplate } from './inline-templates.mjs'; import { literalLineResolver } from './literal-source-lines.mjs'; /** @@ -300,25 +301,6 @@ function declarationResults(node) { return []; } -/** - * `template` of an `@Component({...})` decorator, quoted or backticked. A - * template with `${...}` substitutions is built at runtime and skipped. - */ -function isInlineTemplate(node) { - const call = node.parent?.parent; - return ( - ts.isPropertyAssignment(node) && - declarationName(node) === 'template' && - (ts.isNoSubstitutionTemplateLiteral(node.initializer) || - ts.isStringLiteral(node.initializer)) && - call !== undefined && - ts.isCallExpression(call) && - ts.isIdentifier(call.expression) && - call.expression.text === 'Component' && - ts.isDecorator(call.parent) - ); -} - /** * Icon names in a TypeScript source, plus those in its inline component * templates. diff --git a/tools/nx/inline-templates.mjs b/tools/nx/inline-templates.mjs new file mode 100644 index 000000000..87c33b501 --- /dev/null +++ b/tools/nx/inline-templates.mjs @@ -0,0 +1,47 @@ +import ts from 'typescript'; +import { literalLineResolver } from './literal-source-lines.mjs'; + +/** + * `template` of an `@Component({...})` decorator, quoted or backticked. A + * template with `${...}` substitutions is built at runtime and skipped. + */ +export function isInlineTemplate(node) { + const call = node.parent?.parent; + return ( + ts.isPropertyAssignment(node) && + (ts.isIdentifier(node.name) || ts.isStringLiteral(node.name)) && + node.name.text === 'template' && + (ts.isNoSubstitutionTemplateLiteral(node.initializer) || + ts.isStringLiteral(node.initializer)) && + call !== undefined && + ts.isCallExpression(call) && + ts.isIdentifier(call.expression) && + call.expression.text === 'Component' && + ts.isDecorator(call.parent) + ); +} + +/** + * The inline component templates of a TypeScript source, each with a + * mapping from a template offset to its zero-based source line. + */ +export function inlineTemplates(source, file) { + const sourceFile = ts.createSourceFile( + file, + source, + ts.ScriptTarget.Latest, + true + ); + const found = []; + const visit = (node) => { + if (isInlineTemplate(node)) { + found.push({ + template: node.initializer.text, + lineAt: literalLineResolver(node.initializer, sourceFile), + }); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return found; +}
{{ channel.url }}