From f24cac61bd45afc66b698e89292ac864be6a65c1 Mon Sep 17 00:00:00 2001 From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:08:45 +0200 Subject: [PATCH 1/3] Add cacheable Danish website translation --- README.md | 14 +- lib/cloudflarePurge.js | 5 + lib/i18n.js | 557 +++++++++++++++++++++++++++++++++++ public/css/styles.css | 17 ++ public/js/filter.js | 5 +- public/js/site.js | 15 +- routes/index.js | 98 +++--- server.js | 38 ++- test/cloudflarePurge.test.js | 5 + test/i18n.test.js | 47 +++ test/requestHandling.test.js | 38 +++ test/websiteViews.test.js | 32 +- views/about.pug | 134 +++++---- views/directory.pug | 66 +++-- views/error.pug | 8 +- views/form.pug | 274 ++++++++--------- views/layout.pug | 63 ++-- views/lookup.pug | 78 ++--- views/request-analysis.pug | 18 +- views/statistics.pug | 163 +++++----- 20 files changed, 1240 insertions(+), 435 deletions(-) create mode 100644 lib/i18n.js create mode 100644 test/i18n.test.js diff --git a/README.md b/README.md index de42361..ffda933 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,10 @@ static/ Static image assets | `/about` | How apps are analysed, what a report means, jurisdiction labels, project background and contact | | `/sitemap.xml`, `/robots.txt` | Crawler metadata | +The Danish versions of the public pages use the same paths under `/da/` (for +example `/da/` and `/da/analysis/:appId`). These paths are part of the website +cache key; analyser, API, health, sitemap and asset endpoints remain English-only. + The reverse lookup pages are served from an inverted index built by `lib/reverseIndex.js` and cached under `CACHE_DIR` alongside the aggregate site data. It is rebuilt whenever the set of stored analyses changes, so no extra @@ -154,10 +158,10 @@ Cloudflare dashboard setup — all on the free plan, on the zone *not* what you want: 1. **Security → WAF → Custom rules**, two rules with action *Managed Challenge*: - - `(http.request.uri.path eq "/search")` - - `(starts_with(http.request.uri.path, "/request/"))` + - `(http.request.uri.path eq "/search" or http.request.uri.path eq "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/da/search")` + - `(starts_with(http.request.uri.path, "/request/") or starts_with(http.request.uri.path, "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/da/request/"))` Optionally a third as a backstop: - - `(http.request.method eq "POST" and starts_with(http.request.uri.path, "/analysis/"))` + - `(http.request.method eq "POST" and (starts_with(http.request.uri.path, "/analysis/") or starts_with(http.request.uri.path, "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/da/analysis/")))` 2. **Security → Settings → Challenge Passage** sets how long one solved challenge lasts. @@ -165,8 +169,8 @@ Cloudflare dashboard setup — all on the free plan, on the zone The published pages are cached at the Cloudflare edge, which cannot see the origin invalidate its own cache, so uploading an analysis also purges the URLs -that analysis changed: the app's report, the homepage, `/statistics`, -`/trackers`, `/companies` and `/sitemap.xml`. The tracker and company pages it +that analysis changed in both languages: the app's report, the homepage, +`/statistics`, `/trackers`, `/companies` and `/sitemap.xml`. The tracker and company pages it also changes are left to expire on their own, because purge-by-URL takes at most 30 URLs per call. diff --git a/lib/cloudflarePurge.js b/lib/cloudflarePurge.js index a720e39..0c3423f 100644 --- a/lib/cloudflarePurge.js +++ b/lib/cloudflarePurge.js @@ -53,10 +53,15 @@ function analysisUrls(baseUrl, appId) { return [ `${base}/analysis/${encodeURIComponent(appId)}`, + `${base}/da/analysis/${encodeURIComponent(appId)}`, `${base}/`, + `${base}/da/`, `${base}/statistics`, + `${base}/da/statistics`, `${base}/trackers`, + `${base}/da/trackers`, `${base}/companies`, + `${base}/da/companies`, `${base}/sitemap.xml` ]; } diff --git a/lib/i18n.js b/lib/i18n.js new file mode 100644 index 0000000..409ad6c --- /dev/null +++ b/lib/i18n.js @@ -0,0 +1,557 @@ +'use strict'; + +// The English key is also the English fallback. Keeping the source phrase as +// the key makes untranslated strings obvious and keeps the default rendering +// byte-for-byte equivalent to the existing site. +const da = { + 'Skip to content': 'Spring til indhold', + 'TrackerControl websites': 'TrackerControl-websteder', + 'Project': 'Projekt', + 'Android app': 'Android-app', + 'iOS app reports': 'iOS-app-rapporter', + 'TrackerControl for iOS homepage': 'TrackerControl til iOS-forside', + 'Find out which trackers are embedded in iOS apps, which companies control them, and under which jurisdiction that tracking falls.': 'Find ud af, hvilke trackere der er indlejret i iOS-apps, hvilke virksomheder der kontrollerer dem, og hvilken jurisdiktion sporingen hører under.', + 'Main navigation': 'Hovednavigation', + 'Search': 'Søg', + 'Home': 'Forside', + 'Trackers': 'Trackere', + 'Companies': 'Virksomheder', + 'Statistics': 'Statistik', + 'About': 'Om', + 'Search apps': 'Søg efter apps', + 'Search apps placeholder': 'Søg efter apps', + 'Footer navigation': 'Sidefodsnavigation', + 'TrackerControl project': 'TrackerControl-projektet', + 'Contact': 'Kontakt', + 'Suggest a correction': 'Foreslå en rettelse', + 'Source on GitHub': 'Kildekode på GitHub', + 'Maintained by ': 'Vedligeholdes af ', + 'Language': 'Sprog', + 'English': 'English', + 'Dansk': 'Dansk', + 'App Privacy Checker': 'App-privatlivstjek', + 'Search {{total}} analysed iOS apps to see which trackers they embed, which companies control them, and under which jurisdiction that tracking falls.': 'Søg i {{total}} analyserede iOS-apps for at se, hvilke trackere de indeholder, hvilke virksomheder der kontrollerer dem, og hvilken jurisdiktion sporingen hører under.', + 'How this service analyses iOS apps for embedded trackers, what a report does and does not tell you, who runs it, and how to get in touch.': 'Hvordan denne tjeneste analyserer iOS-apps for indlejrede trackere, hvad en rapport fortæller dig og ikke fortæller dig, hvem der driver tjenesten, og hvordan du får kontakt.', + 'Tracking jurisdiction across {{total}} analysed iOS apps: the most prevalent trackers, the companies behind them, and how they break down by country and App Store category.': 'Sporingsjurisdiktion på tværs af {{total}} analyserede iOS-apps: de mest udbredte trackere, virksomhederne bag dem, og hvordan de fordeler sig efter land og App Store-kategori.', + 'Every tracker detected across {{total}} analysed iOS apps, with the company and country behind it.': 'Alle trackere, der er registreret på tværs af {{total}} analyserede iOS-apps, med virksomheden og landet bag dem.', + 'Every company whose tracking code was detected across {{total}} analysed iOS apps, ranked by how many apps they reach.': 'Alle virksomheder, hvis trackingkode er registreret på tværs af {{total}} analyserede iOS-apps, rangeret efter hvor mange apps de når.', + 'Tracker analysis of {{title}} for iOS.': 'Trackeranalyse af {{title}} til iOS.', + '{{name}} was detected in {{count}} of {{total}} analysed iOS apps ({{pct}}%){{suffix}}': '{{name}} blev registreret i {{count}} af {{total}} analyserede iOS-apps ({{pct}} %){{suffix}}', + '{{prefix}} detected in {{title}} for iOS{{suffix}}': '{{prefix}} registreret i {{title}} til iOS{{suffix}}', + 'No trackers were': 'Ingen trackere blev', + '{{count}} tracker{{plural}}': '{{count}} tracker{{plural}}', + '{{count}} tracker was': '{{count}} tracker blev', + '{{count}} trackers were': '{{count}} trackere blev', + '. Operated by {{company}} ({{country}}).': '. Drevet af {{company}} ({{country}}).', + + 'Independent app privacy research': 'Uafhængig forskning i app-privatliv', + 'A closer look at your iPhone apps.': 'Et nærmere kig på dine iPhone-apps.', + 'Find the tracking software built into iOS apps, and the companies behind it.': 'Find den sporingssoftware, der er indbygget i iOS-apps, og virksomhederne bag den.', + 'Find an app': 'Find en app', + 'Free apps from the UK App Store. New analysis requests require confirmation.': 'Gratis apps fra den britiske App Store. Nye analyseanmodninger kræver bekræftelse.', + 'Search an app name': 'Søg efter et appnavn', + 'Search reports': 'Søg i rapporter', + 'Version': 'Version', + 'Research snapshot': 'Forskningsoversigt', + 'iOS apps analysed': 'analyserede iOS-apps', + 'UK App Store sample': 'Udvalg fra den britiske App Store', + 'have US-only trackers': 'har kun trackere fra USA', + 'of {{total}} analysed': 'af {{total}} analyserede', + 'have no known trackers detected': 'har ingen kendte trackere', + 'Detection identifies embedded signatures, not safety.': 'Detektion identificerer indlejrede signaturer, ikke sikkerhed.', + 'Last analysis: {{date}}.': 'Seneste analyse: {{date}}.', + 'Not recorded': 'Ikke registreret', + 'These figures describe the analysed UK App Store sample, which is not random. New apps join the analysis queue only after a search and confirmation.': 'Tallene beskriver det analyserede udvalg fra den britiske App Store, som ikke er tilfældigt. Nye apps kommer først i analyse-køen efter en søgning og en bekræftelse.', + 'Overview': 'Overblik', + 'Jurisdiction breakdown': 'Fordeling efter jurisdiktion', + '{{count}} apps ({{pct}}%)': '{{count}} apps ({{pct}} %)', + '{{count}} apps': '{{count}} apps', + 'Published reports': 'Udgivne rapporter', + 'Apps with the most trackers': 'Apps med flest trackere', + '{{count}} third-party trackers': '{{count}} tredjepartstrackere', + 'analysed {{date}}': 'analyseret {{date}}', + 'date unavailable': 'dato ikke tilgængelig', + 'View detailed statistics': 'Se detaljeret statistik', + 'Look up a tracker': 'Slå en tracker op', + 'Please note:': 'Bemærk:', + 'reports may be out of date, incomplete or incorrect. They describe an analysed app version on the date shown, and automated analysis makes mistakes. ': 'rapporter kan være forældede, ufuldstændige eller forkerte. De beskriver en analyseret appversion på den viste dato, og automatiske analyser kan tage fejl. ', + 'Read the full disclaimer': 'Læs hele ansvarsfraskrivelsen', + + 'Search apps heading': 'Søg efter apps', + 'Search results': 'Søgeresultater', + 'Search': 'Søg', + 'No apps found for “{{term}}”.': 'Ingen apps fundet for “{{term}}”.', + '{{count}} result for “{{term}}”.': '{{count}} resultat for “{{term}}”.', + '{{count}} results for “{{term}}”.': '{{count}} resultater for “{{term}}”.', + 'Published analysis': 'Udgivet analyse', + 'Request a new analysis': 'Anmod om en ny analyse', + 'Paid apps cannot be analysed by this service.': 'Betalte apps kan ikke analyseres af denne tjeneste.', + 'No matching free app was returned by the UK App Store search. Check the spelling or try a shorter name.': 'Søgningen i den britiske App Store gav ingen matchende gratis app. Kontrollér stavningen, eller prøv et kortere navn.', + 'Free apps without a published report open a confirmation page first. A request is queued only after confirmation.': 'Gratis apps uden en udgivet rapport åbner først en bekræftelsesside. En anmodning kommer kun i kø efter bekræftelse.', + 'Search again': 'Søg igen', + 'Search app': 'Søg efter app', + 'Search apps title': 'Søg efter apps', + + 'App report': 'App-rapport', + 'Untitled app': 'Unavngiven app', + 'Unknown bundle ID': 'Ukendt bundle-id', + 'Analysis date · {{date}}': 'Analysedato · {{date}}', + 'Analysis date': 'Analysedato', + 'Awaiting analysis': 'Afventer analyse', + 'See on the App Store ↗': 'Se i App Store ↗', + 'Report metadata': 'Rapportmetadata', + 'Analysed version': 'Analyseret version', + 'Last known App Store version': 'Senest kendte App Store-version', + 'Queue-time version': 'Version ved køplacering', + 'Store metadata checked': 'Butiksmetadata kontrolleret', + 'Analysis unavailable': 'Analyse ikke tilgængelig', + 'Analysis failed.': 'Analysen mislykkedes.', + 'App not found on the App Store.': 'Appen blev ikke fundet i App Store.', + 'App not found on App Store.': 'Appen blev ikke fundet i App Store.', + 'This app is next in the analysis queue. Check back soon.': 'Denne app er den næste i analyse-køen. Kom snart tilbage.', + 'This app is queued for analysis. {{count}} other app will be analysed first.': 'Denne app står i kø til analyse. {{count}} anden app analyseres først.', + 'This app is queued for analysis. {{count}} other apps will be analysed first.': 'Denne app står i kø til analyse. {{count}} andre apps analyseres først.', + 'Analyser service is currently offline. Analysis will resume when the service is available.': 'Analysetjenesten er i øjeblikket offline. Analysen fortsætter, når tjenesten er tilgængelig.', + 'Report summary': 'Rapportoversigt', + 'Unavailable': 'Ikke tilgængelig', + 'third-party trackers detected': 'registrerede tredjepartstrackere', + 'third-party trackers': 'tredjepartstrackere', + 'System API signature': 'System-API-signatur', + 'System API signatures': 'System-API-signaturer', + 'shown separately': 'vist separat', + 'associated companies': 'tilknyttede virksomheder', + 'unresolved tracker': 'uafklaret tracker', + 'unresolved trackers': 'uafklarede trackere', + 'declared permissions': 'erklærede tilladelser', + 'Detection identifies embedded software signatures. It does not show whether data left the device.': 'Detektion identificerer indlejrede softwaresignaturer. Den viser ikke, om data forlod enheden.', + 'Embedded software': 'Indlejret software', + 'Tracking software': 'Sporingssoftware', + 'third-party tracker shown': 'vist tredjepartstracker', + 'third-party trackers shown': 'viste tredjepartstrackere', + 'Tracker data unavailable': 'Trackerdata ikke tilgængelige', + 'No tracker signatures were supplied for this analysis. This does not establish that the app is free of tracking.': 'Der blev ikke leveret trackersignaturer for denne analyse. Det viser ikke, at appen er fri for sporing.', + 'Filter trackers': 'Filtrér trackere', + 'Search by name, company or category': 'Søg efter navn, virksomhed eller kategori', + 'System API': 'System-API', + 'Apple platform API': 'Apple-platforms-API', + 'Unknown company': 'Ukendt virksomhed', + 'Unknown country': 'Ukendt land', + 'Not applicable': 'Ikke relevant', + 'Unknown category': 'Ukendt kategori', + 'This signature identifies an Apple system API. It is shown for transparency and excluded from the third-party tracker count and jurisdiction analysis.': 'Denne signatur identificerer et Apple-system-API. Den vises af gennemsigtighedshensyn og er udeladt af optællingen af tredjepartstrackere og jurisdiktionsanalysen.', + 'Embedded signature detected. Company jurisdiction describes the identified parent company’s home country; it does not establish an observed transfer.': 'Indlejret signatur registreret. Virksomhedens jurisdiktion beskriver den identificerede modervirksomheds hjemland; den dokumenterer ikke en observeret overførsel.', + 'View {{name}} in the tracker directory ↗': 'Se {{name}} i trackerkataloget ↗', + 'View {{name}} on Exodus Privacy ↗': 'Se {{name}} på Exodus Privacy ↗', + 'Company: {{name}} ↗': 'Virksomhed: {{name}} ↗', + 'Company: {{name}}': 'Virksomhed: {{name}}', + 'Country: {{country}}; region: {{region}}': 'Land: {{country}}; region: {{region}}', + 'Categories: {{categories}}': 'Kategorier: {{categories}}', + 'Unknown tracker category.': 'Ukendt trackerkategori.', + 'No tracker matches that filter. Try another name, company or category.': 'Ingen trackere matcher dette filter. Prøv et andet navn, en anden virksomhed eller kategori.', + 'A tracker is software embedded in an app. Detection does not prove that the software ran or that data was transmitted. ': 'En tracker er software, der er indlejret i en app. Detektion beviser ikke, at softwaren kørte, eller at data blev sendt. ', + 'Learn what a report tells you': 'Se, hvad en rapport fortæller dig', + 'Company footprint': 'Virksomheders tilhørsforhold', + 'Jurisdiction analysis': 'Jurisdiktionsanalyse', + 'Jurisdiction unavailable': 'Jurisdiktion ikke tilgængelig', + 'Company attribution details are unavailable.': 'Oplysninger om virksomhedstilknytning er ikke tilgængelige.', + 'Jurisdiction distribution': 'Fordeling efter jurisdiktion', + '{{count}} company': '{{count}} virksomhed', + '{{count}} companies': '{{count}} virksomheder', + 'Based on {{resolved}} unique resolved company; {{unresolved}} tracker remain unresolved. System API signatures are excluded.': 'Baseret på {{resolved}} entydigt afklaret virksomhed; {{unresolved}} tracker er stadig uafklaret. System-API-signaturer er udeladt.', + 'Based on {{resolved}} unique resolved companies; {{unresolved}} trackers remain unresolved. System API signatures are excluded.': 'Baseret på {{resolved}} entydigt afklarede virksomheder; {{unresolved}} trackere er stadig uafklarede. System-API-signaturer er udeladt.', + 'Company jurisdiction denominator is unavailable.': 'Nævneren for virksomheders jurisdiktion er ikke tilgængelig.', + 'Company jurisdiction is based on the identified parent company’s home country. It does not establish where data travelled or whether any transfer occurred.': 'Virksomhedens jurisdiktion er baseret på den identificerede modervirksomheds hjemland. Den fastslår ikke, hvor data rejste, eller om der fandt en overførsel sted.', + 'Jurisdiction data is unavailable.': 'Jurisdiktionsdata er ikke tilgængelige.', + 'Tracker signatures were not available for this analysis, so a count cannot be reported.': 'Trackersignaturer var ikke tilgængelige for denne analyse, så et antal kan ikke rapporteres.', + 'an unavailable number of': 'et ukendt antal', + 'App transparency': 'App-gennemsigtighed', + 'Tracking domains': 'Sporingsdomæner', + '{{count}} domain declared in the app’s privacy manifest.': '{{count}} domæne angivet i appens privatlivsmanifest.', + '{{count}} domains declared in the app’s privacy manifest.': '{{count}} domæner angivet i appens privatlivsmanifest.', + 'No tracking domains were supplied in the privacy manifest data.': 'Der blev ikke leveret sporingsdomæner i privatlivsmanifestets data.', + 'Tracking domain data is unavailable for this analysis.': 'Data om sporingsdomæner er ikke tilgængelige for denne analyse.', + 'Declared permissions': 'Erklærede tilladelser', + 'Camera': 'Kamera', + 'Calendars': 'Kalendere', + 'Location': 'Placering', + 'Location while using the app': 'Placering under brug af appen', + 'Location at all times': 'Placering hele tiden', + 'Photo library': 'Fotobibliotek', + 'Tracking': 'Sporing', + 'Bluetooth': 'Bluetooth', + 'Microphone': 'Mikrofon', + 'Contacts': 'Kontakter', + '{{count}} permission declared by the app.': '{{count}} tilladelse angivet af appen.', + '{{count}} permissions declared by the app.': '{{count}} tilladelser angivet af appen.', + 'No declared permissions were supplied for this analysis.': 'Der blev ikke leveret erklærede tilladelser for denne analyse.', + 'Permission data is unavailable for this analysis.': 'Data om tilladelser er ikke tilgængelige for denne analyse.', + 'A declared permission describes what an app can ask for; it does not mean access was granted. ': 'En erklæret tilladelse beskriver, hvad en app kan anmode om; det betyder ikke, at adgang blev givet. ', + 'Learn more about permissions ↗': 'Læs mere om tilladelser ↗', + 'Store details are retained from the last recorded metadata check and may be stale. They do not verify the current app version or current behaviour.': 'Butiksoplysninger gemmes fra den senest registrerede metadatakontrol og kan være forældede. De bekræfter ikke den aktuelle appversion eller aktuelle adfærd.', + + 'About the project': 'Om projektet', + 'About TrackerControl for iOS': 'Om TrackerControl til iOS', + 'This service analyses iOS applications in order to list the embedded trackers and permissions. A tracker is a piece of software that collects data about you or your app usage behaviour.': 'Denne tjeneste analyserer iOS-apps for at opliste indlejrede trackere og tilladelser. En tracker er software, der indsamler data om dig eller din brug af apps.', + 'Method': 'Metode', + 'How an app is analysed': 'Sådan analyseres en app', + 'Download': 'Hent', + 'We fetch the app from the UK App Store, like anyone else would.': 'Vi henter appen fra den britiske App Store, som alle andre ville gøre.', + 'Run': 'Kør', + 'We install it on a real iPhone and start it up.': 'Vi installerer den på en rigtig iPhone og starter den.', + 'Look inside': 'Se ind i den', + 'We list the tracking software the app comes with.': 'Vi oplister den sporingssoftware, der følger med appen.', + 'Trace it back': 'Spor den tilbage', + 'Each tracker is matched to the company behind it, and its home country.': 'Hver tracker knyttes til virksomheden bag den og virksomhedens hjemland.', + 'We analyse free apps from the UK App Store, and an app joins the queue only after somebody searches for it here and confirms the request. This is not a random sample of the App Store. It leans heavily towards popular apps: a percentage on this site describes the apps analysed so far, not iOS apps in general.': 'Vi analyserer gratis apps fra den britiske App Store, og en app kommer først i køen, når nogen søger efter den her og bekræfter anmodningen. Dette er ikke et tilfældigt udvalg fra App Store. Det er kraftigt præget af populære apps: En procentdel på dette websted beskriver de apps, der hidtil er analyseret, ikke iOS-apps generelt.', + 'Scope': 'Omfang', + 'What a report tells you': 'Hvad en rapport fortæller dig', + 'What we can see': 'Hvad vi kan se', + 'What we cannot see': 'Hvad vi ikke kan se', + 'Which tracking software is built into an app.': 'Hvilken sporingssoftware der er indbygget i en app.', + 'Which companies it belongs to, and where they are based.': 'Hvilke virksomheder den tilhører, og hvor de har hjemme.', + 'Which permissions the app is able to ask you for.': 'Hvilke tilladelser appen kan anmode dig om.', + 'Whether a tracker ever ran, or whether anything left your phone.': 'Om en tracker nogensinde kørte, eller om noget forlod din telefon.', + 'Tracking we have no way of seeing, such as an app sending data straight to its developer’s own servers. An empty report is not a promise.': 'Sporing, som vi ikke kan se, for eksempel når en app sender data direkte til udviklerens egne servere. En tom rapport er ikke en garanti.', + 'What a company does with your data once it has it.': 'Hvad en virksomhed gør med dine data, når den har dem.', + 'Who controls the tracking': 'Hvem kontrollerer sporingen', + 'Each tracker is traced to the company that runs it, up to the parent company at the top, and then to that company’s home country. What counts is whose laws a company answers to, not where its servers are: a US company can be compelled to hand data over under the CLOUD Act wherever that data is stored. Every app gets one label:': 'Hver tracker spores til virksomheden, der driver den, videre til den øverste modervirksomhed og derefter til virksomhedens hjemland. Det afgørende er, hvis love en virksomhed er underlagt, ikke hvor dens servere står: En amerikansk virksomhed kan tvinges til at udlevere data efter CLOUD Act, uanset hvor dataene er gemt. Hver app får én etiket:', + 'Jurisdiction classifications': 'Jurisdiktionsklassifikationer', + 'No third-party tracker was found.': 'Ingen tredjepartstracker blev fundet.', + 'Every company we identified is based in the US.': 'Alle identificerede virksomheder har hjemme i USA.', + 'Every company we identified is based in the EU, EEA or UK, and subject to GDPR. This is rare.': 'Alle identificerede virksomheder har hjemme i EU, EØS eller Storbritannien og er underlagt GDPR. Det er sjældent.', + 'The companies include both US and Chinese ones.': 'Virksomhederne omfatter både amerikanske og kinesiske virksomheder.', + 'The companies include US ones alongside others, but none from China.': 'Virksomhederne omfatter amerikanske virksomheder og andre, men ingen fra Kina.', + 'The companies span several parts of the world, but none is in the US.': 'Virksomhederne findes i flere dele af verden, men ingen er i USA.', + 'Trackers were found, but we could not tell which companies run them.': 'Der blev fundet trackere, men vi kunne ikke afgøre, hvilke virksomheder der driver dem.', + 'Limits': 'Begrænsninger', + 'Where this stops': 'Her stopper analysen', + 'We can only find trackers we already know about, so new or renamed ones are missed.': 'Vi kan kun finde trackere, vi allerede kender, så nye eller omdøbte trackere overses.', + 'A tracker occasionally ends up attributed to the wrong company, so it is worth checking an individual result before leaning on it.': 'En tracker bliver undertiden knyttet til den forkerte virksomhed, så det er værd at kontrollere et enkelt resultat, før du bygger videre på det.', + 'A report describes the version of the app we analysed, on the date shown. That may no longer be the version in the App Store.': 'En rapport beskriver den appversion, vi analyserede, på den viste dato. Det er muligvis ikke længere den version, der findes i App Store.', + 'If a result looks wrong, tell us — corrections make this better for everyone.': 'Hvis et resultat ser forkert ud, så fortæl os det — rettelser gør dette bedre for alle.', + 'Disclaimer': 'Ansvarsfraskrivelse', + 'Use this information at your own risk': 'Brug disse oplysninger på eget ansvar', + 'The information on this site may be out of date, incomplete or simply incorrect. Apps change with every release, tracker databases lag behind the market, and automated analysis makes mistakes. Nothing here is verified by hand.': 'Oplysningerne på dette websted kan være forældede, ufuldstændige eller ganske enkelt forkerte. Apps ændrer sig med hver udgivelse, trackerdatabaser sakker bagud i forhold til markedet, og automatiske analyser kan tage fejl. Intet her er manuelt verificeret.', + 'Everything is provided “as is”, without any warranty of accuracy, completeness or fitness for a particular purpose. You use this information at your own risk. Please check an individual result yourself before relying on it for anything that matters, and do not treat a report as a statement of fact about an app or the company behind it.': 'Alt leveres “som det er” uden garanti for nøjagtighed, fuldstændighed eller egnethed til et bestemt formål. Du bruger disse oplysninger på eget ansvar. Kontrollér selv et enkelt resultat, før du stoler på det i noget vigtigt, og betragt ikke en rapport som en faktuel erklæring om en app eller virksomheden bag den.', + 'Research provenance': 'Forskningsoprindelse', + 'Where this comes from': 'Hvor dette kommer fra', + 'This project was motivated by ': 'Dette projekt er inspireret af ', + ', which is a similar project for Android apps. Some of the underlying code as well as the design of this website is based on this project.': ', som er et lignende projekt for Android-apps. Noget af den underliggende kode og designet af dette websted er baseret på dette projekt.', + 'The underlying analysis technique for iOS apps was developed in the PhD research of Konrad Kollnig at the Department of Computer Science of the University of Oxford. This research was published in a range of academic papers and is available at ': 'Den underliggende analyseteknik til iOS-apps blev udviklet i Konrad Kollnigs ph.d.-forskning ved Department of Computer Science på University of Oxford. Forskningen blev udgivet i en række akademiske artikler og findes på ', + '. This research was, in turn, based on ': '. Denne forskning byggede igen på ', + ' by the Oxford research group led by Sir Nigel Shadbolt and led to ': ' af Oxford-forskningsgruppen ledet af Sir Nigel Shadbolt og førte til ', + 'A key aim of this research and this tool is to enable anyone to analyse privacy in iOS apps without relying on jailbreaks or the circumvention of copyright protections. These were issues that held back iOS research in the past.': 'Et centralt mål med denne forskning og dette værktøj er at gøre det muligt for alle at analysere privatliv i iOS-apps uden jailbreaks eller omgåelse af ophavsretsbeskyttelse. Det var forhold, der tidligere holdt iOS-forskning tilbage.', + 'Unfortunately, Apple currently encrypts every app downloaded from the App Store with its FairPlay DRM. The circumvention of this DRM might be illegal in some jurisdictions and is thus not done by this tool. This is one of the key innovations behind this work, and has been published in the highly renowned and selective ': 'Apple krypterer desværre i øjeblikket alle apps, der downloades fra App Store, med sin FairPlay-DRM. Omgåelse af denne DRM kan være ulovlig i nogle jurisdiktioner og foretages derfor ikke af dette værktøj. Dette er en af de centrale nyskabelser i arbejdet, som er udgivet i det anerkendte og selektive ', + ' in 2022.': ' i 2022.', + 'This website operates a fair use policy and restricts disproportionate access. Scraping is not permitted.': 'Dette websted følger en fair use-politik og begrænser uforholdsmæssig adgang. Scraping er ikke tilladt.', + 'We never collect personal data. It’s yours. This website is operated by Konrad Kollnig, Assistant Professor at Maastricht University.': 'Vi indsamler aldrig personoplysninger. De er dine. Dette websted drives af Konrad Kollnig, adjunkt ved Maastricht University.', + 'You can report issues and ask questions at ': 'Du kan rapportere problemer og stille spørgsmål på ', + ', or read the code at ': ', eller læse koden på ', + + 'Tracker directory': 'Trackerkatalog', + 'Company directory': 'Virksomhedskatalog', + 'Every tracker signature detected across {{total}} analysed iOS apps. Select one to see the apps in which it was found.': 'Alle trackersignaturer, der er registreret på tværs af {{total}} analyserede iOS-apps. Vælg en for at se de apps, hvor den blev fundet.', + 'Every company whose tracking code was detected across {{total}} analysed iOS apps, ranked by reach.': 'Alle virksomheder, hvis trackingkode er registreret på tværs af {{total}} analyserede iOS-apps, rangeret efter rækkevidde.', + '{{tracked}} of {{total}} analysed apps contain at least one detected third-party tracker.': '{{tracked}} af {{total}} analyserede apps indeholder mindst én registreret tredjepartstracker.', + '{{tracked}} of {{total}} analysed apps contain at least one detected third-party tracker. Last analysis: {{date}}.': '{{tracked}} af {{total}} analyserede apps indeholder mindst én registreret tredjepartstracker. Seneste analyse: {{date}}.', + 'Filter trackers or companies': 'Filtrér trackere eller virksomheder', + 'Filter companies': 'Filtrér virksomheder', + '{{count}} entry': '{{count}} post', + '{{count}} entries': '{{count}} poster', + 'Tracker directory table': 'Trackerkatalogtabel', + 'Company directory table': 'Virksomhedskatalogtabel', + 'Rank': 'Rang', + 'Tracker': 'Tracker', + 'Company': 'Virksomhed', + 'Trackers': 'Trackere', + 'Country': 'Land', + 'Region': 'Region', + 'Apps': 'Apps', + 'Category': 'Kategori', + 'Classification': 'Klassifikation', + 'Share': 'Andel', + 'Unattributed': 'Ikke tilskrevet', + 'No entries match that filter.': 'Ingen poster matcher dette filter.', + 'See how these numbers are produced': 'Se, hvordan tallene er beregnet', + 'A tracker is counted once per app, regardless of how many times its code appears. “System API” marks Apple platform APIs reported by the analyser and excluded from the third-party tracker count. “Unattributed” marks signatures that could not be mapped to a company.': 'En tracker tælles én gang pr. app, uanset hvor mange gange dens kode forekommer. “System-API” markerer Apple-platforms-API’er, som analysatoren har rapporteret, og som er udeladt af tredjepartstællingen. “Ikke tilskrevet” markerer signaturer, der ikke kunne knyttes til en virksomhed.', + 'Companies are counted at the level of the ultimate parent, so subsidiaries are grouped under their owner. An app is counted once per company even when several of that company’s trackers are present.': 'Virksomheder tælles på den øverste modervirksomheds niveau, så datterselskaber grupperes under deres ejer. En app tælles én gang pr. virksomhed, selv når flere af virksomhedens trackere er til stede.', + 'Directory data is not available right now. Please try again later.': 'Katalogdata er ikke tilgængelige lige nu. Prøv igen senere.', + + 'Lookup': 'Opslag', + 'Unknown region': 'Ukendt region', + 'Unknown': 'Ukendt', + 'Unknown tracker': 'Ukendt tracker', + 'Unresolved': 'Uafklaret', + 'Operated by ': 'Drevet af ', + 'Lookup summary': 'Opslagsoversigt', + 'app contains it': 'app indeholder den', + 'apps contain it': 'apps indeholder den', + 'of analysed apps': 'af analyserede apps', + 'of {{total}} apps analysed': 'af {{total}} analyserede apps', + 'This signature identifies an Apple system API rather than a third-party tracker. It is reported for completeness and excluded from jurisdiction analysis.': 'Denne signatur identificerer et Apple-system-API og ikke en tredjepartstracker. Den rapporteres for fuldstændighedens skyld og er udeladt af jurisdiktionsanalysen.', + 'This tracker could not be matched to a company in the tracker database, so no jurisdiction is shown for it.': 'Denne tracker kunne ikke matches med en virksomhed i trackerdatabasen, så der vises ingen jurisdiktion for den.', + 'Trackers operated by {{name}}': 'Trackere drevet af {{name}}', + 'Numbers in brackets are the apps each tracker was detected in.': 'Tallene i parentes er antallet af apps, hvor hver tracker blev registreret.', + 'Tracker profile on Exodus Privacy ↗': 'Trackerprofil på Exodus Privacy ↗', + 'Showing {{from}}–{{to}} of {{total}} apps, most reviewed first.': 'Viser {{from}}–{{to}} af {{total}} apps, først dem med flest anmeldelser.', + 'No apps to show.': 'Ingen apps at vise.', + 'App list pages': 'Sider med appliste', + 'Previous': 'Forrige', + 'Page {{page}} of {{totalPages}}': 'Side {{page}} af {{totalPages}}', + 'Next': 'Næste', + 'Detection means the tracker’s software is built into the app. It does not by itself prove that data was transmitted while the app was in use. Percentages are shares of the {{total}} apps analysed so far, which are not a random sample of the App Store — see ': 'Detektion betyder, at trackerens software er indbygget i appen. Det beviser ikke i sig selv, at data blev sendt, mens appen var i brug. Procenterne er andele af de {{total}} apps, der hidtil er analyseret, og som ikke er et tilfældigt udvalg fra App Store — se ', + ' before quoting these figures.': ' før du citerer tallene.', + 'before quoting these figures.': 'før du citerer tallene.', + 'third-party trackers': 'tredjepartstrackere', + 'previous work': 'tidligere arbejde', + 'Data as of {{date}}.': 'Data pr. {{date}}.', + 'All trackers': 'Alle trackere', + 'All companies': 'Alle virksomheder', + + 'Analysis queue': 'Analyse-kø', + 'Request app analysis': 'Anmod om appanalyse', + 'This app has not been added to the analysis queue yet. Nothing is queued before you confirm below.': 'Denne app er endnu ikke føjet til analyse-køen. Intet sættes i kø, før du bekræfter nedenfor.', + 'Free apps from the UK App Store can be analysed on a real iPhone. A confirmed request is added to the queue and may take time to complete.': 'Gratis apps fra den britiske App Store kan analyseres på en rigtig iPhone. En bekræftet anmodning føjes til køen, og det kan tage tid at gennemføre den.', + 'Bundle ID': 'Bundle-id', + 'Confirm and request analysis': 'Bekræft og anmod om analyse', + 'Back to search': 'Tilbage til søgning', + + 'Aggregate results': 'Samlede resultater', + 'Detailed statistics': 'Detaljeret statistik', + 'In-depth analysis of tracking jurisdiction across {{total}} analysed iOS apps. These figures describe the analysed sample, which is not random and is not all iOS apps.': 'Dybdegående analyse af sporingsjurisdiktion på tværs af {{total}} analyserede iOS-apps. Tallene beskriver det analyserede udvalg, som ikke er tilfældigt og ikke omfatter alle iOS-apps.', + 'Back to overview': 'Tilbage til overblik', + 'Most prevalent trackers': 'Mest udbredte trackere', + 'Most frequently embedded third-party tracker libraries, with parent company and jurisdiction.': 'De hyppigst indlejrede tredjepartsbiblioteker til tracking med modervirksomhed og jurisdiktion.', + 'Most prevalent trackers': 'Mest udbredte trackere', + 'Parent company': 'Modervirksomhed', + 'Most prevalent tracking companies': 'Mest udbredte trackingvirksomheder', + 'Unique companies by ultimate parent, ranked by the number of analysed apps in which they appear.': 'Entydige virksomheder efter øverste modervirksomhed, rangeret efter antallet af analyserede apps, de optræder i.', + 'No tracker prevalence data is available yet.': 'Der er endnu ingen data om trackerudbredelse.', + 'No company prevalence data is available yet.': 'Der er endnu ingen data om virksomhedsudbredelse.', + 'App categories': 'Appkategorier', + 'Jurisdiction by App Store category': 'Jurisdiktion efter App Store-kategori', + 'US-only and European-only tracking percentages by category. Categories with fewer than three apps are excluded from this comparison.': 'Procenter for sporing udelukkende fra USA og udelukkende fra Europa efter kategori. Kategorier med færre end tre apps er udeladt af sammenligningen.', + 'Category comparisons are unavailable.': 'Kategorisammenligninger er ikke tilgængelige.', + 'US-only': 'Kun USA', + 'European-only': 'Kun Europa', + 'No known trackers detected': 'Ingen kendte trackere registreret', + 'Unresolved trackers': 'Uafklarede trackere', + 'US-only tracking': 'Sporing udelukkende fra USA', + 'European-only tracking': 'Sporing udelukkende fra Europa', + 'US and Chinese tracking': 'Sporing fra USA og Kina', + 'US and other tracking': 'Sporing fra USA og andre jurisdiktioner', + 'Mixed tracking without US': 'Blandet sporing uden USA', + 'US & other jurisdictions': 'USA og andre jurisdiktioner', + 'US & China': 'USA og Kina', + 'Non-US jurisdictions only': 'Kun jurisdiktioner uden for USA', + 'US': 'USA', + 'European': 'Europæisk', + 'CN': 'Kina', + 'Other': 'Andre', + 'European alternatives': 'Europæiske alternativer', + 'For each core tracking function, the dominant US provider and whether a viable European alternative appears in the reference data.': 'For hver central trackingfunktion vises den dominerende amerikanske udbyder, og om der findes et levedygtigt europæisk alternativ i referencedataene.', + 'Function': 'Funktion', + 'Dominant provider (US)': 'Dominerende udbyder (USA)', + 'European alternative': 'Europæisk alternativ', + 'Availability': 'Tilgængelighed', + 'Integrated analytics': 'Integreret analyse', + 'Crash reporting': 'Fejlrapportering', + 'Mobile advertising': 'Mobilannoncering', + 'Attribution': 'Attribuering', + 'Authentication': 'Godkendelse', + 'Maps SDK': 'Kort-SDK', + 'None at scale': 'Ingen i større skala', + 'None (HockeyApp was acquired by Microsoft)': 'Ingen (HockeyApp blev opkøbt af Microsoft)', + 'Criteo (FR, limited)': 'Criteo (Frankrig, begrænset)', + 'Adjust (DE, acquired by AppLovin US)': 'Adjust (Tyskland, opkøbt af amerikanske AppLovin)', + 'None recorded': 'Ingen registreret', + 'Limited': 'Begrænset', + 'European alternative data is unavailable.': 'Data om europæiske alternativer er ikke tilgængelige.', + 'Browse all trackers': 'Gennemse alle trackere', + 'Browse all companies': 'Gennemse alle virksomheder', + 'Based on the Xray Tracker Database': 'Baseret på Xray Tracker Database', + 'tracker companies': 'tracker-virksomheder', + 'How these numbers are produced': 'Sådan er tallene beregnet', + + 'Something went wrong': 'Noget gik galt', + 'The page could not be loaded. Please try again later.': 'Siden kunne ikke indlæses. Prøv igen senere.', + 'TrackerControl for iOS': 'TrackerControl til iOS', + 'for iOS': 'til iOS', + 'United States': 'USA', + 'United Kingdom': 'Storbritannien', + 'Germany': 'Tyskland', + 'France': 'Frankrig', + 'Netherlands': 'Nederlandene', + 'Ireland': 'Irland', + 'Spain': 'Spanien', + 'Italy': 'Italien', + 'Poland': 'Polen', + 'Austria': 'Østrig', + 'Belgium': 'Belgien', + 'Bulgaria': 'Bulgarien', + 'Croatia': 'Kroatien', + 'Cyprus': 'Cypern', + 'Czech Republic': 'Tjekkiet', + 'Denmark': 'Danmark', + 'Estonia': 'Estland', + 'Finland': 'Finland', + 'Greece': 'Grækenland', + 'Hungary': 'Ungarn', + 'Latvia': 'Letland', + 'Lithuania': 'Litauen', + 'Luxembourg': 'Luxembourg', + 'Malta': 'Malta', + 'Portugal': 'Portugal', + 'Romania': 'Rumænien', + 'Slovakia': 'Slovakiet', + 'Slovenia': 'Slovenien', + 'Sweden': 'Sverige', + 'Norway': 'Norge', + 'Iceland': 'Island', + 'Liechtenstein': 'Liechtenstein', + 'China': 'Kina', + 'Israel': 'Israel', + 'India': 'Indien', + 'Japan': 'Japan', + 'South Korea': 'Sydkorea', + 'New Zealand': 'New Zealand', + 'Russia': 'Rusland', + 'Brazil': 'Brasilien', + 'Australia': 'Australien', + 'Canada': 'Canada', + 'Singapore': 'Singapore', + 'Switzerland': 'Schweiz', + 'Taiwan': 'Taiwan', + 'Hong Kong': 'Hongkong', + 'Ukraine': 'Ukraine', + 'Vietnam': 'Vietnam', + 'Costa Rica': 'Costa Rica', + 'Guernsey': 'Guernsey', + 'Puerto Rico': 'Puerto Rico', + 'Back to home': 'Tilbage til forsiden', + 'Page not found': 'Siden blev ikke fundet', + 'Request unavailable': 'Anmodning ikke tilgængelig', + 'Too many requests': 'For mange anmodninger', + 'Too many requests, please try again later.': 'For mange anmodninger. Prøv igen senere.', + 'Please provide a valid App Store bundle ID.': 'Angiv et gyldigt App Store-bundle-id.', + 'Please enter a search term': 'Angiv et søgeord', + 'Error while searching. Try again later.': 'Der opstod en fejl under søgningen. Prøv igen senere.', + 'Downloading of app information failed. Please try again later.': 'Download af appoplysninger mislykkedes. Prøv igen senere.', + 'Error storing app information. Please try again later.': 'Der opstod en fejl ved lagring af appoplysninger. Prøv igen senere.', + 'Can’t analyse non-free apps.': 'Betalte apps kan ikke analyseres.', + "Can't analyse non-free apps.": 'Betalte apps kan ikke analyseres.', + '{{pct}}% US-only': '{{pct}} % kun USA', + '{{pct}}% European-only': '{{pct}} % kun Europa', + 'Error adding app. Please try again later.': 'Der opstod en fejl ved tilføjelse af appen. Prøv igen senere.', + 'Lookup data is temporarily unavailable. Please try again later.': 'Opslagsdata er midlertidigt utilgængelige. Prøv igen senere.', + 'Unknown tracker. See /trackers for the full list.': 'Ukendt tracker. Se /trackers for den fulde liste.', + 'Unknown company. See /companies for the full list.': 'Ukendt virksomhed. Se /companies for den fulde liste.', + 'This page could not be found. Search for an app or browse the tracker directory.': 'Siden blev ikke fundet. Søg efter en app, eller gennemse trackerkataloget.', + 'Internal server error.': 'Intern serverfejl.', +}; + +function normalizeLocale(value) { + return value === 'da' ? 'da' : 'en'; +} + +function interpolate(template, values = {}) { + return template.replace(/\{\{([\w]+)\}\}/g, (_, key) => + Object.prototype.hasOwnProperty.call(values, key) ? String(values[key]) : `{{${key}}}` + ); +} + +function createTranslator(locale = 'en') { + const selected = normalizeLocale(locale); + return (key, values) => interpolate( + selected === 'da' && Object.prototype.hasOwnProperty.call(da, key) ? da[key] : key, + values + ); +} + +function formatDate(value, locale = 'en', options = { day: 'numeric', month: 'short', year: 'numeric' }) { + const date = value instanceof Date ? value : new Date(value); + if (!value || Number.isNaN(date.getTime())) return null; + return date.toLocaleDateString(normalizeLocale(locale) === 'da' ? 'da-DK' : 'en-GB', options); +} + +function formatDateTime(value, locale = 'en') { + const date = value instanceof Date ? value : new Date(value); + if (!value || Number.isNaN(date.getTime())) return null; + return date.toLocaleString(normalizeLocale(locale) === 'da' ? 'da-DK' : 'en-GB'); +} + +function localeForRequest(req) { + if (req && (req.locale === 'da' || req.locale === 'en')) + return { locale: req.locale, fromQuery: false }; + const original = req && (req.originalUrl || req.url || ''); + const path = new URL(original, 'http://localhost').pathname; + return { locale: path === '/da' || path.startsWith('/da/') ? 'da' : 'en', fromQuery: false }; +} + +function publicPath(pathname) { + if (pathname === '/da') return '/'; + if (pathname.startsWith('/da/')) return pathname.slice(3) || '/'; + return pathname || '/'; +} + +function isPublicLocalizedPath(pathname, method = 'GET') { + if (!pathname || !pathname.startsWith('/da')) return false; + const path = pathname.replace(/\/+$/, '') || '/da'; + const publicPathname = path === '/da' ? '/' : path.slice(3); + if (!['GET', 'HEAD', 'POST'].includes(method)) return false; + if (method === 'POST') return /^\/analysis\/[^/]+$/.test(publicPathname); + return publicPathname === '/' + || ['/about', '/statistics', '/trackers', '/companies', '/search'].includes(publicPathname) + || /^\/(?:analysis|request|tracker|company)\/[^/]+$/.test(publicPathname); +} + +function stripLocalePrefix(url) { + const parsed = new URL(url || '/', 'http://localhost'); + parsed.pathname = publicPath(parsed.pathname); + return parsed.pathname + parsed.search + parsed.hash; +} + +function localUrl(path, locale = 'en', currentPath = '/') { + const raw = path || currentPath; + if (/^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith('//')) return raw; + const parsed = new URL(raw.startsWith('?') || raw.startsWith('#') + ? `${currentPath || '/'}${raw}` + : raw, 'http://localhost'); + let pathname = publicPath(parsed.pathname); + if (normalizeLocale(locale) === 'da') { + pathname = pathname === '/' ? '/da/' : `/da${pathname}`; + } + return pathname + parsed.search + parsed.hash; +} + +function languageUrl(originalUrl, locale) { + const parsed = new URL(originalUrl || '/', 'http://localhost'); + parsed.searchParams.delete('lang'); + return localUrl( + publicPath(parsed.pathname) + parsed.search + parsed.hash, + locale, + publicPath(parsed.pathname) + ); +} + +function canonicalUrl(base, path, locale, extraQuery = {}) { + const parsed = new URL(path || '/', 'http://localhost'); + const params = new URLSearchParams(parsed.search); + for (const [key, value] of Object.entries(extraQuery)) params.set(key, value); + const pathname = localUrl(parsed.pathname, locale); + const query = params.toString(); + return base.replace(/\/+$/, '') + pathname + (query ? `?${query}` : ''); +} + +module.exports = { + normalizeLocale, + createTranslator, + formatDate, + formatDateTime, + localeForRequest, + isPublicLocalizedPath, + stripLocalePrefix, + localUrl, + languageUrl, + canonicalUrl, +}; diff --git a/public/css/styles.css b/public/css/styles.css index 5579136..196012c 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -101,6 +101,23 @@ button { cursor: pointer; } .site-nav a:hover, .site-footer a:hover { color: var(--brand); text-decoration: underline; } .site-nav a.is-current { color: var(--brand-dark); font-weight: 750; text-decoration: underline; } +.language-toggle { + align-items: center; + display: flex; + flex-shrink: 0; + gap: .15rem; + font-size: .78rem; +} +.language-toggle a { + border: 1px solid transparent; + border-radius: var(--radius); + color: var(--muted); + padding: .25rem .35rem; + text-decoration: none; +} +.language-toggle a:hover { color: var(--brand); text-decoration: underline; } +.language-toggle a.is-current { border-color: var(--border-strong); color: var(--brand-dark); font-weight: 750; } + .nav-search { align-items: center; display: flex; diff --git a/public/js/filter.js b/public/js/filter.js index a8e22b5..23c4dfc 100644 --- a/public/js/filter.js +++ b/public/js/filter.js @@ -11,6 +11,7 @@ var rows = Array.prototype.slice.call(table.querySelectorAll('tbody tr')); var empty = document.getElementById('directory-empty'); var count = document.getElementById('directory-count'); + var danish = document.documentElement.lang.toLowerCase().indexOf('da') === 0; function apply() { var term = input.value.trim().toLowerCase(); @@ -24,7 +25,9 @@ }); if (empty) empty.hidden = visible > 0; - if (count) count.textContent = visible + ' entr' + (visible === 1 ? 'y' : 'ies'); + if (count) count.textContent = danish + ? visible + ' ' + (visible === 1 ? 'post' : 'poster') + : visible + ' entr' + (visible === 1 ? 'y' : 'ies'); } input.addEventListener('input', apply); diff --git a/public/js/site.js b/public/js/site.js index 0cbb3cc..a6eaaa2 100644 --- a/public/js/site.js +++ b/public/js/site.js @@ -7,6 +7,7 @@ var rows = Array.prototype.slice.call(document.querySelectorAll('.tracker-list .tracker-row')); var count = document.getElementById('tracker-result-count'); var empty = document.getElementById('tracker-no-results'); + var danish = document.documentElement.lang.toLowerCase().indexOf('da') === 0; function plural(value, singular, pluralForm) { return value + ' ' + (value === 1 ? singular : pluralForm); @@ -36,11 +37,17 @@ if (!count) return; if (term === '') { - count.textContent = plural(totalThirdParty, 'third-party tracker shown', 'third-party trackers shown'); + count.textContent = danish + ? plural(totalThirdParty, 'tredjepartstracker vist', 'tredjepartstrackere vist') + : plural(totalThirdParty, 'third-party tracker shown', 'third-party trackers shown'); } else { - count.textContent = plural(visibleThirdParty, 'third-party tracker matches', 'third-party trackers match') - + ' of ' + totalThirdParty - + (visibleSystem > 0 ? '; ' + plural(visibleSystem, 'System API signature matches', 'System API signatures match') : ''); + count.textContent = danish + ? plural(visibleThirdParty, 'tredjepartstracker matcher', 'tredjepartstrackere matcher') + + ' af ' + totalThirdParty + + (visibleSystem > 0 ? '; ' + plural(visibleSystem, 'System-API-signatur matcher', 'System-API-signaturer matcher') : '') + : plural(visibleThirdParty, 'third-party tracker matches', 'third-party trackers match') + + ' of ' + totalThirdParty + + (visibleSystem > 0 ? '; ' + plural(visibleSystem, 'System API signature matches', 'System API signatures match') : ''); } } diff --git a/routes/index.js b/routes/index.js index 0a59b53..1437568 100644 --- a/routes/index.js +++ b/routes/index.js @@ -12,6 +12,7 @@ const asyncHandler = require('../lib/asyncHandler'); const { buildReportMetadata, buildListingDetails } = require('../lib/appMetadata'); const { siteBaseUrl } = require('../lib/siteUrl'); const cloudflare = require('../lib/cloudflarePurge'); +const { canonicalUrl } = require('../lib/i18n'); // Taken from https://reports.exodus-privacy.eu.org/api/trackers const exodusTrackers = JSON.parse(fs.readFileSync('./exodusTrackers.json', 'utf-8')) @@ -40,22 +41,29 @@ function requireValidAppId(req, res, next) { function renderPublicError(res, status, message) { res.set('X-Robots-Tag', 'noindex'); + const translate = res.locals.t || ((value) => value); return res.status(status).render('error', { - title: status === 404 ? 'Page not found' : 'Request unavailable', + title: translate(status === 404 ? 'Page not found' : 'Request unavailable'), status, - message, + message: translate(message), }); } function renderAnalysisRequest(res, appId, { status = 200, error = null } = {}) { res.set('X-Robots-Tag', 'noindex'); + const translate = res.locals.t || ((value) => value); return res.status(status).render('request-analysis', { - title: 'Request app analysis', + title: translate('Request app analysis'), appId, - error, + error: error ? translate(error) : error, }); } +function redirectPublic(res, status, path) { + const local = res.locals.localUrl || ((value) => value); + return res.redirect(status, local(path)); +} + // ping from analyser in past hour? router.use(function (req, res, next) { res.locals.analyserOnline = lastPing > Date.now() - 1000*60*60; @@ -76,11 +84,11 @@ router.use(function (req, res, next) { const base = siteBaseUrl(req); const path = req.path.length > 1 ? req.path.replace(/\/+$/, '') : req.path; - res.locals.siteName = SITE_NAME; + res.locals.siteName = res.locals.t(SITE_NAME); res.locals.currentPath = req.path.toLowerCase(); res.locals.siteBaseUrl = base; - res.locals.canonicalUrl = base + path; - res.locals.pageDescription = DEFAULT_DESCRIPTION; + res.locals.canonicalUrl = canonicalUrl(base, path, res.locals.locale); + res.locals.pageDescription = res.locals.t(DEFAULT_DESCRIPTION); res.locals.ogImage = null; next(); }); @@ -319,11 +327,9 @@ router.get('/', asyncHandler(async (req, res) => { try { const data = await getSiteData(); return res.render('form', { - title: 'App Privacy Checker', + title: res.locals.t('App Privacy Checker'), data: req.body, - pageDescription: `Search ${data.headlines.totalApps} analysed iOS apps to see ` - + 'which trackers they embed, which companies control them, and under ' - + 'which jurisdiction that tracking falls.', + pageDescription: res.locals.t('Search {{total}} analysed iOS apps to see which trackers they embed, which companies control them, and under which jurisdiction that tracking falls.', { total: data.headlines.totalApps }), headlines: data.headlines, appsWithMostTrackers: data.appsWithMostTrackers, jurisdictionStats: data.jurisdictionStats, @@ -332,7 +338,7 @@ router.get('/', asyncHandler(async (req, res) => { } catch (err) { console.error('Homepage error:', err.message); return res.render('form', { - title: 'App Privacy Checker', + title: res.locals.t('App Privacy Checker'), data: req.body, headlines: null, appsWithMostTrackers: [], @@ -374,7 +380,7 @@ router.get('/statistics', asyncHandler(async (req, res) => { } catch (err) { console.error('Statistics error:', err.message); return res.render('statistics', { - title: 'Detailed Statistics', + title: res.locals.t('Detailed statistics'), data: req.body, headlines: { totalApps: 0 }, jurisdictionStats: { totalApps: 0, classificationCounts: {}, classificationPcts: {}, topCompaniesSorted: [], categoriesSorted: [] }, @@ -394,11 +400,9 @@ router.get('/statistics', asyncHandler(async (req, res) => { const linked = withLookupSlugs(data, index); return res.render('statistics', { - title: 'Detailed Statistics', + title: res.locals.t('Detailed statistics'), data: req.body, - pageDescription: `Tracking jurisdiction across ${data.headlines.totalApps} ` - + 'analysed iOS apps: the most prevalent trackers, the companies behind ' - + 'them, and how they break down by country and App Store category.', + pageDescription: res.locals.t('Tracking jurisdiction across {{total}} analysed iOS apps: the most prevalent trackers, the companies behind them, and how they break down by country and App Store category.', { total: data.headlines.totalApps }), headlines: data.headlines, jurisdictionStats: linked.jurisdictionStats, jurisdictionMeta: jurisdiction.classificationMeta, @@ -452,7 +456,7 @@ router.get('/search', })); res.render('form', { - title: 'Search app', + title: res.locals.t('Search app'), errors: errors.array(), data: req.query, searchResults @@ -460,14 +464,14 @@ router.get('/search', } catch (err) { console.log(err); res.status(502).render('form', { - title: 'Search apps', + title: res.locals.t('Search apps title'), data: req.query, - errors: [{ msg: 'Error while searching. Try again later.' }], + errors: [{ msg: res.locals.t('Error while searching. Try again later.') }], }); } } else { res.render('form', { - title: 'Search app', + title: res.locals.t('Search app'), errors: errors.array(), data: req.query, }); @@ -481,7 +485,7 @@ router.get('/search', router.get('/request/:appId', requireValidAppId, asyncHandler(async (req, res) => { const appId = req.params.appId; const existing = await Apps.findApp(appId); - if (existing) return res.redirect(303, `/analysis/${existing.appid}`); + if (existing) return redirectPublic(res, 303, `/analysis/${existing.appid}`); return renderAnalysisRequest(res, appId); })); @@ -492,7 +496,7 @@ router.get('/analysis/:appId', requireValidAppId, asyncHandler(async (req, res) console.log('Fetching', appId); let app = await Apps.findApp(appId); - if (!app) return res.redirect(303, `/request/${appId}`); + if (!app) return redirectPublic(res, 303, `/request/${appId}`); app.reportMetadata = buildReportMetadata({ analysis: { @@ -565,10 +569,16 @@ router.get('/analysis/:appId', requireValidAppId, asyncHandler(async (req, res) // so a refreshed storefront title is not contradicted by the card. const displayTitle = app.reportMetadata.title || app.details.title; const pageDescription = trackerCount === null - ? `Tracker analysis of ${displayTitle} for iOS.` - : `${trackerCount === 0 ? 'No trackers were' : `${trackerCount} tracker${trackerCount === 1 ? ' was' : 's were'}`}` - + ` detected in ${displayTitle} for iOS` - + (jurisdictionData && jurisdictionData.meta ? `: ${jurisdictionData.meta.label.toLowerCase()}.` : '.'); + ? res.locals.t('Tracker analysis of {{title}} for iOS.', { title: displayTitle }) + : res.locals.t('{{prefix}} detected in {{title}} for iOS{{suffix}}', { + prefix: trackerCount === 0 + ? res.locals.t('No trackers were') + : res.locals.t(trackerCount === 1 ? '{{count}} tracker was' : '{{count}} trackers were', { count: trackerCount }), + title: displayTitle, + suffix: jurisdictionData && jurisdictionData.meta + ? `: ${res.locals.t(jurisdictionData.meta.label)}.` + : '.', + }); res.render('form', { title: displayTitle, @@ -591,7 +601,7 @@ router.post('/analysis/:appId', const requestedAppId = req.params.appId; const existing = await Apps.findApp(requestedAppId); if (existing) - return res.redirect(303, `/analysis/${existing.appid}`); + return redirectPublic(res, 303, `/analysis/${existing.appid}`); let details = await Apps.findCachedAppStoreResult(requestedAppId); let fetchedFromAppStore = false; @@ -647,17 +657,15 @@ router.post('/analysis/:appId', }); } - return res.redirect(303, `/analysis/${details.appId}`); + return redirectPublic(res, 303, `/analysis/${details.appId}`); })); // About page: what this service does, what a report does and does not mean, // where the country labels come from, and who is behind it. router.get('/about', (req, res) => { res.render('about', { - title: 'About', - pageDescription: 'How this service analyses iOS apps for embedded trackers, ' - + 'what a report does and does not tell you, who runs it, and how to get ' - + 'in touch.', + title: res.locals.t('About'), + pageDescription: res.locals.t('How this service analyses iOS apps for embedded trackers, what a report does and does not tell you, who runs it, and how to get in touch.'), jurisdictionMeta: jurisdiction.classificationMeta }); }); @@ -681,17 +689,15 @@ function renderDirectory(kind) { : index.companyList.map((slug) => index.companies[slug]); res.render('directory', { - title: isTracker ? 'Tracker directory' : 'Company directory', + title: res.locals.t(isTracker ? 'Tracker directory' : 'Company directory'), kind, entries, totalApps: index.totalApps, trackedApps: index.trackedApps, latestAnalysis: index.latestAnalysis, pageDescription: isTracker - ? `Every tracker detected across ${index.totalApps} analysed iOS apps, ` - + 'with the company and country behind it.' - : `Every company whose tracking code was detected across ${index.totalApps} ` - + 'analysed iOS apps, ranked by how many apps they reach.' + ? res.locals.t('Every tracker detected across {{total}} analysed iOS apps, with the company and country behind it.', { total: index.totalApps }) + : res.locals.t('Every company whose tracking code was detected across {{total}} analysed iOS apps, ranked by how many apps they reach.', { total: index.totalApps }) }); }); } @@ -732,9 +738,15 @@ function renderLookup(kind) { ); const attribution = entry.company || (isTracker ? null : entry.name); - const description = `${entry.name} was detected in ${entry.appCount} of ` - + `${index.totalApps} analysed iOS apps (${entry.pct}%)` - + (attribution && entry.countryName ? `. Operated by ${attribution} (${entry.countryName}).` : '.'); + const description = res.locals.t('{{name}} was detected in {{count}} of {{total}} analysed iOS apps ({{pct}}%){{suffix}}', { + name: entry.name, + count: entry.appCount, + total: index.totalApps, + pct: entry.pct, + suffix: attribution && entry.countryName + ? res.locals.t('. Operated by {{company}} ({{country}}).', { company: attribution, country: entry.countryName }) + : '.', + }); res.render('lookup', { title: entry.name, @@ -747,8 +759,8 @@ function renderLookup(kind) { exodus: isTracker ? trackerNameToExodus[entry.name] : null, companySlug: isTracker ? entry.companySlug : null, pageDescription: description, - canonicalUrl: res.locals.canonicalUrl - + (pagination.page > 1 ? `?page=${pagination.page}` : '') + canonicalUrl: canonicalUrl(siteBaseUrl(req), req.path, res.locals.locale, + pagination.page > 1 ? { page: pagination.page } : {}) }); }); } diff --git a/server.js b/server.js index 09040be..c77cd5c 100644 --- a/server.js +++ b/server.js @@ -11,6 +11,16 @@ const bodyParser = require('body-parser'); const rateLimit = require('express-rate-limit') const { analyserAuthenticated } = require('./lib/auth'); const { originGate } = require('./lib/originGate'); +const { + createTranslator, + formatDate, + formatDateTime, + localeForRequest, + isPublicLocalizedPath, + stripLocalePrefix, + localUrl, + languageUrl, +} = require('./lib/i18n'); require('dotenv').config(); // improve express security @@ -44,11 +54,12 @@ function sendError(req, res, status, message) { if (isAnalyserPath(req) || !req.accepts('html')) return res.status(status).send(message); + const translate = res.locals.t || createTranslator('en'); res.set('X-Robots-Tag', 'noindex'); return res.status(status).render('error', { - title: status === 404 ? 'Page not found' : status === 429 ? 'Too many requests' : 'Request unavailable', + title: translate(status === 404 ? 'Page not found' : status === 429 ? 'Too many requests' : 'Request unavailable'), status, - message, + message: translate(message), }); } @@ -74,6 +85,29 @@ const isBrowseRequest = (req) => && !isAnalyserPath(req) && !isAppStorePath(req); +// Resolve the display language before rate-limit path classification and route +// mounting. Only the explicitly public page routes accept the /da prefix; +// analyser, API, health, sitemap and asset paths keep their existing routing. +app.use((req, res, next) => { + const requestPath = new URL(req.url || '/', 'http://localhost').pathname; + if (isPublicLocalizedPath(requestPath, req.method)) { + req.locale = 'da'; + req.url = stripLocalePrefix(req.url); + } else { + req.locale = 'en'; + } + const selection = localeForRequest(req); + const locale = selection.locale; + res.locals.locale = locale; + res.locals.t = createTranslator(locale); + res.locals.siteName = res.locals.t('TrackerControl for iOS'); + res.locals.formatDate = (value, options) => formatDate(value, locale, options); + res.locals.formatDateTime = (value) => formatDateTime(value, locale); + res.locals.languageUrl = (target) => languageUrl(req.originalUrl, target); + res.locals.localUrl = (path, target = locale) => localUrl(path, target, req.path); + next(); +}); + // Optional hardening for the case where the origin becomes reachable without // Cloudflare: the WAF challenge rules protecting /search and the request page // only apply to traffic that goes through the edge. Inert unless diff --git a/test/cloudflarePurge.test.js b/test/cloudflarePurge.test.js index 6399777..b50c151 100644 --- a/test/cloudflarePurge.test.js +++ b/test/cloudflarePurge.test.js @@ -48,10 +48,15 @@ test('an unconfigured purge makes no request', async () => { test('a new analysis purges its report and the pages built from it', () => { assert.deepEqual(cloudflare.analysisUrls('https://example.test/', 'com.example.app'), [ 'https://example.test/analysis/com.example.app', + 'https://example.test/da/analysis/com.example.app', 'https://example.test/', + 'https://example.test/da/', 'https://example.test/statistics', + 'https://example.test/da/statistics', 'https://example.test/trackers', + 'https://example.test/da/trackers', 'https://example.test/companies', + 'https://example.test/da/companies', 'https://example.test/sitemap.xml' ]); assert.deepEqual(cloudflare.analysisUrls('', 'com.example.app'), []); diff --git a/test/i18n.test.js b/test/i18n.test.js new file mode 100644 index 0000000..8406955 --- /dev/null +++ b/test/i18n.test.js @@ -0,0 +1,47 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { + canonicalUrl, + createTranslator, + formatDate, + isPublicLocalizedPath, + languageUrl, + localUrl, + localeForRequest +} = require('../lib/i18n'); + +test('Danish translation interpolates values and falls back to English', () => { + const da = createTranslator('da'); + assert.equal(da('Search reports'), 'Søg i rapporter'); + assert.equal(da('United States'), 'USA'); + assert.equal(da('{{count}} apps', { count: 3 }), '3 apps'); + assert.equal(createTranslator('en')('Search reports'), 'Search reports'); +}); + +test('request locale comes from the public path, never from query or cookies', () => { + assert.deepEqual(localeForRequest({ originalUrl: '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/da/statistics?lang=en', headers: {} }), { locale: 'da', fromQuery: false }); + assert.deepEqual(localeForRequest({ originalUrl: '/statistics?lang=da', headers: { cookie: 'tc_locale=da' } }), { locale: 'en', fromQuery: false }); +}); + +test('language links preserve path and existing query parameters', () => { + assert.equal(languageUrl('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/tracker/acme?page=2&filter=all', 'da'), '/da/tracker/acme?page=2&filter=all'); + assert.equal(languageUrl('/da/tracker/acme?page=2&filter=all', 'en'), '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/tracker/acme?page=2&filter=all'); + assert.equal(localUrl('?page=2#apps', 'da', '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/tracker/acme'), '/da/tracker/acme?page=2#apps'); + assert.equal(canonicalUrl('https://example.test', '/statistics', 'da', { page: 2 }), 'https://example.test/da/statistics?page=2'); + assert.equal(canonicalUrl('https://example.test', '/statistics', 'en'), 'https://example.test/statistics'); +}); + +test('only public website routes may be stripped from /da', () => { + assert.equal(isPublicLocalizedPath('/da', 'GET'), true); + assert.equal(isPublicLocalizedPath('/da/analysis/com.example.app', 'POST'), true); + for (const path of ['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/da/queue', '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/da/ping', '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/da/uploadAnalysis', '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/da/reportAnalysisFailure', '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/da/healthz', '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/da/sitemap.xml', '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/da/robots.txt', '/da/assets/app.css']) + assert.equal(isPublicLocalizedPath(path, 'GET'), false, path); +}); + +test('Danish dates use the Danish locale', () => { + const date = new Date('2026-09-08T10:00:00Z'); + assert.match(formatDate(date, 'da'), /2026/); + assert.notEqual(formatDate(date, 'da'), formatDate(date, 'en')); +}); diff --git a/test/requestHandling.test.js b/test/requestHandling.test.js index efe25d7..687676c 100644 --- a/test/requestHandling.test.js +++ b/test/requestHandling.test.js @@ -170,6 +170,44 @@ test('search is a GET so a Cloudflare challenge can replay it', async () => { } }); +test('Danish pages use stable /da URLs and public HTML stays cacheable', async () => { + await withServer(async (base) => { + const english = await fetch(`${base}/`); + const englishHtml = await english.text(); + assert.equal(english.status, 200); + assert.match(englishHtml, //); + assert.match(englishHtml, /action="\/search" method="GET"/); + assert.doesNotMatch(englishHtml, /Et nærmere kig på dine iPhone-apps/); + assert.equal(english.headers.get('set-cookie'), null); + assert.doesNotMatch(english.headers.get('vary') || '', /cookie/i); + assert.doesNotMatch(english.headers.get('cache-control') || '', /private|no-store/i); + + const danish = await fetch(`${base}/da/?from=test`); + const danishHtml = await danish.text(); + assert.equal(danish.status, 200); + assert.match(danishHtml, //); + assert.match(danishHtml, /Et nærmere kig på dine iPhone-apps/); + assert.match(danishHtml, /action="\/da\/search" method="GET"/); + assert.doesNotMatch(danishHtml, /A closer look at your iPhone apps\./); + assert.equal(danish.headers.get('set-cookie'), null); + assert.doesNotMatch(danish.headers.get('vary') || '', /cookie/i); + assert.doesNotMatch(danish.headers.get('cache-control') || '', /private|no-store/i); + + const queryDoesNotSelectDanish = await fetch(`${base}/?lang=da`); + const queryHtml = await queryDoesNotSelectDanish.text(); + assert.match(queryHtml, //); + assert.doesNotMatch(queryHtml, /Et nærmere kig på dine iPhone-apps/); + + const preservedQuery = await fetch(`${base}/da/about?from=test`); + const preservedHtml = await preservedQuery.text(); + assert.match(preservedHtml, /href="\/about\?from=test"/); + + const unsafeAlias = await fetch(`${base}/da/healthz`); + assert.equal(unsafeAlias.status, 404); + assert.doesNotMatch(await unsafeAlias.text(), /"ok"\s*:\s*(true|false)/); + }); +}); + test('search returns results for a query string', async () => { const originalSearch = store.search; const originalFindApp = Apps.findApp; diff --git a/test/websiteViews.test.js b/test/websiteViews.test.js index ec70ec8..dad7fb4 100644 --- a/test/websiteViews.test.js +++ b/test/websiteViews.test.js @@ -4,6 +4,7 @@ const assert = require('node:assert/strict'); const path = require('node:path'); const test = require('node:test'); const pug = require('pug'); +const { createTranslator, formatDate, formatDateTime, languageUrl, localUrl } = require('../lib/i18n'); const views = path.join(__dirname, '..', 'views'); const labels = { @@ -23,7 +24,13 @@ const base = { canonicalUrl: 'https://example.test/', currentPath: '/', data: {}, - jurisdictionMeta: labels + jurisdictionMeta: labels, + locale: 'en', + t: createTranslator('en'), + formatDate: (value, options) => formatDate(value, 'en', options), + formatDateTime: (value) => formatDateTime(value, 'en'), + languageUrl: (target) => languageUrl('/', target), + localUrl: (path) => path }; function render(name, locals = {}) { @@ -214,3 +221,26 @@ test('migrated output contains no legacy Bootstrap or inline event hooks', () => assert.doesNotMatch(html, /bootstrap|jquery|popper|data-toggle|onclick/i); } }); + +test('Danish pages expose the language toggle and translate report labels and countries', () => { + const danish = { + locale: 'da', + t: createTranslator('da'), + formatDate: (value, options) => formatDate(value, 'da', options), + formatDateTime: (value) => formatDateTime(value, 'da'), + languageUrl: (target) => languageUrl('/trackers', target), + localUrl: (path) => localUrl(path, 'da', '/trackers') + }; + const directory = render('directory.pug', { + ...danish, + kind: 'tracker', + entries: [{ name: 'Acme Analytics', slug: 'acme-analytics', company: 'Acme Corp', countryName: 'United States', region: 'US', appCount: 2, pct: '50.0' }], + totalApps: 4, + trackedApps: 2 + }); + assert.match(directory, //); + assert.match(directory, /href="\/da\/trackers"/); + assert.match(directory, /Trackerkatalog/); + assert.match(directory, /USA/); + assert.doesNotMatch(directory, /United States/); +}); diff --git a/views/about.pug b/views/about.pug index f31bcc5..6d22826 100644 --- a/views/about.pug +++ b/views/about.pug @@ -2,115 +2,133 @@ extends layout block content - const aboutMeta = (typeof jurisdictionMeta !== 'undefined' && jurisdictionMeta) || {} - - const labelFor = (key, fallback) => aboutMeta[key] && aboutMeta[key].label ? aboutMeta[key].label : fallback - p.eyebrow About the project - h1 About TrackerControl for iOS - p.lede This service analyses iOS applications in order to list the embedded trackers and permissions. A tracker is a piece of software that collects data about you or your app usage behaviour. + - const labelFor = (key, fallback) => aboutMeta[key] && aboutMeta[key].label ? translate(aboutMeta[key].label) : translate(fallback) + p.eyebrow= translate('About the project') + h1= translate('About TrackerControl for iOS') + p.lede= translate('This service analyses iOS applications in order to list the embedded trackers and permissions. A tracker is a piece of software that collects data about you or your app usage behaviour.') section.about-section#how-it-works(aria-labelledby='how-title') - p.eyebrow Method - h2#how-title How an app is analysed + p.eyebrow= translate('Method') + h2#how-title= translate('How an app is analysed') ol.method-list li span.method-number 01 div - strong Download - p We fetch the app from the UK App Store, like anyone else would. + strong= translate('Download') + p= translate('We fetch the app from the UK App Store, like anyone else would.') li span.method-number 02 div - strong Run - p We install it on a real iPhone and start it up. + strong= translate('Run') + p= translate('We install it on a real iPhone and start it up.') li span.method-number 03 div - strong Look inside - p We list the tracking software the app comes with. + strong= translate('Look inside') + p= translate('We list the tracking software the app comes with.') li span.method-number 04 div - strong Trace it back - p Each tracker is matched to the company behind it, and its home country. + strong= translate('Trace it back') + p= translate('Each tracker is matched to the company behind it, and its home country.') section.notice.notice--quiet#sample(role='note') - p We analyse free apps from the UK App Store, and an app joins the queue only after somebody searches for it here and confirms the request. This is not a random sample of the App Store. It leans heavily towards popular apps: a percentage on this site describes the apps analysed so far, not iOS apps in general. + p= translate('We analyse free apps from the UK App Store, and an app joins the queue only after somebody searches for it here and confirms the request. This is not a random sample of the App Store. It leans heavily towards popular apps: a percentage on this site describes the apps analysed so far, not iOS apps in general.') section.about-section#results(aria-labelledby='results-title') - p.eyebrow Scope - h2#results-title What a report tells you + p.eyebrow= translate('Scope') + h2#results-title= translate('What a report tells you') .result-grid .result-card - h3 What we can see + h3= translate('What we can see') ul - li Which tracking software is built into an app. - li Which companies it belongs to, and where they are based. - li Which permissions the app is able to ask you for. + li= translate('Which tracking software is built into an app.') + li= translate('Which companies it belongs to, and where they are based.') + li= translate('Which permissions the app is able to ask you for.') .result-card - h3 What we cannot see + h3= translate('What we cannot see') ul - li Whether a tracker ever ran, or whether anything left your phone. - li Tracking we have no way of seeing, such as an app sending data straight to its developer's own servers. An empty report is not a promise. - li What a company does with your data once it has it. + li= translate('Whether a tracker ever ran, or whether anything left your phone.') + li= translate('Tracking we have no way of seeing, such as an app sending data straight to its developer’s own servers. An empty report is not a promise.') + li= translate('What a company does with your data once it has it.') section.about-section#jurisdiction(aria-labelledby='jurisdiction-title') - p.eyebrow Company footprint - h2#jurisdiction-title Who controls the tracking - p Each tracker is traced to the company that runs it, up to the parent company at the top, and then to that company's home country. What counts is whose laws a company answers to, not where its servers are: a US company can be compelled to hand data over under the CLOUD Act wherever that data is stored. Every app gets one label: - .table-scroll(role='region' tabindex='0' aria-label='Jurisdiction classifications') + p.eyebrow= translate('Company footprint') + h2#jurisdiction-title= translate('Who controls the tracking') + p= translate('Each tracker is traced to the company that runs it, up to the parent company at the top, and then to that company’s home country. What counts is whose laws a company answers to, not where its servers are: a US company can be compelled to hand data over under the CLOUD Act wherever that data is stored. Every app gets one label:') + .table-scroll(role='region' tabindex='0' aria-label=translate('Jurisdiction classifications')) table.data-table - caption Jurisdiction classifications + caption= translate('Jurisdiction classifications') tbody tr th(scope='row')= labelFor('no_tracking', 'No known trackers detected') - td No third-party tracker was found. + td= translate('No third-party tracker was found.') tr th(scope='row')= labelFor('us_only', 'US-only tracking') - td Every company we identified is based in the US. + td= translate('Every company we identified is based in the US.') tr th(scope='row')= labelFor('european_only', 'European-only tracking') - td Every company we identified is based in the EU, EEA or UK, and subject to GDPR. This is rare. + td= translate('Every company we identified is based in the EU, EEA or UK, and subject to GDPR. This is rare.') tr th(scope='row')= labelFor('mixed_with_us_cn', 'US and Chinese tracking') - td The companies include both US and Chinese ones. + td= translate('The companies include both US and Chinese ones.') tr th(scope='row')= labelFor('mixed_with_us', 'US and other tracking') - td The companies include US ones alongside others, but none from China. + td= translate('The companies include US ones alongside others, but none from China.') tr th(scope='row')= labelFor('mixed_no_us', 'Mixed tracking without US') - td The companies span several parts of the world, but none is in the US. + td= translate('The companies span several parts of the world, but none is in the US.') tr th(scope='row')= labelFor('unresolved_only', 'Unresolved trackers') - td Trackers were found, but we could not tell which companies run them. + td= translate('Trackers were found, but we could not tell which companies run them.') section.about-section#limitations(aria-labelledby='limitations-title') - p.eyebrow Limits - h2#limitations-title Where this stops + p.eyebrow= translate('Limits') + h2#limitations-title= translate('Where this stops') ul - li We can only find trackers we already know about, so new or renamed ones are missed. - li A tracker occasionally ends up attributed to the wrong company, so it is worth checking an individual result before leaning on it. - li A report describes the version of the app we analysed, on the date shown. That may no longer be the version in the App Store. - p If a result looks wrong, tell us — corrections make this better for everyone. + li= translate('We can only find trackers we already know about, so new or renamed ones are missed.') + li= translate('A tracker occasionally ends up attributed to the wrong company, so it is worth checking an individual result before leaning on it.') + li= translate('A report describes the version of the app we analysed, on the date shown. That may no longer be the version in the App Store.') + p= translate('If a result looks wrong, tell us — corrections make this better for everyone.') section.about-section#disclaimer(aria-labelledby='disclaimer-title') - p.eyebrow Disclaimer - h2#disclaimer-title Use this information at your own risk + p.eyebrow= translate('Disclaimer') + h2#disclaimer-title= translate('Use this information at your own risk') .notice.notice--warning - p The information on this site may be out of date, incomplete or simply incorrect. Apps change with every release, tracker databases lag behind the market, and automated analysis makes mistakes. Nothing here is verified by hand. - p Everything is provided “as is”, without any warranty of accuracy, completeness or fitness for a particular purpose. You use this information at your own risk. Please check an individual result yourself before relying on it for anything that matters, and do not treat a report as a statement of fact about an app or the company behind it. + p= translate('The information on this site may be out of date, incomplete or simply incorrect. Apps change with every release, tracker databases lag behind the market, and automated analysis makes mistakes. Nothing here is verified by hand.') + p= translate('Everything is provided “as is”, without any warranty of accuracy, completeness or fitness for a particular purpose. You use this information at your own risk. Please check an individual result yourself before relying on it for anything that matters, and do not treat a report as a statement of fact about an app or the company behind it.') section.about-section#background(aria-labelledby='background-title') - p.eyebrow Research provenance - h2#background-title Where this comes from - p This project was motivated by #[a(href='https://exodus-privacy.eu.org/' target='_blank' rel='noopener noreferrer') Exodus Privacy], which is a similar project for Android apps. Some of the underlying code as well as the design of this website is based on this project. - p The underlying analysis technique for iOS apps was developed in the PhD research of Konrad Kollnig at the Department of Computer Science of the University of Oxford. This research was published in a range of academic papers and is available at #[a(href='https://www.platformcontrol.org/' target='_blank' rel='noopener noreferrer') PlatformControl.org]. This research was, in turn, based on #[a(href='https://sociam.org/mobile-app-x-ray' target='_blank' rel='noopener noreferrer') previous work] by the Oxford research group led by Sir Nigel Shadbolt and led to #[a(href='https://trackercontrol.org/' target='_blank' rel='noopener noreferrer') TrackerControl for Android]. - p A key aim of this research and this tool is to enable anyone to analyse privacy in iOS apps without relying on jailbreaks or the circumvention of copyright protections. These were issues that held back iOS research in the past. - p Unfortunately, Apple currently encrypts every app downloaded from the App Store with its FairPlay DRM. The circumvention of this DRM might be illegal in some jurisdictions and is thus not done by this tool. This is one of the key innovations behind this work, and has been published in the highly renowned and selective #[a(href='https://petsymposium.org/popets/2022/popets-2022-0033.pdf' target='_blank' rel='noopener noreferrer') Proceedings on Privacy Enhancing Technologies] in 2022. - p This website operates a fair use policy and restricts disproportionate access. Scraping is not permitted. - p We never collect personal data. It's yours. This website is operated by Konrad Kollnig, Assistant Professor at Maastricht University. + p.eyebrow= translate('Research provenance') + h2#background-title= translate('Where this comes from') + p + = translate('This project was motivated by ') + a(href='https://exodus-privacy.eu.org/' target='_blank' rel='noopener noreferrer') Exodus Privacy + = translate(', which is a similar project for Android apps. Some of the underlying code as well as the design of this website is based on this project.') + p + = translate('The underlying analysis technique for iOS apps was developed in the PhD research of Konrad Kollnig at the Department of Computer Science of the University of Oxford. This research was published in a range of academic papers and is available at ') + a(href='https://www.platformcontrol.org/' target='_blank' rel='noopener noreferrer') PlatformControl.org + = translate('. This research was, in turn, based on ') + a(href='https://sociam.org/mobile-app-x-ray' target='_blank' rel='noopener noreferrer')= translate('previous work') + = translate(' by the Oxford research group led by Sir Nigel Shadbolt and led to ') + a(href='https://trackercontrol.org/' target='_blank' rel='noopener noreferrer') TrackerControl for Android + | . + p= translate('A key aim of this research and this tool is to enable anyone to analyse privacy in iOS apps without relying on jailbreaks or the circumvention of copyright protections. These were issues that held back iOS research in the past.') + p + = translate('Unfortunately, Apple currently encrypts every app downloaded from the App Store with its FairPlay DRM. The circumvention of this DRM might be illegal in some jurisdictions and is thus not done by this tool. This is one of the key innovations behind this work, and has been published in the highly renowned and selective ') + a(href='https://petsymposium.org/popets/2022/popets-2022-0033.pdf' target='_blank' rel='noopener noreferrer') Proceedings on Privacy Enhancing Technologies + = translate(' in 2022.') + p= translate('This website operates a fair use policy and restricts disproportionate access. Scraping is not permitted.') + p= translate('We never collect personal data. It’s yours. This website is operated by Konrad Kollnig, Assistant Professor at Maastricht University.') section.about-section#contact(aria-labelledby='contact-title') - p.eyebrow Contact - h2#contact-title Contact - p You can report issues and ask questions at #[a(href='mailto:ios@trackercontrol.org') ios@trackercontrol.org], or read the code at #[a(href='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/TrackerControl/tracker-control-ios' target='_blank' rel='noopener noreferrer') github.com/TrackerControl/tracker-control-ios]. + p.eyebrow= translate('Contact') + h2#contact-title= translate('Contact') + p + = translate('You can report issues and ask questions at ') + a(href='mailto:ios@trackercontrol.org') ios@trackercontrol.org + = translate(', or read the code at ') + a(href='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/TrackerControl/tracker-control-ios' target='_blank' rel='noopener noreferrer') github.com/TrackerControl/tracker-control-ios + | . block app diff --git a/views/directory.pug b/views/directory.pug index c1b4855..1b96de8 100644 --- a/views/directory.pug +++ b/views/directory.pug @@ -7,67 +7,71 @@ block content - const total = typeof totalApps !== 'undefined' ? totalApps : 0 - const tracked = typeof trackedApps !== 'undefined' ? trackedApps : 0 - const directoryLatest = typeof latestAnalysis !== 'undefined' ? latestAnalysis : null - - const safeDate = (value) => { const date = value instanceof Date ? value : new Date(value); return value && !Number.isNaN(date.getTime()) ? date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) : null } - h1= isTracker ? 'Tracker directory' : 'Company directory' + - const safeDate = (value) => localizedDate(value, { day: 'numeric', month: 'short', year: 'numeric' }) + h1= translate(isTracker ? 'Tracker directory' : 'Company directory') if directoryEntries.length > 0 p.lede if isTracker - | Every tracker signature detected across #{total} analysed iOS apps. Select one to see the apps in which it was found. + = translate('Every tracker signature detected across {{total}} analysed iOS apps. Select one to see the apps in which it was found.', { total }) else - | Every company whose tracking code was detected across #{total} analysed iOS apps, ranked by reach. - p.section-note #{tracked} of #{total} analysed apps contain at least one detected third-party tracker#{directoryLatest && safeDate(directoryLatest) ? `. Last analysis: ${safeDate(directoryLatest)}.` : '.'} See #[a(href='/about#how-it-works') how these numbers are produced]. + = translate('Every company whose tracking code was detected across {{total}} analysed iOS apps, ranked by reach.', { total }) + p.section-note + = translate(directoryLatest && safeDate(directoryLatest) ? '{{tracked}} of {{total}} analysed apps contain at least one detected third-party tracker. Last analysis: {{date}}.' : '{{tracked}} of {{total}} analysed apps contain at least one detected third-party tracker.', { tracked, total, date: safeDate(directoryLatest) }) + = ' ' + a(href=localize('/about#how-it-works'))= translate('See how these numbers are produced') + | . .directory-controls - label(for='directory-filter') Filter #{isTracker ? 'trackers or companies' : 'companies'} - input#directory-filter(type='search' placeholder=isTracker ? 'Filter trackers or companies' : 'Filter companies' aria-controls='directory-table' data-filter-target='#directory-table' autocomplete='off') - p#directory-count.result-count(role='status' aria-live='polite') #{directoryEntries.length} entr#{directoryEntries.length === 1 ? 'y' : 'ies'} + label(for='directory-filter')= translate(isTracker ? 'Filter trackers or companies' : 'Filter companies') + input#directory-filter(type='search' placeholder=translate(isTracker ? 'Filter trackers or companies' : 'Filter companies') aria-controls='directory-table' data-filter-target='#directory-table' autocomplete='off') + p#directory-count.result-count(role='status' aria-live='polite')= translate(directoryEntries.length === 1 ? '{{count}} entry' : '{{count}} entries', { count: directoryEntries.length }) - .table-scroll(role='region' tabindex='0' aria-label=`${isTracker ? 'Tracker' : 'Company'} directory table`) + .table-scroll(role='region' tabindex='0' aria-label=translate(isTracker ? 'Tracker directory table' : 'Company directory table')) table#directory-table.data-table - caption= isTracker ? 'Tracker directory' : 'Company directory' + caption= translate(isTracker ? 'Tracker directory' : 'Company directory') thead tr - th(scope='col') Rank - th(scope='col')= isTracker ? 'Tracker' : 'Company' - th(scope='col')= isTracker ? 'Company' : 'Trackers' - th(scope='col') Country - th(scope='col') Region - th(scope='col') Apps - th(scope='col') Share + th(scope='col')= translate('Rank') + th(scope='col')= translate(isTracker ? 'Tracker' : 'Company') + th(scope='col')= translate(isTracker ? 'Company' : 'Trackers') + th(scope='col')= translate('Country') + th(scope='col')= translate('Region') + th(scope='col')= translate('Apps') + th(scope='col')= translate('Share') tbody each entry, index in directoryEntries - - const entryName = entry.name || 'Unknown' + - const entryName = entry.name || translate('Unknown') - const haystack = `${entryName} ${isTracker ? (entry.company || '') : ''} ${entry.countryName || ''} ${entry.region || ''}`.toLowerCase() tr(data-filter=haystack) td= index + 1 td - a.entry-link(href=`/${directoryKind}/${entry.slug}`)= entryName + a.entry-link(href=localize(`/${directoryKind}/${entry.slug}`))= entryName if isTracker && entry.system - span.status-label System API + span.status-label= translate('System API') td if isTracker if entry.company && entry.companySlug - a(href=`/company/${entry.companySlug}`)= entry.company + a(href=localize(`/company/${entry.companySlug}`))= entry.company else if entry.company = entry.company else if entry.system - span.muted Apple platform API + span.muted= translate('Apple platform API') else - span.muted Unattributed + span.muted= translate('Unattributed') else = entry.trackers ? entry.trackers.length : 0 - td= entry.countryName || 'Unknown' - td= entry.region || 'Unresolved' - td= entry.appCount === undefined ? 'Unavailable' : entry.appCount - td= entry.pct === undefined ? 'Unavailable' : `${entry.pct}%` - p#directory-empty.notice.notice--quiet(hidden) No entries match that filter. + td= entry.countryName ? translate(entry.countryName) : translate('Unknown') + td= entry.region ? translate(entry.region) : translate('Unresolved') + td= entry.appCount === undefined ? translate('Unavailable') : entry.appCount + td= entry.pct === undefined ? translate('Unavailable') : `${entry.pct}%` + p#directory-empty.notice.notice--quiet(hidden)= translate('No entries match that filter.') p.section-note if isTracker - | A tracker is counted once per app, regardless of how many times its code appears. “System API” marks Apple platform APIs reported by the analyser and excluded from the third-party tracker count. “Unattributed” marks signatures that could not be mapped to a company. + = translate('A tracker is counted once per app, regardless of how many times its code appears. “System API” marks Apple platform APIs reported by the analyser and excluded from the third-party tracker count. “Unattributed” marks signatures that could not be mapped to a company.') else - | Companies are counted at the level of the ultimate parent, so subsidiaries are grouped under their owner. An app is counted once per company even when several of that company's trackers are present. + = translate('Companies are counted at the level of the ultimate parent, so subsidiaries are grouped under their owner. An app is counted once per company even when several of that company’s trackers are present.') else .notice.notice--warning(role='status') - p Directory data is not available right now. Please try again later. + p= translate('Directory data is not available right now. Please try again later.') block app diff --git a/views/error.pug b/views/error.pug index 8924715..10eab21 100644 --- a/views/error.pug +++ b/views/error.pug @@ -2,15 +2,15 @@ extends layout block content - const errorStatus = typeof status !== 'undefined' && status ? status : null - - const errorTitle = (typeof title !== 'undefined' && title) || 'Something went wrong' - - const errorMessage = (typeof message !== 'undefined' && message) || 'The page could not be loaded. Please try again later.' - p.eyebrow TrackerControl for iOS + - const errorTitle = (typeof title !== 'undefined' && title) || translate('Something went wrong') + - const errorMessage = (typeof message !== 'undefined' && message) || translate('The page could not be loaded. Please try again later.') + p.eyebrow= translate('TrackerControl for iOS') if errorStatus p.error-status= errorStatus h1= errorTitle .notice.notice--warning(role='alert') p= errorMessage p.page-links - a(href='/') ← Back to home + a(href=localize('/')) ← #{translate('Back to home')} block app diff --git a/views/form.pug b/views/form.pug index 5df3e20..18c9860 100644 --- a/views/form.pug +++ b/views/form.pug @@ -10,44 +10,44 @@ block content - const formData = (typeof data !== 'undefined' && data) || {} - const featuredApps = (typeof appsWithMostTrackers !== 'undefined' && appsWithMostTrackers) || [] - const classes = ['us_only', 'mixed_with_us_cn', 'mixed_with_us', 'mixed_no_us', 'european_only', 'unresolved_only', 'no_tracking'] - - const displayDate = (value) => { const date = value instanceof Date ? value : new Date(value); return value && !Number.isNaN(date.getTime()) ? date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) : null } + - const displayDate = (value) => localizedDate(value, { day: 'numeric', month: 'short', year: 'numeric' }) if isHomepage section.hero(aria-labelledby='hero-title') - p.eyebrow Independent app privacy research - h1#hero-title A closer look at your iPhone apps. - p.hero-intro Find the tracking software built into iOS apps, and the companies behind it. - form.search-form#search(action='/search' method='GET' role='search' aria-labelledby='search-title') + p.eyebrow= translate('Independent app privacy research') + h1#hero-title= translate('A closer look at your iPhone apps.') + p.hero-intro= translate('Find the tracking software built into iOS apps, and the companies behind it.') + form.search-form#search(action=localize('/search') method='GET' role='search' aria-labelledby='search-title') .search-form__label - label#search-title(for='app-search') Find an app - p.field-hint#search-hint Free apps from the UK App Store. New analysis requests require confirmation. + label#search-title(for='app-search')= translate('Find an app') + p.field-hint#search-hint= translate('Free apps from the UK App Store. New analysis requests require confirmation.') .search-form__controls - input#app-search(type='search' name='search' placeholder='Search an app name' autocomplete='off' aria-describedby='search-hint') - button.button(type='submit') Search reports + input#app-search(type='search' name='search' placeholder=translate('Search an app name') autocomplete='off' aria-describedby='search-hint') + button.button(type='submit')= translate('Search reports') if headlineData && headlineData.totalApps !== undefined - section.metrics(aria-label='Research snapshot') + section.metrics(aria-label=translate('Research snapshot')) .metric strong= headlineData.totalApps - span iOS apps analysed - small UK App Store sample + span= translate('iOS apps analysed') + small= translate('UK App Store sample') .metric strong= `${headlineData.usOnlyPct || 0}%` - span have US-only trackers - small of #{headlineData.totalApps} analysed + span= translate('have US-only trackers') + small= translate('of {{total}} analysed', { total: headlineData.totalApps }) .metric strong= `${headlineData.noTrackersPct || 0}%` - span have no known trackers detected - small Detection identifies embedded signatures, not safety. + span= translate('have no known trackers detected') + small= translate('Detection identifies embedded signatures, not safety.') if headlineData.latestAnalysis - p.section-note Last analysis: #{displayDate(headlineData.latestAnalysis) || 'Not recorded'}. - p.section-note These figures describe the analysed UK App Store sample, which is not random. New apps join the analysis queue only after a search and confirmation. + p.section-note= translate('Last analysis: {{date}}.', { date: displayDate(headlineData.latestAnalysis) || translate('Not recorded') }) + p.section-note= translate('These figures describe the analysed UK App Store sample, which is not random. New apps join the analysis queue only after a search and confirmation.') if statsData section.home-section(aria-labelledby='jurisdiction-overview-title') .section-heading - p.eyebrow Overview - h2#jurisdiction-overview-title Jurisdiction breakdown + p.eyebrow= translate('Overview') + h2#jurisdiction-overview-title= translate('Jurisdiction breakdown') .bar-list each cls in classes - const count = statsData.classificationCounts && statsData.classificationCounts[cls] @@ -56,20 +56,20 @@ block content if count !== undefined && count !== null .bar-item .bar-label - span= meta.label || cls.replace(/_/g, ' ') - strong #{count} apps (#{pct === undefined ? '0' : pct}%) - progress.bar-track(value=Number(pct) || 0 max='100' aria-label=`${meta.label || cls} ${count} apps`) + span= meta.label ? translate(meta.label) : cls.replace(/_/g, ' ') + strong= translate('{{count}} apps ({{pct}}%)', { count, pct: pct === undefined ? '0' : pct }) + progress.bar-track(value=Number(pct) || 0 max='100' aria-label=translate('{{count}} apps', { count })) | #{pct === undefined ? '0' : pct}% if featuredApps.length > 0 section.home-section(aria-labelledby='featured-title') .section-heading - p.eyebrow Published reports - h2#featured-title Apps with the most trackers + p.eyebrow= translate('Published reports') + h2#featured-title= translate('Apps with the most trackers') .app-list each featured in featuredApps - - const featuredTitle = featured.title || featured.appid || 'Untitled app' - a.app-row.app-row--linked(href=`/analysis/${featured.appid}`) + - const featuredTitle = featured.title || featured.appid || translate('Untitled app') + a.app-row.app-row--linked(href=localize(`/analysis/${featured.appid}`)) if featured.icon img.app-icon.app-icon--small(src=featured.icon alt='' width='48' height='48' loading='lazy') else @@ -77,50 +77,50 @@ block content span.app-row__body span.app-row__name= featuredTitle span.app-row__meta - | #{featured.trackerCount} third-party trackers + = translate('{{count}} third-party trackers', { count: featured.trackerCount }) if featured.analysed - | · analysed #{displayDate(featured.analysed) || 'date unavailable'} + = ` · ${translate('analysed {{date}}', { date: displayDate(featured.analysed) || translate('date unavailable') })}` if metaData[featured.classification] - span.app-row__meta= metaData[featured.classification].label + span.app-row__meta= translate(metaData[featured.classification].label) span.app-row__count #{featured.trackerCount} span.app-row__arrow(aria-hidden='true') ↗ p.section-actions - a.button.button--secondary(href='/statistics') View detailed statistics - a.button.button--secondary(href='/trackers') Look up a tracker + a.button.button--secondary(href=localize('/statistics'))= translate('View detailed statistics') + a.button.button--secondary(href=localize('/trackers'))= translate('Look up a tracker') if hasErrors section.search-state(aria-labelledby='search-title') - p.eyebrow Search - h1#search-title Search apps + p.eyebrow= translate('Search') + h1#search-title= translate('Search apps heading') .notice.notice--warning(role='alert') ul.error-list each error in errors - li= error.msg || error - p.search-help Free apps from the UK App Store. New analysis requests require confirmation. - form.search-form.search-form--compact(action='/search' method='GET' role='search') - label(for='search-again') Search again + li= translate(error.msg || error) + p.search-help= translate('Free apps from the UK App Store. New analysis requests require confirmation.') + form.search-form.search-form--compact(action=localize('/search') method='GET' role='search') + label(for='search-again')= translate('Search again') .search-form__controls input#search-again(type='search' name='search' value=formData.search || '' autocomplete='off') - button.button(type='submit') Search reports + button.button(type='submit')= translate('Search reports') else if hasSearchResults - const term = formData.search || '' section.search-state(aria-labelledby='search-results-title') - p.eyebrow Search - h1#search-results-title Search results + p.eyebrow= translate('Search') + h1#search-results-title= translate('Search results') p.result-summary if searchResults.length === 0 - | No apps found for “#{term}”. + = translate('No apps found for “{{term}}”.', { term }) else - | #{searchResults.length} result#{searchResults.length === 1 ? '' : 's'} for “#{term}”. + = translate(searchResults.length === 1 ? '{{count}} result for “{{term}}”.' : '{{count}} results for “{{term}}”.', { count: searchResults.length, term }) if searchResults.length > 0 .search-results each result in searchResults - const resultId = result.appId || result.appid || '' - - const resultTitle = result.title || resultId || 'Untitled app' + - const resultTitle = result.title || resultId || translate('Untitled app') if result.free - a.search-result(href=result.inDatabase ? `/analysis/${resultId}` : `/request/${resultId}`) + a.search-result(href=localize(result.inDatabase ? `/analysis/${resultId}` : `/request/${resultId}`)) if result.icon img.app-icon.app-icon--small(src=result.icon alt='' width='48' height='48' loading='lazy') else @@ -128,11 +128,11 @@ block content span.search-result__body strong= resultTitle span.search-result__meta - | Version #{result.version || 'Not recorded'} + = `${translate('Version')} ${result.version || translate('Not recorded')}` if result.inDatabase - | · Published analysis + = ` · ${translate('Published analysis')}` else - | · Request a new analysis + = ` · ${translate('Request a new analysis')}` span.search-result__arrow(aria-hidden='true') ↗ else .search-result.search-result--unavailable @@ -142,27 +142,29 @@ block content span.app-icon.app-icon--small.app-icon--fallback(aria-hidden='true')= resultTitle.slice(0, 1).toUpperCase() span.search-result__body strong= resultTitle - span.search-result__meta Paid apps cannot be analysed by this service. + span.search-result__meta= translate('Paid apps cannot be analysed by this service.') else .notice.notice--quiet - p No matching free app was returned by the UK App Store search. Check the spelling or try a shorter name. - p.search-help Free apps without a published report open a confirmation page first. A request is queued only after confirmation. + p= translate('No matching free app was returned by the UK App Store search. Check the spelling or try a shorter name.') + p.search-help= translate('Free apps without a published report open a confirmation page first. A request is queued only after confirmation.') if isHomepage p.site-disclaimer - strong Please note: - | reports may be out of date, incomplete or incorrect. They describe an analysed app version on the date shown, and automated analysis makes mistakes. #[a(href='/about#disclaimer') Read the full disclaimer]. + strong= translate('Please note:') + = translate('reports may be out of date, incomplete or incorrect. They describe an analysed app version on the date shown, and automated analysis makes mistakes. ') + a(href=localize('/about#disclaimer'))= translate('Read the full disclaimer') + | . block app if typeof app !== 'undefined' && app - const report = app - const reportMeta = report.reportMetadata || {} - const reportDetails = report.details || {} - - const reportTitle = reportMeta.title || reportDetails.title || reportDetails.appId || report.appid || 'App report' + - const reportTitle = reportMeta.title || reportDetails.title || reportDetails.appId || report.appid || translate('App report') - const reportIcon = reportMeta.icon || reportDetails.icon || null - const reportAnalysis = report.analysis || null - const failed = Boolean(report.analysisFailure || (reportAnalysis && reportAnalysis.success === false)) - - const failureMessage = report.analysisFailure || (reportAnalysis && reportAnalysis.reason === 'app_not_found' ? 'App not found on the App Store.' : 'Analysis failed.') + - const failureMessage = report.analysisFailure ? translate(report.analysisFailure) : (reportAnalysis && reportAnalysis.reason === 'app_not_found' ? translate('App not found on the App Store.') : translate('Analysis failed.')) - const hasTrackerField = Boolean(reportAnalysis && Object.prototype.hasOwnProperty.call(reportAnalysis, 'trackers') && reportAnalysis.trackers && typeof reportAnalysis.trackers === 'object') - const trackerNames = hasTrackerField ? Object.keys(reportAnalysis.trackers) : [] - const providedSystemNames = (typeof systemTrackerNames !== 'undefined' && systemTrackerNames) || [] @@ -173,13 +175,13 @@ block app - const trackerSlugMap = (typeof trackerSlugs !== 'undefined' && trackerSlugs) || {} - const companySlugMap = (typeof companySlugs !== 'undefined' && companySlugs) || {} - const reportJurisdiction = (typeof jurisdictionData !== 'undefined' && jurisdictionData) || null - - const formatDate = (value) => { const date = value instanceof Date ? value : new Date(value); return value && !Number.isNaN(date.getTime()) ? date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) : 'Not recorded' } - - const formatDateTime = (value) => { const date = value instanceof Date ? value : new Date(value); return value && !Number.isNaN(date.getTime()) ? date.toLocaleString('en-GB') : 'Not recorded' } + - const formatReportDate = (value) => localizedDate(value, { day: 'numeric', month: 'short', year: 'numeric' }) || translate('Not recorded') + - const formatReportDateTime = (value) => localizedDateTime(value) || translate('Not recorded') - const trackerDetails = reportJurisdiction && Array.isArray(reportJurisdiction.trackerDetails) ? reportJurisdiction.trackerDetails : [] - const detailByName = Object.create(null) - trackerDetails.forEach((detail) => { const key = String(detail.name || '').toLowerCase(); if (key && !detailByName[key]) detailByName[key] = detail }) - const permissionLabels = { Camera: 'Camera', Calendars: 'Calendars', Location: 'Location', LocationWhenInUse: 'Location while using the app', LocationAlways: 'Location at all times', Photos: 'Photo library', Tracking: 'Tracking', BluetoothAlways: 'Bluetooth', Microphone: 'Microphone', Contacts: 'Contacts' } - - const readablePermission = (permission) => permissionLabels[permission] || permission + - const readablePermission = (permission) => permissionLabels[permission] ? translate(permissionLabels[permission]) : permission - const knownVersion = reportMeta.currentVersion || (reportMeta.currentVersionFromStorefront && reportMeta.analysedVersion) || reportMeta.queueVersion || reportDetails.version article.site-width.report-page .report-header @@ -188,81 +190,81 @@ block app else span.app-icon.app-icon--large.app-icon--fallback(aria-hidden='true')= reportTitle.slice(0, 1).toUpperCase() .report-header__copy - p.eyebrow App report + p.eyebrow= translate('App report') h1= reportTitle p.report-meta if reportAnalysis - | Analysis date · #{formatDate(reportMeta.analysedAt)} + = translate('Analysis date · {{date}}', { date: formatReportDate(reportMeta.analysedAt) }) else - | Awaiting analysis + = translate('Awaiting analysis') if reportMeta.url - a.report-header__link(href=reportMeta.url target='_blank' rel='noopener noreferrer') See on the App Store ↗ + a.report-header__link(href=reportMeta.url target='_blank' rel='noopener noreferrer')= translate('See on the App Store ↗') - section.metadata(aria-label='Report metadata') + section.metadata(aria-label=translate('Report metadata')) if reportAnalysis && !failed .metadata-item - span Analysed version - strong= reportMeta.analysedVersion || 'Not recorded' + span= translate('Analysed version') + strong= reportMeta.analysedVersion || translate('Not recorded') .metadata-item - span Analysis date - strong= formatDate(reportMeta.analysedAt) + span= translate('Analysis date') + strong= formatReportDate(reportMeta.analysedAt) .metadata-item - span= reportMeta.currentVersionFromStorefront ? 'Last known App Store version' : 'Queue-time version' - strong= knownVersion || 'Not recorded' + span= translate(reportMeta.currentVersionFromStorefront ? 'Last known App Store version' : 'Queue-time version') + strong= knownVersion || translate('Not recorded') .metadata-item - span Store metadata checked - strong= formatDateTime(reportMeta.currentFetchedAt) + span= translate('Store metadata checked') + strong= formatReportDateTime(reportMeta.currentFetchedAt) if failed section.notice.notice--warning(role='alert') - h2 Analysis unavailable + h2= translate('Analysis unavailable') p= failureMessage else if !reportAnalysis section.notice.notice--quiet - h2 Awaiting analysis + h2= translate('Awaiting analysis') if report.queueCount === 0 - p This app is next in the analysis queue. Check back soon. + p= translate('This app is next in the analysis queue. Check back soon.') else - p This app is queued for analysis. #{report.queueCount || 0} other app#{report.queueCount === 1 ? '' : 's'} will be analysed first. + p= translate(report.queueCount === 1 ? 'This app is queued for analysis. {{count}} other app will be analysed first.' : 'This app is queued for analysis. {{count}} other apps will be analysed first.', { count: report.queueCount || 0 }) if typeof analyserOnline !== 'undefined' && analyserOnline === false - p Analyser service is currently offline. Analysis will resume when the service is available. + p= translate('Analyser service is currently offline. Analysis will resume when the service is available.') else - .report-summary(aria-label='Report summary') + .report-summary(aria-label=translate('Report summary')) .summary-item - strong= trackerCountKnown ? thirdPartyNames.length : 'Unavailable' - span third-party trackers detected + strong= trackerCountKnown ? thirdPartyNames.length : translate('Unavailable') + span= translate('third-party trackers detected') if systemNames.length > 0 - small #{systemNames.length} System API signature#{systemNames.length === 1 ? '' : 's'} shown separately + small= `${systemNames.length} ${translate(systemNames.length === 1 ? 'System API signature' : 'System API signatures')} ${translate('shown separately')}` .summary-item - strong= reportJurisdiction && reportJurisdiction.resolvedCount !== undefined ? reportJurisdiction.resolvedCount : 'Unavailable' - span associated companies + strong= reportJurisdiction && reportJurisdiction.resolvedCount !== undefined ? reportJurisdiction.resolvedCount : translate('Unavailable') + span= translate('associated companies') if reportJurisdiction && reportJurisdiction.unresolvedCount !== undefined - small #{reportJurisdiction.unresolvedCount} unresolved tracker#{reportJurisdiction.unresolvedCount === 1 ? '' : 's'} + small= `${reportJurisdiction.unresolvedCount} ${translate(reportJurisdiction.unresolvedCount === 1 ? 'unresolved tracker' : 'unresolved trackers')}` .summary-item if Array.isArray(reportAnalysis.permissions) strong= reportAnalysis.permissions.length - span declared permissions + span= translate('declared permissions') else - strong Unavailable - span declared permissions - p.summary-note Detection identifies embedded software signatures. It does not show whether data left the device. + strong= translate('Unavailable') + span= translate('declared permissions') + p.summary-note= translate('Detection identifies embedded software signatures. It does not show whether data left the device.') section.report-section#trackers(aria-labelledby='trackers-title') .section-heading.section-heading--inline div - p.eyebrow Embedded software - h2#trackers-title Tracking software + p.eyebrow= translate('Embedded software') + h2#trackers-title= translate('Tracking software') if trackerCountKnown - span.result-count#tracker-result-count(role='status' aria-live='polite') #{thirdPartyNames.length} third-party tracker#{thirdPartyNames.length === 1 ? '' : 's'} shown + span.result-count#tracker-result-count(role='status' aria-live='polite')= `${thirdPartyNames.length} ${translate(thirdPartyNames.length === 1 ? 'third-party tracker shown' : 'third-party trackers shown')}` else - span.result-count#tracker-result-count(role='status' aria-live='polite') Tracker data unavailable + span.result-count#tracker-result-count(role='status' aria-live='polite')= translate('Tracker data unavailable') if trackerCountKnown if trackerNames.length === 0 - p.notice.notice--quiet No tracker signatures were supplied for this analysis. This does not establish that the app is free of tracking. + p.notice.notice--quiet= translate('No tracker signatures were supplied for this analysis. This does not establish that the app is free of tracking.') else .tracker-controls - label(for='tracker-filter') Filter trackers - input#tracker-filter(type='search' placeholder='Search by name, company or category' autocomplete='off' aria-describedby='tracker-result-count') + label(for='tracker-filter')= translate('Filter trackers') + input#tracker-filter(type='search' placeholder=translate('Search by name, company or category') autocomplete='off' aria-describedby='tracker-result-count') .tracker-list each trackerName in trackerNames - const trackerKey = String(trackerName).toLowerCase().trim() @@ -275,92 +277,98 @@ block app - const companyKey = companyName ? String(companyName).toLowerCase().trim() : '' - const companySlug = companyKey && Object.prototype.hasOwnProperty.call(companySlugMap, companyKey) ? companySlugMap[companyKey] : null - const isSystem = systemNames.includes(trackerName) - - const countryText = isSystem ? 'Not applicable' : (detail && detail.countryName ? detail.countryName : 'Unknown country') - - const categoryText = isSystem ? 'System API' : (categories.length > 0 ? categories.join(', ') : 'Unknown category') + - const countryText = isSystem ? translate('Not applicable') : (detail && detail.countryName ? translate(detail.countryName) : translate('Unknown country')) + - const categoryText = isSystem ? translate('System API') : (categories.length > 0 ? categories.join(', ') : translate('Unknown category')) details.tracker-row(data-filter=`${trackerName} ${companyName} ${categoryText}` data-system=isSystem ? 'true' : 'false') summary span.tracker-row__name= trackerName - span.tracker-row__category= isSystem ? 'System API' : categoryText - span.tracker-row__company= companyName || (isSystem ? 'Apple platform API' : 'Unknown company') + span.tracker-row__category= isSystem ? translate('System API') : categoryText + span.tracker-row__company= companyName || (isSystem ? translate('Apple platform API') : translate('Unknown company')) span.tracker-row__country= countryText .tracker-row__detail if isSystem - p This signature identifies an Apple system API. It is shown for transparency and excluded from the third-party tracker count and jurisdiction analysis. + p= translate('This signature identifies an Apple system API. It is shown for transparency and excluded from the third-party tracker count and jurisdiction analysis.') else - p Embedded signature detected. Company jurisdiction describes the identified parent company's home country; it does not establish an observed transfer. + p= translate('Embedded signature detected. Company jurisdiction describes the identified parent company’s home country; it does not establish an observed transfer.') if localSlug - a(href=`/tracker/${localSlug}`) View #{trackerName} in the tracker directory ↗ + a(href=localize(`/tracker/${localSlug}`))= translate('View {{name}} in the tracker directory ↗', { name: trackerName }) else if exodus && exodus.id - a(href=`https://reports.exodus-privacy.eu.org/en/trackers/${exodus.id}/` target='_blank' rel='noopener noreferrer') View #{trackerName} on Exodus Privacy ↗ + a(href=`https://reports.exodus-privacy.eu.org/en/trackers/${exodus.id}/` target='_blank' rel='noopener noreferrer')= translate('View {{name}} on Exodus Privacy ↗', { name: trackerName }) if companyName if companySlug - a(href=`/company/${companySlug}`) Company: #{companyName} ↗ + a(href=localize(`/company/${companySlug}`))= translate('Company: {{name}} ↗', { name: companyName }) else - span Company: #{companyName} + span= translate('Company: {{name}}', { name: companyName }) if detail && detail.region - span Country: #{countryText}; region: #{detail.region} + span= translate('Country: {{country}}; region: {{region}}', { country: countryText, region: translate(detail.region) }) if categories.length > 0 - span Categories: #{categoryText} + span= translate('Categories: {{categories}}', { categories: categoryText }) else if !isSystem - span Unknown tracker category. - p.no-results#tracker-no-results(hidden) No tracker matches that filter. Try another name, company or category. - p.section-note A tracker is software embedded in an app. Detection does not prove that the software ran or that data was transmitted. #[a(href='/about#results') Learn what a report tells you]. + span= translate('Unknown tracker category.') + p.no-results#tracker-no-results(hidden)= translate('No tracker matches that filter. Try another name, company or category.') + p.section-note + = translate('A tracker is software embedded in an app. Detection does not prove that the software ran or that data was transmitted. ') + a(href=localize('/about#results'))= translate('Learn what a report tells you') + | . else - p.notice.notice--quiet Tracker signatures were not available for this analysis, so a count cannot be reported. + p.notice.notice--quiet= translate('Tracker signatures were not available for this analysis, so a count cannot be reported.') if reportJurisdiction section.report-section#jurisdiction(aria-labelledby='jurisdiction-title') - p.eyebrow Company footprint - h2#jurisdiction-title Jurisdiction analysis - p.status-label= (reportJurisdiction.meta && reportJurisdiction.meta.label) || reportJurisdiction.classification || 'Jurisdiction unavailable' - p Company jurisdiction is based on the identified parent company's home country. It does not establish where data travelled or whether any transfer occurred. + p.eyebrow= translate('Company footprint') + h2#jurisdiction-title= translate('Jurisdiction analysis') + p.status-label= (reportJurisdiction.meta && reportJurisdiction.meta.label ? translate(reportJurisdiction.meta.label) : null) || reportJurisdiction.classification || translate('Jurisdiction unavailable') + p= translate('Company jurisdiction is based on the identified parent company’s home country. It does not establish where data travelled or whether any transfer occurred.') if !reportJurisdiction.trackerDetails || reportJurisdiction.trackerDetails.length === 0 - p.notice.notice--quiet Company attribution details are unavailable. + p.notice.notice--quiet= translate('Company attribution details are unavailable.') if reportJurisdiction.regionBreakdown && Object.keys(reportJurisdiction.regionBreakdown).length > 0 - h3 Jurisdiction distribution + h3= translate('Jurisdiction distribution') if reportJurisdiction.resolvedCount !== undefined && Number(reportJurisdiction.resolvedCount) > 0 .bar-list each count, region in reportJurisdiction.regionBreakdown - const percentage = Math.round(Number(count) / Number(reportJurisdiction.resolvedCount) * 100) .bar-item .bar-label - span= region - strong #{count} compan#{Number(count) === 1 ? 'y' : 'ies'} (#{percentage}%) - progress.bar-track(value=percentage max='100' aria-label=`${region} ${count} companies`) + span= translate(region) + strong= translate(Number(count) === 1 ? '{{count}} company ({{pct}}%)' : '{{count}} companies ({{pct}}%)', { count, pct: percentage }) + progress.bar-track(value=percentage max='100' aria-label=translate('{{count}} companies', { count })) | #{percentage}% - p.section-note Based on #{reportJurisdiction.resolvedCount} unique resolved compan#{Number(reportJurisdiction.resolvedCount) === 1 ? 'y' : 'ies'}; #{reportJurisdiction.unresolvedCount === undefined ? 'an unavailable number of' : reportJurisdiction.unresolvedCount} tracker#{reportJurisdiction.unresolvedCount === 1 ? '' : 's'} remain unresolved. System API signatures are excluded. + p.section-note= translate(Number(reportJurisdiction.resolvedCount) === 1 ? 'Based on {{resolved}} unique resolved company; {{unresolved}} tracker remain unresolved. System API signatures are excluded.' : 'Based on {{resolved}} unique resolved companies; {{unresolved}} trackers remain unresolved. System API signatures are excluded.', { resolved: reportJurisdiction.resolvedCount, unresolved: reportJurisdiction.unresolvedCount === undefined ? translate('an unavailable number of') : reportJurisdiction.unresolvedCount }) else - p.notice.notice--quiet Company jurisdiction denominator is unavailable. + p.notice.notice--quiet= translate('Company jurisdiction denominator is unavailable.') section.report-section#domains(aria-labelledby='domains-title') - p.eyebrow App transparency - h2#domains-title Tracking domains + p.eyebrow= translate('App transparency') + h2#domains-title= translate('Tracking domains') if Array.isArray(reportAnalysis.trackingDomains) if reportAnalysis.trackingDomains.length > 0 - p #{reportAnalysis.trackingDomains.length} domain#{reportAnalysis.trackingDomains.length === 1 ? '' : 's'} declared in the app's privacy manifest. + p= translate(reportAnalysis.trackingDomains.length === 1 ? '{{count}} domain declared in the app’s privacy manifest.' : '{{count}} domains declared in the app’s privacy manifest.', { count: reportAnalysis.trackingDomains.length }) ul.data-list each domain in reportAnalysis.trackingDomains li= domain else - p.notice.notice--quiet No tracking domains were supplied in the privacy manifest data. + p.notice.notice--quiet= translate('No tracking domains were supplied in the privacy manifest data.') else - p.notice.notice--quiet Tracking domain data is unavailable for this analysis. + p.notice.notice--quiet= translate('Tracking domain data is unavailable for this analysis.') section.report-section#permissions(aria-labelledby='permissions-title') - p.eyebrow App transparency - h2#permissions-title Declared permissions + p.eyebrow= translate('App transparency') + h2#permissions-title= translate('Declared permissions') if Array.isArray(reportAnalysis.permissions) if reportAnalysis.permissions.length > 0 - p #{reportAnalysis.permissions.length} permission#{reportAnalysis.permissions.length === 1 ? '' : 's'} declared by the app. + p= translate(reportAnalysis.permissions.length === 1 ? '{{count}} permission declared by the app.' : '{{count}} permissions declared by the app.', { count: reportAnalysis.permissions.length }) ul.data-list each permission in reportAnalysis.permissions li span= readablePermission(permission) code= permission else - p.notice.notice--quiet No declared permissions were supplied for this analysis. + p.notice.notice--quiet= translate('No declared permissions were supplied for this analysis.') else - p.notice.notice--quiet Permission data is unavailable for this analysis. - p.section-note A declared permission describes what an app can ask for; it does not mean access was granted. #[a(href='https://reports.exodus-privacy.eu.org/en/info/permissions/' target='_blank' rel='noopener noreferrer') Learn more about permissions ↗]. + p.notice.notice--quiet= translate('Permission data is unavailable for this analysis.') + p.section-note + = translate('A declared permission describes what an app can ask for; it does not mean access was granted. ') + a(href='https://reports.exodus-privacy.eu.org/en/info/permissions/' target='_blank' rel='noopener noreferrer')= translate('Learn more about permissions ↗') + | . - p.stale-note Store details are retained from the last recorded metadata check and may be stale. They do not verify the current app version or current behaviour. + p.stale-note= translate('Store details are retained from the last recorded metadata check and may be stale. They do not verify the current app version or current behaviour.') diff --git a/views/layout.pug b/views/layout.pug index 7673574..bbb0fcb 100644 --- a/views/layout.pug +++ b/views/layout.pug @@ -9,7 +9,13 @@ doctype html - const searchData = (typeof data !== 'undefined' && data) || {} - const current = (typeof currentPath !== 'undefined' && currentPath) || '' - const assets = typeof assetPrefix === 'string' ? assetPrefix : '' -html(lang='en-GB') +- const localeCode = (typeof locale === 'string' && locale) || 'en' +- const translate = typeof t === 'function' ? t : (key, values) => key.replace(/\{\{([\w]+)\}\}/g, (_, name) => values && values[name] !== undefined ? values[name] : `{{${name}}}`) +- const localize = typeof localUrl === 'function' ? localUrl : (path) => path +- const languageHref = typeof languageUrl === 'function' ? languageUrl : (target) => `?lang=${target}` +- const localizedDate = typeof formatDate === 'function' ? formatDate : (value, options) => { const date = value instanceof Date ? value : new Date(value); return value && !Number.isNaN(date.getTime()) ? date.toLocaleDateString(localeCode === 'da' ? 'da-DK' : 'en-GB', options) : null } +- const localizedDateTime = typeof formatDateTime === 'function' ? formatDateTime : (value) => { const date = value instanceof Date ? value : new Date(value); return value && !Number.isNaN(date.getTime()) ? date.toLocaleString(localeCode === 'da' ? 'da-DK' : 'en-GB') : null } +html(lang=localeCode === 'da' ? 'da-DK' : 'en-GB') head meta(charset='utf-8') meta(name='viewport' content='width=device-width, initial-scale=1') @@ -36,28 +42,31 @@ html(lang='en-GB') link(rel='stylesheet' href=assets + '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/css/styles.css') link(rel='icon' type='image/png' href=assets + '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/images/trackercontrol.png') body - a.skip-link(href='#main-content') Skip to content + a.skip-link(href=localize('#main-content'))= translate('Skip to content') .platform-header - nav.platform-nav.site-width(aria-label='TrackerControl websites') - a(href='https://trackercontrol.org/') Project - a(href='https://trackercontrol.org/#android') Android app - a.is-current(href='/' aria-current='true') iOS app reports + nav.platform-nav.site-width(aria-label=translate('TrackerControl websites')) + a(href='https://trackercontrol.org/')= translate('Project') + a(href='https://trackercontrol.org/#android')= translate('Android app') + a.is-current(href=localize('/') aria-current='true')= translate('iOS app reports') header.site-header.site-width - a.wordmark(href='/' aria-label='TrackerControl for iOS homepage') + a.wordmark(href=localize('/') aria-label=translate('TrackerControl for iOS homepage')) img.wordmark__icon(src=assets + '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/images/trackercontrol.png' alt='' width='36' height='36') span TrackerControl - small for iOS - nav.site-nav(aria-label='Main navigation') - a(href='/#search' class=current === '/search' ? 'is-current' : undefined aria-current=current === '/search' ? 'page' : undefined) Search - a(href='/' class=current === '/' ? 'is-current' : undefined aria-current=current === '/' ? 'page' : undefined) Home - a(href='/trackers' class=current.startsWith('/tracker') || current === '/trackers' ? 'is-current' : undefined aria-current=current.startsWith('/tracker') || current === '/trackers' ? 'page' : undefined) Trackers - a(href='/companies' class=current.startsWith('/company') || current === '/companies' ? 'is-current' : undefined aria-current=current.startsWith('/company') || current === '/companies' ? 'page' : undefined) Companies - a(href='/statistics' class=current === '/statistics' ? 'is-current' : undefined aria-current=current === '/statistics' ? 'page' : undefined) Statistics - a(href='/about' class=current === '/about' ? 'is-current' : undefined aria-current=current === '/about' ? 'page' : undefined) About - form.nav-search(action='/search' method='GET' role='search') - label.sr-only(for='nav-search') Search apps - input#nav-search(type='search' name='search' value=searchData.search || '' placeholder='Search apps' autocomplete='off') - button.button.button--small(type='submit') Search + small= translate('for iOS') + nav.site-nav(aria-label=translate('Main navigation')) + a(href=localize('/#search') class=current === '/search' ? 'is-current' : undefined aria-current=current === '/search' ? 'page' : undefined)= translate('Search') + a(href=localize('/') class=current === '/' ? 'is-current' : undefined aria-current=current === '/' ? 'page' : undefined)= translate('Home') + a(href=localize('/trackers') class=current.startsWith('/tracker') || current === '/trackers' ? 'is-current' : undefined aria-current=current.startsWith('/tracker') || current === '/trackers' ? 'page' : undefined)= translate('Trackers') + a(href=localize('/companies') class=current.startsWith('/company') || current === '/companies' ? 'is-current' : undefined aria-current=current.startsWith('/company') || current === '/companies' ? 'page' : undefined)= translate('Companies') + a(href=localize('/statistics') class=current === '/statistics' ? 'is-current' : undefined aria-current=current === '/statistics' ? 'page' : undefined)= translate('Statistics') + a(href=localize('/about') class=current === '/about' ? 'is-current' : undefined aria-current=current === '/about' ? 'page' : undefined)= translate('About') + form.nav-search(action=localize('/search') method='GET' role='search') + label.sr-only(for='nav-search')= translate('Search apps') + input#nav-search(type='search' name='search' value=searchData.search || '' placeholder=translate('Search apps placeholder') autocomplete='off') + button.button.button--small(type='submit')= translate('Search') + nav.language-toggle(aria-label=translate('Language')) + a(href=languageHref('en') class=localeCode === 'en' ? 'is-current' : undefined aria-current=localeCode === 'en' ? 'true' : undefined)= translate('English') + a(href=languageHref('da') class=localeCode === 'da' ? 'is-current' : undefined aria-current=localeCode === 'da' ? 'true' : undefined)= translate('Dansk') main#main-content if typeof app === 'undefined' .site-width.page-content @@ -65,17 +74,17 @@ html(lang='en-GB') block app footer.site-footer .site-width.site-footer__inner - a.wordmark.wordmark--footer(href='/') + a.wordmark.wordmark--footer(href=localize('/')) img.wordmark__icon(src=assets + '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/images/trackercontrol.png' alt='' width='28' height='28') span TrackerControl - small for iOS - nav(aria-label='Footer navigation') - a(href='https://trackercontrol.org/') TrackerControl project - a(href='/about#contact') Contact - a(href='mailto:ios@trackercontrol.org') Suggest a correction - a(href='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/TrackerControl/tracker-control-ios' target='_blank' rel='noopener noreferrer') Source on GitHub + small= translate('for iOS') + nav(aria-label=translate('Footer navigation')) + a(href='https://trackercontrol.org/')= translate('TrackerControl project') + a(href=localize('/about#contact'))= translate('Contact') + a(href='mailto:ios@trackercontrol.org')= translate('Suggest a correction') + a(href='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/TrackerControl/tracker-control-ios' target='_blank' rel='noopener noreferrer')= translate('Source on GitHub') p.site-width.site-footer__credit - = 'Maintained by ' + = translate('Maintained by ') a(href='https://kollnig.net/') Konrad Kollnig | , Maastricht University. script(src=assets + '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/js/filter.js' defer) diff --git a/views/lookup.pug b/views/lookup.pug index 728a452..deebcae 100644 --- a/views/lookup.pug +++ b/views/lookup.pug @@ -8,69 +8,69 @@ block content - const total = typeof totalApps !== 'undefined' ? totalApps : 0 - const lookupMeta = (typeof jurisdictionMeta !== 'undefined' && jurisdictionMeta) || {} - const lookupLatest = typeof latestAnalysis !== 'undefined' ? latestAnalysis : null - - const safeDate = (value) => { const date = value instanceof Date ? value : new Date(value); return value && !Number.isNaN(date.getTime()) ? date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) : null } - h1= lookupEntry.name || 'Lookup' + - const safeDate = (value) => localizedDate(value, { day: 'numeric', month: 'short', year: 'numeric' }) + h1= lookupEntry.name || translate('Lookup') .lookup-context if lookupEntry.countryName - span.status-label= lookupEntry.region || 'Unknown region' - span= lookupEntry.countryName + span.status-label= lookupEntry.region ? translate(lookupEntry.region) : translate('Unknown region') + span= translate(lookupEntry.countryName) else if isTracker && lookupEntry.system - span.status-label System API + span.status-label= translate('System API') else - span.status-label= isTracker ? 'Unattributed' : 'Unknown country' + span.status-label= isTracker ? translate('Unattributed') : translate('Unknown country') if isTracker && lookupEntry.company - = ' Operated by ' + = ` ${translate('Operated by ')}` if lookupEntry.companySlug - a(href=`/company/${lookupEntry.companySlug}`)= lookupEntry.company + a(href=localize(`/company/${lookupEntry.companySlug}`))= lookupEntry.company else = lookupEntry.company - .lookup-summary(aria-label='Lookup summary') + .lookup-summary(aria-label=translate('Lookup summary')) .summary-item - strong= lookupEntry.appCount === undefined ? 'Unavailable' : lookupEntry.appCount - span= lookupEntry.appCount === 1 ? 'app contains it' : 'apps contain it' + strong= lookupEntry.appCount === undefined ? translate('Unavailable') : lookupEntry.appCount + span= translate(lookupEntry.appCount === 1 ? 'app contains it' : 'apps contain it') .summary-item - strong= lookupEntry.pct === undefined ? 'Unavailable' : `${lookupEntry.pct}%` - span of analysed apps - small of #{total} apps analysed + strong= lookupEntry.pct === undefined ? translate('Unavailable') : `${lookupEntry.pct}%` + span= translate('of analysed apps') + small= translate('of {{total}} apps analysed', { total }) if isTracker && lookupEntry.system .notice.notice--quiet(role='status') - p This signature identifies an Apple system API rather than a third-party tracker. It is reported for completeness and excluded from jurisdiction analysis. + p= translate('This signature identifies an Apple system API rather than a third-party tracker. It is reported for completeness and excluded from jurisdiction analysis.') if isTracker && !lookupEntry.company && !lookupEntry.system .notice.notice--warning(role='status') - p This tracker could not be matched to a company in the tracker database, so no jurisdiction is shown for it. + p= translate('This tracker could not be matched to a company in the tracker database, so no jurisdiction is shown for it.') if !isTracker && lookupEntry.trackers && lookupEntry.trackers.length > 0 section.lookup-trackers(aria-labelledby='lookup-trackers-title') - h2#lookup-trackers-title Trackers operated by #{lookupEntry.name} + h2#lookup-trackers-title= translate('Trackers operated by {{name}}', { name: lookupEntry.name }) p each tracker, index in lookupEntry.trackers if index > 0 = ', ' - a(href=`/tracker/${tracker.slug}`)= tracker.name + a(href=localize(`/tracker/${tracker.slug}`))= tracker.name | (#{tracker.appCount}) - p.section-note Numbers in brackets are the apps each tracker was detected in. + p.section-note= translate('Numbers in brackets are the apps each tracker was detected in.') if isTracker && typeof exodus !== 'undefined' && exodus p.lookup-exodus - a(href=`https://reports.exodus-privacy.eu.org/en/trackers/${exodus.id}/` target='_blank' rel='noopener noreferrer') Tracker profile on Exodus Privacy ↗ + a(href=`https://reports.exodus-privacy.eu.org/en/trackers/${exodus.id}/` target='_blank' rel='noopener noreferrer')= translate('Tracker profile on Exodus Privacy ↗') if exodus.categories && exodus.categories.length > 0 - | · Categories: #{exodus.categories.join(', ')} + = ` · ${translate('Categories: {{categories}}', { categories: exodus.categories.join(', ') })}` section.lookup-apps(aria-labelledby='lookup-apps-title') - h2#lookup-apps-title Apps + h2#lookup-apps-title= translate('Apps') p.section-note if page.total > 0 - | Showing #{page.from}–#{page.to} of #{page.total} apps, most reviewed first. + = translate('Showing {{from}}–{{to}} of {{total}} apps, most reviewed first.', { from: page.from, to: page.to, total: page.total }) else - | No apps to show. + = translate('No apps to show.') if page.apps && page.apps.length > 0 .app-list each listed in page.apps - - const listedTitle = listed.title || listed.appid || 'Untitled app' - a.app-row.app-row--linked(href=`/analysis/${listed.appid}`) + - const listedTitle = listed.title || listed.appid || translate('Untitled app') + a.app-row.app-row--linked(href=localize(`/analysis/${listed.appid}`)) if listed.icon img.app-icon.app-icon--small(src=listed.icon alt='' width='48' height='48' loading='lazy') else @@ -78,32 +78,34 @@ block content span.app-row__body span.app-row__name= listedTitle span.app-row__meta - | #{listed.trackerCount === undefined ? 'Unavailable' : listed.trackerCount} third-party trackers + = `${listed.trackerCount === undefined ? translate('Unavailable') : listed.trackerCount} ${translate('third-party trackers')}` if listed.category | · #{listed.category} if listed.analysed - | · analysed #{safeDate(listed.analysed) || 'date unavailable'} + = ` · ${translate('analysed {{date}}', { date: safeDate(listed.analysed) || translate('date unavailable') })}` if lookupMeta[listed.classification] - span.app-row__tag= lookupMeta[listed.classification].label + span.app-row__tag= translate(lookupMeta[listed.classification].label) span.app-row__arrow(aria-hidden='true') ↗ if page.totalPages > 1 - nav.pagination(aria-label='App list pages') + nav.pagination(aria-label=translate('App list pages')) if page.page > 1 - a(href=`?page=${page.page - 1}` rel='prev') ← Previous + a(href=localize(`?page=${page.page - 1}`) rel='prev') ← #{translate('Previous')} else - span.pagination-disabled ← Previous - span.pagination-current Page #{page.page} of #{page.totalPages} + span.pagination-disabled ← #{translate('Previous')} + span.pagination-current= translate('Page {{page}} of {{totalPages}}', { page: page.page, totalPages: page.totalPages }) if page.page < page.totalPages - a(href=`?page=${page.page + 1}` rel='next') Next → + a(href=localize(`?page=${page.page + 1}`) rel='next') #{translate('Next')} → else - span.pagination-disabled Next → + span.pagination-disabled= `${translate('Next')} →` p.section-note.lookup-caveat - | Detection means the tracker's software is built into the app. It does not by itself prove that data was transmitted while the app was in use. Percentages are shares of the #{total} apps analysed so far, which are not a random sample of the App Store — see #[a(href='/about#results') About] before quoting these figures. + = translate('Detection means the tracker’s software is built into the app. It does not by itself prove that data was transmitted while the app was in use. Percentages are shares of the {{total}} apps analysed so far, which are not a random sample of the App Store — see ', { total }) + a(href=localize('/about#results'))= translate('About') + = ` ${translate('before quoting these figures.')}` if lookupLatest && safeDate(lookupLatest) - | Data as of #{safeDate(lookupLatest)}. + = ` ${translate('Data as of {{date}}.', { date: safeDate(lookupLatest) })}` p.page-links - a(href=isTracker ? '/trackers' : '/companies') ← All #{isTracker ? 'trackers' : 'companies'} + a(href=localize(isTracker ? '/trackers' : '/companies')) ← #{translate(isTracker ? 'All trackers' : 'All companies')} block app diff --git a/views/request-analysis.pug b/views/request-analysis.pug index a518db4..2a16988 100644 --- a/views/request-analysis.pug +++ b/views/request-analysis.pug @@ -1,21 +1,21 @@ extends layout block content - - const requestedId = (typeof appId !== 'undefined' && appId) || 'Unknown bundle ID' - p.eyebrow Analysis queue - h1 Request app analysis + - const requestedId = (typeof appId !== 'undefined' && appId) || translate('Unknown bundle ID') + p.eyebrow= translate('Analysis queue') + h1= translate('Request app analysis') if typeof error !== 'undefined' && error .notice.notice--warning(role='alert')= error .request-card - p This app has not been added to the analysis queue yet. Nothing is queued before you confirm below. - p Free apps from the UK App Store can be analysed on a real iPhone. A confirmed request is added to the queue and may take time to complete. + p= translate('This app has not been added to the analysis queue yet. Nothing is queued before you confirm below.') + p= translate('Free apps from the UK App Store can be analysed on a real iPhone. A confirmed request is added to the queue and may take time to complete.') dl.request-details - dt Bundle ID + dt= translate('Bundle ID') dd code= requestedId - form.request-form(action=`/analysis/${requestedId}` method='POST') - button.button(type='submit') Confirm and request analysis + form.request-form(action=localize(`/analysis/${requestedId}`) method='POST') + button.button(type='submit')= translate('Confirm and request analysis') p.page-links - a(href='/#search') ← Back to search + a(href=localize('/#search')) ← #{translate('Back to search')} block app diff --git a/views/statistics.pug b/views/statistics.pug index 355db1f..b449df4 100644 --- a/views/statistics.pug +++ b/views/statistics.pug @@ -7,19 +7,19 @@ block content - const trackers = (typeof topTrackersEnriched !== 'undefined' && topTrackersEnriched) || [] - const alternatives = (typeof europeanAlternatives !== 'undefined' && europeanAlternatives) || [] - const classOrder = ['us_only', 'mixed_with_us_cn', 'mixed_with_us', 'mixed_no_us', 'european_only', 'unresolved_only', 'no_tracking'] - - const safeDate = (value) => { const date = value instanceof Date ? value : new Date(value); return value && !Number.isNaN(date.getTime()) ? date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) : null } + - const safeDate = (value) => localizedDate(value, { day: 'numeric', month: 'short', year: 'numeric' }) - const totalApps = statsHeadlines.totalApps !== undefined ? statsHeadlines.totalApps : (stats.totalApps || 0) - p.eyebrow Aggregate results - h1 Detailed statistics - p.lede In-depth analysis of tracking jurisdiction across #{totalApps} analysed iOS apps. These figures describe the analysed sample, which is not random and is not all iOS apps. + p.eyebrow= translate('Aggregate results') + h1= translate('Detailed statistics') + p.lede= translate('In-depth analysis of tracking jurisdiction across {{total}} analysed iOS apps. These figures describe the analysed sample, which is not random and is not all iOS apps.', { total: totalApps }) p.page-links - a(href='/') ← Back to overview + a(href=localize('/')) ← #{translate('Back to overview')} section.stats-section(aria-labelledby='stats-jurisdiction-title') .section-heading - p.eyebrow Classification - h2#stats-jurisdiction-title Jurisdiction breakdown + p.eyebrow= translate('Classification') + h2#stats-jurisdiction-title= translate('Jurisdiction breakdown') if stats.classificationCounts && Object.keys(stats.classificationCounts).length > 0 .bar-list each cls in classOrder @@ -29,144 +29,149 @@ block content if count !== undefined && count !== null .bar-item .bar-label - span= meta.label || cls.replace(/_/g, ' ') - strong #{count} apps (#{pct === undefined ? '0' : pct}%) - progress.bar-track(value=Number(pct) || 0 max='100' aria-label=`${meta.label || cls} ${count} apps`) + span= meta.label ? translate(meta.label) : cls.replace(/_/g, ' ') + strong= translate('{{count}} apps ({{pct}}%)', { count, pct: pct === undefined ? '0' : pct }) + progress.bar-track(value=Number(pct) || 0 max='100' aria-label=translate('{{count}} apps', { count })) | #{pct === undefined ? '0' : pct}% else - p.notice.notice--quiet Jurisdiction data is unavailable. + p.notice.notice--quiet= translate('Jurisdiction data is unavailable.') section.stats-section(aria-labelledby='stats-trackers-title') .section-heading - p.eyebrow Embedded software - h2#stats-trackers-title Most prevalent trackers + p.eyebrow= translate('Embedded software') + h2#stats-trackers-title= translate('Most prevalent trackers') if trackers.length > 0 - p.section-note Most frequently embedded third-party tracker libraries, with parent company and jurisdiction. - .table-scroll(role='region' tabindex='0' aria-label='Most prevalent trackers') + p.section-note= translate('Most frequently embedded third-party tracker libraries, with parent company and jurisdiction.') + .table-scroll(role='region' tabindex='0' aria-label=translate('Most prevalent trackers')) table.data-table - caption Most prevalent trackers + caption= translate('Most prevalent trackers') thead tr - th(scope='col') Rank - th(scope='col') Tracker - th(scope='col') Parent company - th(scope='col') Country - th(scope='col') Region - th(scope='col') Apps - th(scope='col') Share + th(scope='col')= translate('Rank') + th(scope='col')= translate('Tracker') + th(scope='col')= translate('Parent company') + th(scope='col')= translate('Country') + th(scope='col')= translate('Region') + th(scope='col')= translate('Apps') + th(scope='col')= translate('Share') tbody each tracker, index in trackers tr td= index + 1 td if tracker.slug - a(href=`/tracker/${tracker.slug}`)= tracker.name + a(href=localize(`/tracker/${tracker.slug}`))= tracker.name else - = tracker.name || 'Unknown tracker' + = tracker.name || translate('Unknown tracker') td if tracker.companySlug - a(href=`/company/${tracker.companySlug}`)= tracker.company + a(href=localize(`/company/${tracker.companySlug}`))= tracker.company else - = tracker.company || 'Unknown company' - td= tracker.countryName || 'Unknown' - td= tracker.region || 'Unresolved' + = tracker.company || translate('Unknown company') + td= tracker.countryName ? translate(tracker.countryName) : translate('Unknown') + td= tracker.region ? translate(tracker.region) : translate('Unresolved') td= tracker.count - td= tracker.pct === undefined ? 'Unavailable' : `${tracker.pct}%` + td= tracker.pct === undefined ? translate('Unavailable') : `${tracker.pct}%` else - p.notice.notice--quiet No tracker prevalence data is available yet. + p.notice.notice--quiet= translate('No tracker prevalence data is available yet.') section.stats-section(aria-labelledby='stats-companies-title') .section-heading - p.eyebrow Company footprint - h2#stats-companies-title Most prevalent tracking companies + p.eyebrow= translate('Company footprint') + h2#stats-companies-title= translate('Most prevalent tracking companies') if stats.topCompaniesSorted && stats.topCompaniesSorted.length > 0 - p.section-note Unique companies by ultimate parent, ranked by the number of analysed apps in which they appear. - .table-scroll(role='region' tabindex='0' aria-label='Most prevalent tracking companies') + p.section-note= translate('Unique companies by ultimate parent, ranked by the number of analysed apps in which they appear.') + .table-scroll(role='region' tabindex='0' aria-label=translate('Most prevalent tracking companies')) table.data-table - caption Most prevalent tracking companies + caption= translate('Most prevalent tracking companies') thead tr - th(scope='col') Rank - th(scope='col') Company - th(scope='col') Country - th(scope='col') Region - th(scope='col') Apps + th(scope='col')= translate('Rank') + th(scope='col')= translate('Company') + th(scope='col')= translate('Country') + th(scope='col')= translate('Region') + th(scope='col')= translate('Apps') tbody each company, index in stats.topCompaniesSorted tr td= index + 1 td if company.slug - a(href=`/company/${company.slug}`)= company.name + a(href=localize(`/company/${company.slug}`))= company.name else - = company.name || 'Unknown company' - td= company.countryName || 'Unknown' - td= company.region || 'Unresolved' + = company.name || translate('Unknown company') + td= company.countryName ? translate(company.countryName) : translate('Unknown') + td= company.region ? translate(company.region) : translate('Unresolved') td= company.count else - p.notice.notice--quiet No company prevalence data is available yet. + p.notice.notice--quiet= translate('No company prevalence data is available yet.') section.stats-section(aria-labelledby='stats-category-title') .section-heading - p.eyebrow App categories - h2#stats-category-title Jurisdiction by App Store category + p.eyebrow= translate('App categories') + h2#stats-category-title= translate('Jurisdiction by App Store category') if stats.categoriesSorted && stats.categoriesSorted.length > 0 - p.section-note US-only and European-only tracking percentages by category. Categories with fewer than three apps are excluded from this comparison. - .table-scroll(role='region' tabindex='0' aria-label='Jurisdiction by App Store category') + p.section-note= translate('US-only and European-only tracking percentages by category. Categories with fewer than three apps are excluded from this comparison.') + .table-scroll(role='region' tabindex='0' aria-label=translate('Jurisdiction by App Store category')) table.data-table - caption Jurisdiction by App Store category + caption= translate('Jurisdiction by App Store category') thead tr - th(scope='col') Category - th(scope='col') Apps - th(scope='col') US-only - th(scope='col') European-only + th(scope='col')= translate('Category') + th(scope='col')= translate('Apps') + th(scope='col')= translate('US-only') + th(scope='col')= translate('European-only') tbody each category in stats.categoriesSorted tr - td= category.name || 'Unknown' + td= category.name || translate('Unknown') td= category.total td .inline-stat - progress.bar-track(value=Number(category.usOnlyPct) || 0 max='100' aria-label=`${category.usOnlyPct || 0}% US-only`) - span= category.usOnlyPct === undefined ? 'Unavailable' : `${category.usOnlyPct}%` + progress.bar-track(value=Number(category.usOnlyPct) || 0 max='100' aria-label=translate('{{pct}}% US-only', { pct: category.usOnlyPct || 0 })) + span= category.usOnlyPct === undefined ? translate('Unavailable') : `${category.usOnlyPct}%` td .inline-stat - progress.bar-track(value=Number(category.euOnlyPct) || 0 max='100' aria-label=`${category.euOnlyPct || 0}% European-only`) - span= category.euOnlyPct === undefined ? 'Unavailable' : `${category.euOnlyPct}%` + progress.bar-track(value=Number(category.euOnlyPct) || 0 max='100' aria-label=translate('{{pct}}% European-only', { pct: category.euOnlyPct || 0 })) + span= category.euOnlyPct === undefined ? translate('Unavailable') : `${category.euOnlyPct}%` else - p.notice.notice--quiet Category comparisons are unavailable. + p.notice.notice--quiet= translate('Category comparisons are unavailable.') section.stats-section(aria-labelledby='stats-alternatives-title') .section-heading - p.eyebrow European alternatives - h2#stats-alternatives-title European alternatives + p.eyebrow= translate('European alternatives') + h2#stats-alternatives-title= translate('European alternatives') if alternatives.length > 0 - p.section-note For each core tracking function, the dominant US provider and whether a viable European alternative appears in the reference data. - .table-scroll(role='region' tabindex='0' aria-label='European alternatives') + p.section-note= translate('For each core tracking function, the dominant US provider and whether a viable European alternative appears in the reference data.') + .table-scroll(role='region' tabindex='0' aria-label=translate('European alternatives')) table.data-table - caption European alternatives + caption= translate('European alternatives') thead tr - th(scope='col') Function - th(scope='col') Dominant provider (US) - th(scope='col') European alternative - th(scope='col') Availability + th(scope='col')= translate('Function') + th(scope='col')= translate('Dominant provider (US)') + th(scope='col')= translate('European alternative') + th(scope='col')= translate('Availability') tbody each alternative in alternatives tr - td= alternative.function || 'Unknown' - td= alternative.usDominant || 'Unknown' - td= alternative.euAlternative || 'None recorded' - td= alternative.adopted === undefined ? 'Unavailable' : (alternative.adopted ? 'Limited' : 'None recorded') + td= alternative.function ? translate(alternative.function) : translate('Unknown') + td= alternative.usDominant ? translate(alternative.usDominant) : translate('Unknown') + td= alternative.euAlternative ? translate(alternative.euAlternative) : translate('None recorded') + td= alternative.adopted === undefined ? translate('Unavailable') : (alternative.adopted ? translate('Limited') : translate('None recorded')) else - p.notice.notice--quiet European alternative data is unavailable. + p.notice.notice--quiet= translate('European alternative data is unavailable.') p.section-actions - a.button.button--secondary(href='/trackers') Browse all trackers - a.button.button--secondary(href='/companies') Browse all companies + a.button.button--secondary(href=localize('/trackers'))= translate('Browse all trackers') + a.button.button--secondary(href=localize('/companies'))= translate('Browse all companies') p.section-note - | Based on the Xray Tracker Database#{typeof xrayCompanyCount !== 'undefined' && xrayCompanyCount !== null ? ` (${xrayCompanyCount} tracker companies)` : ''}. #{statsHeadlines.latestAnalysis && safeDate(statsHeadlines.latestAnalysis) ? `Data as of ${safeDate(statsHeadlines.latestAnalysis)}. ` : ''} - a(href='/about#how-it-works') How these numbers are produced + = translate('Based on the Xray Tracker Database') + if typeof xrayCompanyCount !== 'undefined' && xrayCompanyCount !== null + = ` (${xrayCompanyCount} ${translate('tracker companies')})` + | . + if statsHeadlines.latestAnalysis && safeDate(statsHeadlines.latestAnalysis) + = ` ${translate('Data as of {{date}}.', { date: safeDate(statsHeadlines.latestAnalysis) })}` + a(href=localize('/about#how-it-works'))= translate('How these numbers are produced') block app From 589b85d49c473f992ff6548cfd086a508b1a19e0 Mon Sep 17 00:00:00 2001 From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:10:43 +0200 Subject: [PATCH 2/3] Fix purge expectations and disclaimer spacing --- test/analysisPurge.test.js | 5 +++++ views/form.pug | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/test/analysisPurge.test.js b/test/analysisPurge.test.js index 6628d2a..28b9c5e 100644 --- a/test/analysisPurge.test.js +++ b/test/analysisPurge.test.js @@ -82,10 +82,15 @@ test('an uploaded analysis purges the pages it changed', async (t) => { assert.equal(purges[0].url, 'https://api.cloudflare.test/client/v4/zones/zone-id/purge_cache'); assert.deepEqual(purges[0].body.files, [ 'https://example.test/analysis/com.example.app', + 'https://example.test/da/analysis/com.example.app', 'https://example.test/', + 'https://example.test/da/', 'https://example.test/statistics', + 'https://example.test/da/statistics', 'https://example.test/trackers', + 'https://example.test/da/trackers', 'https://example.test/companies', + 'https://example.test/da/companies', 'https://example.test/sitemap.xml' ]); }); diff --git a/views/form.pug b/views/form.pug index 18c9860..9568518 100644 --- a/views/form.pug +++ b/views/form.pug @@ -151,7 +151,7 @@ block content if isHomepage p.site-disclaimer strong= translate('Please note:') - = translate('reports may be out of date, incomplete or incorrect. They describe an analysed app version on the date shown, and automated analysis makes mistakes. ') + = ' ' + translate('reports may be out of date, incomplete or incorrect. They describe an analysed app version on the date shown, and automated analysis makes mistakes. ') a(href=localize('/about#disclaimer'))= translate('Read the full disclaimer') | . From 2da038471f23df6f3363220aea3e896535f9fb64 Mon Sep 17 00:00:00 2001 From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:11:57 +0200 Subject: [PATCH 3/3] Preserve spacing before statistics link --- views/statistics.pug | 1 + 1 file changed, 1 insertion(+) diff --git a/views/statistics.pug b/views/statistics.pug index b449df4..d107925 100644 --- a/views/statistics.pug +++ b/views/statistics.pug @@ -172,6 +172,7 @@ block content | . if statsHeadlines.latestAnalysis && safeDate(statsHeadlines.latestAnalysis) = ` ${translate('Data as of {{date}}.', { date: safeDate(statsHeadlines.latestAnalysis) })}` + = ' ' a(href=localize('/about#how-it-works'))= translate('How these numbers are produced') block app