i18n: add freshness to Lunaria dashboard (#4192)

Co-authored-by: delucis <[email protected]>
Co-authored-by: Chris Swithinbank <[email protected]>
This commit is contained in:
HiDeoo
2026-09-23 11:25:58 +02:00
committed by GitHub
co-authored by delucis Chris Swithinbank
parent b0d3b8e721
commit f5f7768598
4 changed files with 734 additions and 1 deletions
+172 -1
View File
@@ -1,4 +1,6 @@
import { html, type Locale } from '@lunariajs/core';
import { readFileSync } from 'node:fs';
import { html, type Locale, type LunariaConfig, type LunariaStatus } from '@lunariajs/core';
import { StatusByFile } from '@lunariajs/core/components';
import { defineConfig } from '@lunariajs/core/config';
export const locales = [
@@ -70,7 +72,15 @@ export default defineConfig({
},
},
renderer: {
overrides: {
statusByFile: (config, status) => StatusByFile(config, status) + Freshness(config, status),
},
slots: {
head: () => html`
<script type="module">
${readFileSync(new URL('./lunaria/freshness.mjs', import.meta.url), 'utf8')};
</script>
`,
afterTitle: () => html`
<p>
If you're interested in helping us translate
@@ -90,3 +100,164 @@ export default defineConfig({
},
},
});
const Freshness = (config: LunariaConfig, status: LunariaStatus) => {
const { name, branch, rootDir } = config.repository;
const data = {
locales: config.locales.map(({ lang, label }) => ({
lang,
label,
files: status
.flatMap((entry) => {
const translation = entry.localizations.find((translation) => translation.lang === lang);
if (!translation || translation.status === 'missing') return [];
const base = config.dashboard.basesToHide?.find(
(base) => base !== '' && entry.source.path.includes(base)
);
const updatedAt = translation.git.latestTrackedCommit.date.getTime();
const sourceUpdatedAt = entry.source.git.latestTrackedCommit.date.getTime();
return [
{
path: entry.source.path.replace(base ?? '', ''),
url: `https://github.com/${name}/blob/${branch}/${rootDir}/${translation.path}`,
isOutdated: translation.status === 'outdated',
updatedAt,
sourceUpdatedAt,
},
];
})
.sort((a, b) => a.updatedAt - b.updatedAt || a.path.localeCompare(b.path)),
})),
};
return html`
<translation-freshness>
<script type="application/json">
${JSON.stringify(data)}
</script>
<h2 id="freshness">
<a href="#freshness">Translation freshness</a>
</h2>
<form>
<p>
<label for="freshness-old-update-age">Old translation threshold (months)</label>
<input
id="freshness-old-update-age"
aria-describedby="freshness-old-update-age-desc"
type="number"
min="1"
max="120"
step="1"
value="12"
name="oldUpdateAge"
required
/>
<small id="freshness-old-update-age-desc">
Show locales with outdated translations last updated more than this many months ago.
</small>
</p>
<p>
<label for="freshness-recent-update-age">Recent activity window (months)</label>
<input
id="freshness-recent-update-age"
aria-describedby="freshness-recent-update-age-desc"
type="number"
min="1"
max="120"
step="1"
value="3"
name="recentUpdateAge"
required
/>
<small id="freshness-recent-update-age-desc">
Show how many translation files were updated within this many months.
</small>
</p>
<button type="submit">Compute freshness</button>
</form>
<p id="freshness-status" role="status"></p>
<div id="freshness-results" hidden>
<div id="freshness-overview">
<ul class="freshness-legend">
<li>
<span class="freshness-up-to-date" aria-hidden="true"></span>
Up to date
</li>
<li>
<span class="freshness-outdated-within" aria-hidden="true"></span>
<span id="freshness-outdated-within-label"></span>
</li>
<li>
<span class="freshness-outdated-beyond" aria-hidden="true"></span>
<span id="freshness-outdated-beyond-label"></span>
</li>
<li>
<span class="freshness-recent" aria-hidden="true"></span>
<span id="freshness-recent-label"></span>
</li>
</ul>
<div class="freshness-chart-header" aria-hidden="true">
<span>Locale</span>
<span>Translation status</span>
</div>
<ul class="freshness-chart-rows"></ul>
</div>
<div id="freshness-details"></div>
</div>
<template id="freshness-locale-template">
<h3></h3>
<table data-outdated-files>
<thead>
<tr>
<th>Translation</th>
<th>Last translation update</th>
<th>Last source update</th>
</tr>
</thead>
<tbody></tbody>
</table>
</template>
<template id="freshness-file-template">
<tr>
<th><a data-file></a></th>
<td>
<time data-translation-update>
<span data-relative-date></span>
<span data-calendar-date></span>
</time>
</td>
<td>
<time data-source-update>
<span data-relative-date></span>
<span data-calendar-date></span>
</time>
</td>
</tr>
</template>
<template id="freshness-chart-row-template">
<li class="freshness-chart-row">
<div>
<a data-locale-link></a>
<p data-chart-coverage></p>
<p class="sr-only" data-chart-summary></p>
</div>
<div>
<div class="freshness-bar" aria-hidden="true">
<span class="freshness-up-to-date" data-up-to-date-bar></span>
<span class="freshness-outdated-within" data-outdated-within-bar></span>
<span class="freshness-outdated-beyond" data-outdated-beyond-bar></span>
</div>
<div class="freshness-bar freshness-activity" aria-hidden="true">
<span class="freshness-recent" data-activity-bar></span>
<p data-activity-percentage></p>
</div>
</div>
</li>
</template>
</translation-freshness>
`;
};
+300
View File
@@ -0,0 +1,300 @@
/**
* @typedef {object} TranslationFile
* @property {string} path
* @property {string} url
* @property {boolean} isOutdated
* @property {number} updatedAt
* @property {number} sourceUpdatedAt
*/
/**
* @typedef {object} LocaleData
* @property {string} lang
* @property {string} label
* @property {TranslationFile[]} files
*/
/**
* @typedef {object} LocaleFreshness
* @property {string} lang
* @property {string} label
* @property {TranslationFile[]} outdatedFiles
* @property {number} oldOutdatedCount
* @property {number} recentUpdateCount
* @property {number} translatedCount
*/
class TranslationFreshness extends HTMLElement {
#dateFormat = new Intl.DateTimeFormat('en', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
#relativeTime = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
constructor() {
super();
this.addEventListener('submit', (event) => {
if (!(event.target instanceof HTMLFormElement)) return;
event.preventDefault();
const values = new FormData(event.target);
const oldUpdateAge = Number(values.get('oldUpdateAge'));
const recentUpdateAge = Number(values.get('recentUpdateAge'));
const script = this.#getElement('script[type="application/json"]');
if (!(script instanceof HTMLScriptElement)) throw new Error('Missing data script.');
/** @type {{ locales: LocaleData[] }} */
const data = JSON.parse(script.text);
const freshness = this.#computeFreshness(data.locales, oldUpdateAge, recentUpdateAge);
this.#renderFreshness(freshness, oldUpdateAge, recentUpdateAge);
});
}
/**
* @param {LocaleData[]} locales
* @param {number} oldUpdateAge
* @param {number} recentUpdateAge
* @returns {LocaleFreshness[]}
*/
#computeFreshness(locales, oldUpdateAge, recentUpdateAge) {
const now = new Date();
const oldUpdateThreshold = this.#getTimestampMonthsAgo(now, oldUpdateAge);
const recentUpdateThreshold = this.#getTimestampMonthsAgo(now, recentUpdateAge);
return locales
.map(({ files, label, lang }) => {
const outdatedFiles = files.filter((file) => file.isOutdated);
return {
lang,
label,
outdatedFiles,
oldOutdatedCount: outdatedFiles.filter((file) => file.updatedAt < oldUpdateThreshold)
.length,
recentUpdateCount: files.filter(
(file) => file.updatedAt >= recentUpdateThreshold && file.updatedAt <= now.getTime()
).length,
translatedCount: files.length,
};
})
.filter((locale) => locale.oldOutdatedCount > 0);
}
/**
* @param {LocaleFreshness[]} locales
* @param {number} oldUpdateAge
* @param {number} recentUpdateAge
*/
#renderFreshness(locales, oldUpdateAge, recentUpdateAge) {
const fragment = document.createDocumentFragment();
const template = this.#getElement('#freshness-locale-template');
if (!(template instanceof HTMLTemplateElement)) throw new Error('Missing locale template.');
const now = Date.now();
for (const locale of locales) {
const content = document.importNode(template.content, true);
const heading = this.#getElement('h3', content);
heading.id = `freshness-${locale.lang}`;
heading.textContent = `${locale.label} (${locale.lang})`;
this.#renderOutdatedFiles(content, locale, now);
fragment.append(content);
}
this.#renderOverview(locales, oldUpdateAge, recentUpdateAge);
this.#getElement('#freshness-details').replaceChildren(fragment);
this.#getElement('#freshness-results').hidden = locales.length === 0;
const status = this.#getElement('#freshness-status');
status.classList.toggle('sr-only', locales.length > 0);
status.textContent =
locales.length > 0
? `Showing freshness for ${locales.length} ${this.#pluralize('locale', locales.length)}.`
: 'No outdated translations match the configured threshold. Try fewer months.';
}
/**
* @param {LocaleFreshness[]} locales
* @param {number} oldUpdateAge
* @param {number} recentUpdateAge
*/
#renderOverview(locales, oldUpdateAge, recentUpdateAge) {
const fragment = document.createDocumentFragment();
const template = this.#getElement('#freshness-chart-row-template');
if (!(template instanceof HTMLTemplateElement)) throw new Error('Missing chart row template.');
const oldUnit = this.#pluralize('month', oldUpdateAge);
const recentUnit = this.#pluralize('month', recentUpdateAge);
this.#getElement('#freshness-outdated-within-label').textContent =
`Outdated ≤ ${oldUpdateAge} ${oldUnit}`;
this.#getElement('#freshness-outdated-beyond-label').textContent =
`Outdated > ${oldUpdateAge} ${oldUnit}`;
this.#getElement('#freshness-recent-label').textContent =
`Updated ≤ ${recentUpdateAge} ${recentUnit}`;
for (const locale of locales) {
const content = document.importNode(template.content, true);
const total = locale.translatedCount;
const outdated = locale.outdatedFiles.length;
const upToDate = total - outdated;
const within = outdated - locale.oldOutdatedCount;
const beyond = locale.oldOutdatedCount;
this.#getElement('[data-chart-summary]', content).textContent =
`Up to date: ${upToDate}. ` +
`Outdated and last updated within ${oldUpdateAge} ${oldUnit}: ${within}. ` +
`Outdated and last updated more than ${oldUpdateAge} ${oldUnit} ago: ${beyond}. ` +
`Updated in the last ${recentUpdateAge} ${recentUnit}: ${locale.recentUpdateCount} of ${total}.`;
const link = this.#getElement('[data-locale-link]', content);
if (!(link instanceof HTMLAnchorElement)) throw new Error('Missing locale link element.');
link.textContent = `${locale.label} (${locale.lang})`;
link.href = `#freshness-${locale.lang}`;
this.#getElement('[data-chart-coverage]', content).textContent =
`${total} translated ${this.#pluralize('page', total)}`;
this.#renderBar(this.#getElement('[data-up-to-date-bar]', content), upToDate, total);
this.#renderBar(this.#getElement('[data-outdated-within-bar]', content), within, total);
this.#renderBar(this.#getElement('[data-outdated-beyond-bar]', content), beyond, total);
this.#renderBar(
this.#getElement('[data-activity-bar]', content),
locale.recentUpdateCount,
total
);
const percentage = Math.round((locale.recentUpdateCount / total) * 100);
this.#getElement('[data-activity-percentage]', content).textContent =
locale.recentUpdateCount === 0 ? '0 (0%)' : `(${percentage}%)`;
fragment.append(content);
}
this.#getElement('.freshness-chart-rows').replaceChildren(fragment);
}
/**
* @param {DocumentFragment} content
* @param {LocaleFreshness} locale
* @param {number} now
*/
#renderOutdatedFiles(content, locale, now) {
const table = this.#getElement('[data-outdated-files]', content);
table.setAttribute('aria-labelledby', `freshness-${locale.lang}`);
const template = this.#getElement('#freshness-file-template');
if (!(template instanceof HTMLTemplateElement)) throw new Error('Missing file template.');
const body = this.#getElement('tbody', table);
for (const file of locale.outdatedFiles) {
const row = document.importNode(template.content, true);
const link = this.#getElement('[data-file]', row);
if (!(link instanceof HTMLAnchorElement)) throw new Error('Missing file link element.');
link.textContent = file.path;
link.href = file.url;
this.#renderUpdateTime(
this.#getElement('[data-translation-update]', row),
file.updatedAt,
now
);
this.#renderUpdateTime(
this.#getElement('[data-source-update]', row),
file.sourceUpdatedAt,
now
);
body.append(row);
}
}
/**
* @param {HTMLElement} element
* @param {number} count
* @param {number} total
*/
#renderBar(element, count, total) {
element.style.width = `${(count / total) * 100}%`;
element.textContent = count === 0 ? '' : String(count);
}
/**
* @param {HTMLElement} element
* @param {number} timestamp
* @param {number} now
*/
#renderUpdateTime(element, timestamp, now) {
if (!(element instanceof HTMLTimeElement)) throw new Error('Invalid time element.');
const date = new Date(timestamp);
const days = Math.floor(timestamp / 86_400_000) - Math.floor(now / 86_400_000);
element.dateTime = date.toISOString();
this.#getElement('[data-relative-date]', element).textContent = this.#relativeTime.format(
days,
'day'
);
this.#getElement('[data-calendar-date]', element).textContent = this.#dateFormat.format(date);
}
/**
* @param {Date} now
* @param {number} months
* @returns {number}
*/
#getTimestampMonthsAgo(now, months) {
const date = new Date(now);
date.setUTCMonth(date.getUTCMonth() - months);
// If the day doesn't exist in this month, use the last day of the month.
if (date.getUTCDate() !== now.getUTCDate()) {
date.setUTCDate(0);
}
return date.getTime();
}
/**
* @param {string} word
* @param {number} count
* @returns {string}
*/
#pluralize(word, count) {
return count === 1 ? word : `${word}s`;
}
/**
* @param {string} selector
* @param {ParentNode} [root]
* @returns {HTMLElement}
*/
#getElement(selector, root = this) {
const element = root.querySelector(selector);
if (!(element instanceof HTMLElement)) {
throw new Error(`Missing or invalid element: '${selector}'.`);
}
return element;
}
}
customElements.define('translation-freshness', TranslationFreshness);
+254
View File
@@ -45,3 +45,257 @@ sup {
display: flex;
justify-content: center;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
translation-freshness {
--freshness-color-bar-text: hsl(218, 35%, 14%);
--freshness-color-up-to-date: var(--ln-color-done);
--freshness-color-outdated-within: hsl(30, 100%, 75%);
--freshness-color-outdated-beyond: var(--ln-color-outdated);
--freshness-color-recent: hsl(265, 92%, 70%);
display: block;
form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
margin-bottom: 2rem;
p {
display: grid;
gap: 0.5rem;
grid-row: span 3;
grid-template-rows: subgrid;
margin: 0;
}
label {
font-weight: bold;
}
small {
color: var(--ln-color-black);
font-size: 0.875rem;
}
input {
background-color: var(--theme-table-header);
border: 1px solid var(--ln-color-black);
box-sizing: border-box;
color: var(--theme-text-bright);
padding: 0.5rem 0.75rem;
width: 5rem;
}
button {
background-color: color-mix(in hsl, var(--theme-accent) 10%, transparent);
border: 1px solid currentColor;
color: var(--theme-accent);
font-size: 0.875rem;
justify-self: start;
padding: 0.75rem 1rem;
&:hover {
background-color: color-mix(in hsl, var(--theme-accent) 15%, transparent);
}
}
}
.freshness-up-to-date {
background-color: var(--freshness-color-up-to-date);
}
.freshness-outdated-within {
background-color: var(--freshness-color-outdated-within);
}
.freshness-outdated-beyond {
background-color: var(--freshness-color-outdated-beyond);
}
.freshness-recent {
background-color: var(--freshness-color-recent);
}
.freshness-legend,
.freshness-chart-rows {
list-style: none;
padding: 0;
}
.freshness-legend {
display: flex;
flex-wrap: wrap;
font-weight: bold;
gap: 0.75rem 1rem;
li {
align-items: center;
display: flex;
gap: 0.5rem;
}
span[aria-hidden] {
border: 1px solid var(--ln-color-table-border);
box-sizing: border-box;
display: inline-block;
height: 1rem;
width: 1rem;
}
}
.freshness-chart-header,
.freshness-chart-row {
display: grid;
gap: 1.25rem;
grid-template-columns: minmax(8rem, 1fr) minmax(0, 3fr);
}
.freshness-chart-header {
color: var(--ln-color-black);
font-size: 0.875rem;
font-weight: bold;
padding-block: 1.25rem 0.75rem;
}
.freshness-chart-row {
align-items: center;
border-bottom: 1px solid var(--ln-color-table-border);
padding-block: 1rem;
& > div {
min-width: 0;
overflow-wrap: anywhere;
}
p {
color: var(--ln-color-black);
margin-top: 0.5rem;
}
[data-locale-link] {
font-weight: bold;
}
}
.freshness-bar {
background-color: var(--ln-color-table-background);
display: flex;
height: 2.125rem;
overflow: hidden;
& > span {
align-items: center;
color: var(--freshness-color-bar-text);
display: flex;
font-size: 0.8125rem;
font-weight: bold;
justify-content: center;
min-width: min-content;
padding-inline: 2px;
&:empty {
padding-inline: 0;
}
}
& > span:not(:empty) ~ span:not(:empty) {
border-inline-start: 1px solid var(--theme-bg);
}
}
.freshness-activity {
height: 1.5rem;
margin-top: 1px;
[data-activity-bar] {
flex-shrink: 0;
}
[data-activity-percentage] {
align-items: center;
color: var(--theme-text-bright);
display: flex;
flex-shrink: 0;
font-size: 0.8125rem;
inset-inline-end: 0;
margin: 0;
padding-inline: 0.5rem;
position: sticky;
text-shadow:
-1px 0 var(--theme-bg),
1px 0 var(--theme-bg),
0 -1px var(--theme-bg),
0 1px var(--theme-bg);
}
}
@media (max-width: 48rem) {
.freshness-chart-header {
display: none;
}
.freshness-chart-row {
gap: 0.75rem;
grid-template-columns: 1fr;
}
}
table {
border: 1px solid var(--ln-color-table-border);
border-collapse: collapse;
box-sizing: border-box;
font-size: 0.8125rem;
width: 100%;
}
th {
font-weight: normal;
text-align: left;
white-space: nowrap;
}
thead th {
background-color: var(--ln-color-table-background);
font-weight: bold;
position: sticky;
top: -1px;
z-index: 1;
}
th,
td {
border: 1px solid var(--ln-color-table-border);
padding: 0.3rem 1rem;
}
tbody tr:is(:hover, :focus-within) {
background-color: var(--ln-color-table-background);
}
td time {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1rem;
span {
white-space: nowrap;
}
[data-calendar-date] {
margin-inline-start: auto;
}
}
}
+8
View File
@@ -85,5 +85,13 @@ export default defineConfig(
'@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/no-namespace': 'off',
},
},
// Enable browser globals for the Lunaria freshness component.
{
files: ['docs/lunaria/freshness.mjs'],
languageOptions: {
globals: globals.browser,
},
}
);