From f5f776859844c671fd797651294520217bf871d5 Mon Sep 17 00:00:00 2001 From: HiDeoo <494699+HiDeoo@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:25:58 +0200 Subject: [PATCH] i18n: add freshness to Lunaria dashboard (#4192) Co-authored-by: delucis <357379+delucis@users.noreply.github.com> Co-authored-by: Chris Swithinbank --- docs/lunaria.config.ts | 173 ++++++++++++++++++++- docs/lunaria/freshness.mjs | 300 +++++++++++++++++++++++++++++++++++++ docs/lunaria/styles.css | 254 +++++++++++++++++++++++++++++++ eslint.config.mjs | 8 + 4 files changed, 734 insertions(+), 1 deletion(-) create mode 100644 docs/lunaria/freshness.mjs diff --git a/docs/lunaria.config.ts b/docs/lunaria.config.ts index de8b8726c..2916d1a37 100644 --- a/docs/lunaria.config.ts +++ b/docs/lunaria.config.ts @@ -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` + + `, afterTitle: () => html`

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 +

+
+

+ + + + Show locales with outdated translations last updated more than this many months ago. + +

+

+ + + + Show how many translation files were updated within this many months. + +

+ +
+

+ + + + + + `; +}; diff --git a/docs/lunaria/freshness.mjs b/docs/lunaria/freshness.mjs new file mode 100644 index 000000000..3928137f8 --- /dev/null +++ b/docs/lunaria/freshness.mjs @@ -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); diff --git a/docs/lunaria/styles.css b/docs/lunaria/styles.css index 43ce52800..60b70f9e7 100644 --- a/docs/lunaria/styles.css +++ b/docs/lunaria/styles.css @@ -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; + } + } +} diff --git a/eslint.config.mjs b/eslint.config.mjs index b7040339e..b5e38f272 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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, + }, } );