forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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 🔄 outdated ✔ 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}