[READ-ONLY] a fast, modern browser for the npm registry
at main 449 lines 14 kB view raw
1import { 2 type createLunaria, 3 type Locale, 4 type LunariaConfig, 5 type LunariaStatus, 6 type StatusEntry, 7} from '@lunariajs/core' 8import { BaseStyles, CustomStyles } from './styles.ts' 9 10export function html( 11 strings: TemplateStringsArray, 12 ...values: ((string | number) | (string | number)[])[] 13) { 14 const treatedValues = values.map(value => (Array.isArray(value) ? value.join('') : value)) 15 16 return String.raw({ raw: strings }, ...treatedValues) 17} 18 19type LunariaInstance = Awaited<ReturnType<typeof createLunaria>> 20 21function collapsePath(path: string) { 22 const basesToHide = ['src/content/docs/en/', 'src/i18n/en/', 'src/content/docs/', 'src/content/'] 23 24 for (const base of basesToHide) { 25 const newPath = path.replace(base, '') 26 27 if (newPath === path) continue 28 return newPath 29 } 30 31 return path 32} 33 34export const Page = ( 35 config: LunariaConfig, 36 status: LunariaStatus, 37 lunaria: LunariaInstance, 38): string => { 39 return html` 40 <!doctype html> 41 <html dir="ltr" lang="en"> 42 <head> 43 ${Meta} ${BaseStyles} ${CustomStyles} 44 </head> 45 <body> 46 ${Body(config, status, lunaria)} 47 </body> 48 </html> 49 ` 50} 51 52export const Meta = html` 53 <meta charset="utf-8" /> 54 <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" /> 55 <title>npmx - Translation Status</title> 56 <meta 57 name="description" 58 content="Translation progress tracker for the npmx site. See how much has been translated in your language and get involved!" 59 /> 60 <meta property="last-build" content="${new Date(Date.now()).toString()}" /> 61 <link rel="canonical" href="https://i18n.npmx.dev/" /> 62 <link rel="preconnect" href="https://fonts.googleapis.com"> 63 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 64 <link href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&family=Geist:wght@100..900&display=swap" rel="stylesheet"> 65 <meta property="og:title" content="npmx - Translation Status" /> 66 <meta property="og:type" content="website" /> 67 <meta property="og:url" content="https://i18n.npmx.dev/" /> 68 <meta 69 property="og:description" 70 content="Translation progress tracker for the npmx site. See how much has been translated in your language and get involved!" 71 /> 72 <link rel="icon" href="https://npmx.dev/favicon.ico" type="image/x-icon" /> 73 <link rel="icon" href="https://npmx.dev/favicon.svg" type="image/svg+xml" /> 74` 75 76export const Body = ( 77 config: LunariaConfig, 78 status: LunariaStatus, 79 lunaria: LunariaInstance, 80): string => { 81 return html` 82 <main> 83 <div class="limit-to-viewport"> 84 <h1>npmx Translation Status</h1> 85 ${TitleParagraph} ${StatusByLocale(config, status, lunaria)} 86 </div> 87 ${StatusByFile(config, status, lunaria)} 88 </main> 89 ` 90} 91 92export const StatusByLocale = ( 93 config: LunariaConfig, 94 status: LunariaStatus, 95 lunaria: LunariaInstance, 96): string => { 97 const { locales } = config 98 return html` 99 <h2 id="by-locale"> 100 <a href="#by-locale">Translation progress by locale</a> 101 </h2> 102 ${locales.map(locale => LocaleDetails(status, locale, lunaria))} 103 ` 104} 105 106export const LocaleDetails = ( 107 status: LunariaStatus, 108 locale: Locale, 109 lunaria: LunariaInstance, 110): string => { 111 const { label, lang } = locale 112 113 const missingFiles = status.filter( 114 file => 115 file.localizations.find(localization => localization.lang === lang)?.status === 'missing', 116 ) 117 const outdatedFiles = status.filter(file => { 118 const localization = file.localizations.find(localization => localization.lang === lang) 119 120 if (!localization || localization.status === 'missing') return false 121 if (file.type === 'dictionary') 122 return 'missingKeys' in localization ? localization.missingKeys.length > 0 : false 123 124 return ( 125 localization.status === 'outdated' || 126 ('missingKeys' in localization && localization.missingKeys.length > 0) 127 ) 128 }) 129 130 const doneLength = status.length - outdatedFiles.length - missingFiles.length 131 132 const links = lunaria.gitHostingLinks() 133 134 return html` 135 <details class="progress-details"> 136 <summary> 137 <strong>${label} (${lang})</strong> 138 <br /> 139 <span class="progress-summary"> 140 ${doneLength.toString()} done, ${outdatedFiles.length.toString()} outdated, 141 ${missingFiles.length.toString()} missing 142 </span> 143 <br /> 144 ${ProgressBar(status.length, outdatedFiles.length, missingFiles.length)} 145 </summary> 146 ${outdatedFiles.length > 0 ? OutdatedFiles(outdatedFiles, lang, lunaria) : ''} 147 ${ 148 missingFiles.length > 0 149 ? html`<h3 class="capitalize">Missing</h3> 150 <ul> 151 ${missingFiles.map(file => { 152 const localization = file.localizations.find( 153 localization => localization.lang === lang, 154 )! 155 return html` 156 <li> 157 ${Link(links.source(file.source.path), collapsePath(file.source.path))} 158 ${CreateFileLink(links.create(localization.path), 'Create file')} 159 </li> 160 ` 161 })} 162 </ul>` 163 : '' 164 } 165 ${ 166 missingFiles.length == 0 && outdatedFiles.length == 0 167 ? html` 168 <p>This translation is complete, amazing job! 🎉</p> 169 ` 170 : '' 171 } 172 </details> 173 ` 174} 175 176export const OutdatedFiles = ( 177 outdatedFiles: LunariaStatus, 178 lang: string, 179 lunaria: LunariaInstance, 180): string => { 181 return html` 182 <h3 class="capitalize">Outdated</h3> 183 <ul> 184 ${outdatedFiles.map(file => { 185 const localization = file.localizations.find(localization => localization.lang === lang)! 186 187 const isMissingKeys = 188 localization.status !== 'missing' && 189 'missingKeys' in localization && 190 localization.missingKeys.length > 0 191 192 return html` 193 <li> 194 ${ 195 isMissingKeys 196 ? html` 197 <details> 198 <summary>${ContentDetailsLinks(file, lang, lunaria)}</summary> 199 <h4>Missing keys</h4> 200 <ul> 201 ${localization.missingKeys.map(key => html`<li>${(key as unknown as string[]).join('.')}</li>`)} 202 </ul> 203 </details> 204 ` 205 : html` ${ContentDetailsLinks(file, lang, lunaria)} ` 206 } 207 </li> 208 ` 209 })} 210 </ul> 211 ` 212} 213 214export const StatusByFile = ( 215 config: LunariaConfig, 216 status: LunariaStatus, 217 lunaria: LunariaInstance, 218): string => { 219 const { locales } = config 220 return html` 221 <h2 id="by-file"> 222 <a href="#by-file">Translation status by file</a> 223 </h2> 224 <table class="status-by-file"> 225 <thead> 226 <tr> 227 ${['File', ...locales.map(({ lang }) => lang)].map(col => html`<th>${col}</th>`)} 228 </tr> 229 </thead> 230 ${TableBody(status, locales, lunaria)} 231 </table> 232 <sup class="capitalize">❌ missing &nbsp; 🔄 outdated &nbsp; ✔ done </sup> 233 ` 234} 235 236export const TableBody = ( 237 status: LunariaStatus, 238 locales: Locale[], 239 lunaria: LunariaInstance, 240): string => { 241 const links = lunaria.gitHostingLinks() 242 243 return html` 244 <tbody> 245 ${status.map( 246 file => 247 html` 248 <tr> 249 <td>${Link(links.source(file.source.path), collapsePath(file.source.path))}</td> 250 ${locales.map(({ lang }) => { 251 return TableContentStatus(file.localizations, lang, lunaria) 252 })} 253 </td> 254 </tr>`, 255 )} 256 </tbody> 257 ` 258} 259 260export const TableContentStatus = ( 261 localizations: StatusEntry['localizations'], 262 lang: string, 263 lunaria: LunariaInstance, 264): string => { 265 const localization = localizations.find(localization => localization.lang === lang)! 266 const isMissingKeys = 'missingKeys' in localization && localization.missingKeys.length > 0 267 const status = isMissingKeys ? 'outdated' : localization.status 268 const links = lunaria.gitHostingLinks() 269 const link = 270 status === 'missing' ? links.create(localization.path) : links.source(localization.path) 271 return html`<td>${EmojiFileLink(link, status)}</td>` 272} 273 274export const ContentDetailsLinks = ( 275 fileStatus: StatusEntry, 276 lang: string, 277 lunaria: LunariaInstance, 278): string => { 279 const localization = fileStatus.localizations.find(localization => localization.lang === lang)! 280 const isMissingKeys = 281 localization.status !== 'missing' && 282 'missingKeys' in localization && 283 localization.missingKeys.length > 0 284 285 const links = lunaria.gitHostingLinks() 286 287 return html` 288 ${Link(links.source(fileStatus.source.path), collapsePath(fileStatus.source.path))} 289 (${Link( 290 links.source(localization.path), 291 isMissingKeys ? 'incomplete translation' : 'outdated translation', 292 )}, 293 ${Link( 294 links.history( 295 fileStatus.source.path, 296 'git' in localization 297 ? new Date(localization.git.latestTrackedCommit.date).toISOString() 298 : undefined, 299 ), 300 'source change history', 301 )}) 302 ` 303} 304 305export const EmojiFileLink = ( 306 href: string | null, 307 type: 'missing' | 'outdated' | 'up-to-date', 308): string => { 309 const statusTextOpts = { 310 'missing': 'missing', 311 'outdated': 'outdated', 312 'up-to-date': 'done', 313 } as const 314 315 const statusEmojiOpts = { 316 'missing': '❌', 317 'outdated': '🔄', 318 'up-to-date': '✔', 319 } as const 320 321 return href 322 ? html`<a href="${href}" title="${statusTextOpts[type]}"> 323 <span aria-hidden="true">${statusEmojiOpts[type]}</span> 324 </a>` 325 : html`<span title="${statusTextOpts[type]}"> 326 <span aria-hidden="true">${statusEmojiOpts[type]}</span> 327 </span>` 328} 329 330export const Link = (href: string, text: string): string => { 331 return html`<a href="${href}">${text}</a>` 332} 333 334export const CreateFileLink = (href: string, text: string): string => { 335 return html`<a class="create-button" href="${href}">${text}</a>` 336} 337 338export const ProgressBar = ( 339 total: number, 340 outdated: number, 341 missing: number, 342 { size = 20 }: { size?: number } = {}, 343): string => { 344 const outdatedSize = Math.round((outdated / total) * size) 345 const missingSize = Math.round((missing / total) * size) 346 const doneSize = size - outdatedSize - missingSize 347 348 const getBlocks = (size: number, type: 'missing' | 'outdated' | 'up-to-date') => { 349 const items = [] 350 for (let i = 0; i < size; i++) { 351 items.push(html`<div class="${type}-bar"></div>`) 352 } 353 return items 354 } 355 356 return html` 357 <div class="progress-bar" aria-hidden="true"> 358 ${getBlocks(doneSize, 'up-to-date')} ${getBlocks(outdatedSize, 'outdated')} 359 ${getBlocks(missingSize, 'missing')} 360 </div> 361 ` 362} 363 364export const TitleParagraph = html` 365 <p> 366 If you're interested in helping us translate 367 <a href="https://npmx.dev/">npmx.dev</a> into one of the languages listed below, you've come to 368 the right place! This auto-updating page always lists all the content that could use your help 369 right now. 370 </p> 371 <p> 372 Before starting, please read our 373 <a href="https://github.com/npmx-dev/npmx.dev/blob/main/CONTRIBUTING.md#localization-i18n" 374 >localization (i18n) guide</a 375 > 376 to learn about our translation process and how you can get involved. 377 </p> 378` 379 380/** 381 * Build an SVG file showing a summary of each language's translation progress. 382 */ 383export const SvgSummary = (config: LunariaConfig, status: LunariaStatus): string => { 384 const localeHeight = 56 // Each locale’s summary is 56px high. 385 const svgHeight = localeHeight * Math.ceil(config.locales.length / 2) 386 return html`<svg 387 xmlns="http://www.w3.org/2000/svg" 388 viewBox="0 0 400 ${svgHeight}" 389 font-family="ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'" 390 > 391 ${config.locales 392 .map(locale => SvgLocaleSummary(status, locale)) 393 .sort((a, b) => b.progress - a.progress) 394 .map( 395 ({ svg }, index) => 396 html`<g transform="translate(${(index % 2) * 215} ${Math.floor(index / 2) * 56})" 397 >${svg}</g 398 >`, 399 )} 400 </svg>` 401} 402 403function SvgLocaleSummary( 404 status: LunariaStatus, 405 { label, lang }: Locale, 406): { svg: string; progress: number } { 407 const missingFiles = status.filter( 408 file => 409 file.localizations.find(localization => localization.lang === lang)?.status === 'missing', 410 ) 411 const outdatedFiles = status.filter(file => { 412 const localization = file.localizations.find(localization => localization.lang === lang) 413 if (!localization || localization.status === 'missing') { 414 return false 415 } else if (file.type === 'dictionary') { 416 return 'missingKeys' in localization ? localization.missingKeys.length > 0 : false 417 } else { 418 return ( 419 localization.status === 'outdated' || 420 ('missingKeys' in localization && localization.missingKeys.length > 0) 421 ) 422 } 423 }) 424 425 const doneLength = status.length - outdatedFiles.length - missingFiles.length 426 const barWidth = 184 427 const doneFraction = doneLength / status.length 428 const outdatedFraction = outdatedFiles.length / status.length 429 const doneWidth = (doneFraction * barWidth).toFixed(2) 430 const outdatedWidth = ((outdatedFraction + doneFraction) * barWidth).toFixed(2) 431 432 return { 433 progress: doneFraction, 434 svg: html`<text x="0" y="12" font-size="11" font-weight="600" fill="#999" 435 >${label} (${lang})</text 436 > 437 <text x="0" y="26" font-size="9" fill="#999"> 438 ${ 439 missingFiles.length == 0 && outdatedFiles.length == 0 440 ? '100% complete, amazing job! 🎉' 441 : html`${doneLength} done, ${outdatedFiles.length} outdated, ${missingFiles.length} 442 missing` 443 } 444 </text> 445 <rect x="0" y="34" width="${barWidth}" height="8" fill="#999" opacity="0.25"></rect> 446 <rect x="0" y="34" width="${outdatedWidth}" height="8" fill="#fb923c"></rect> 447 <rect x="0" y="34" width="${doneWidth}" height="8" fill="#c084fc"></rect>`, 448 } 449}