mirror of
https://github.com/withastro/starlight.git
synced 2026-10-11 21:49:55 +00:00
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:
co-authored by
delucis
Chris Swithinbank
parent
b0d3b8e721
commit
f5f7768598
+172
-1
@@ -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>
|
||||
`;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user