From eea2430e6adb7df28724171c72b63e1a6d5c818a Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:47:54 -0400 Subject: [PATCH] Docs: document NODE_OPTIONS and refresh the widget copies NODE_OPTIONS was missing from the environment variable table. Add it with the shipped default and the reason the ceiling matters. Re-sync the embedded application code so the previews run what the current release runs. --- public/js/admin-app-form.js | 49 +++-- public/js/admin-auth.js | 6 +- public/js/admin-color-control.js | 16 +- public/js/admin-logic.js | 4 - public/js/admin-settings.js | 8 +- public/js/admin-shared.js | 10 +- public/js/admin-widget-form.js | 30 ++- public/js/admin.js | 23 +-- public/js/badge-logic.js | 17 +- public/js/dashboard.js | 22 ++- public/js/i18n.js | 176 ++++++++++++++++-- public/js/spotlight.js | 4 +- public/js/ui.js | 16 +- public/js/utils.js | 13 ++ public/js/widget-config-form.js | 22 +-- public/js/yaml-lite.js | 4 +- public/widgets/clock/analog.html | 8 +- public/widgets/clock/digital.html | 9 +- public/widgets/dns/i18n/fa.json | 2 +- public/widgets/github/i18n/fa.json | 4 +- public/widgets/github/pullrequests.html | 2 +- public/widgets/system-summary/i18n/fa.json | 4 +- .../docs/docs/advanced-configuration.md | 1 + 23 files changed, 308 insertions(+), 142 deletions(-) diff --git a/public/js/admin-app-form.js b/public/js/admin-app-form.js index a76128f..5bf22d4 100644 --- a/public/js/admin-app-form.js +++ b/public/js/admin-app-form.js @@ -1,9 +1,9 @@ -import { clr as rc, el, inp as inpById, q as qSel, qa, qi, tgt } from '/js/utils.js?v=26566e09'; +import { clr as rc, el, inp as inpById, q as qSel, qa, qi, tgt } from '/js/utils.js?v=8ca7ce3c'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { loadLocalIcons, resolveIcon, iconChain, cdnIconName } from '/js/icons.js?v=69c2b9bd'; import { state } from '/js/admin-state.js?v=c23e6346'; -import { isDockBlocked, DOCK_MAX, clearsStoredSecret } from '/js/admin-logic.js?v=ddfc6f80'; -import { t } from '/js/i18n.js?v=d056c9c5'; +import { isDockBlocked, DOCK_MAX, clearsStoredSecret } from '/js/admin-logic.js?v=d17394da'; +import { t } from '/js/i18n.js?v=83239bf4'; import { toast, ag, @@ -13,9 +13,9 @@ import { initInlineEdit, setTogDisabled, wireChecklist, -} from '/js/admin-shared.js?v=e9afcefb'; -import { MAX_LABELS } from '/js/badge-logic.js?v=c6430afc'; -import { renderColorControl, BADGE_SWATCHES, BADGE_DEFAULT } from '/js/admin-color-control.js?v=32111fda'; +} from '/js/admin-shared.js?v=1d330931'; +import { MAX_LABELS } from '/js/badge-logic.js?v=41a929ac'; +import { renderColorControl, BADGE_SWATCHES, BADGE_DEFAULT } from '/js/admin-color-control.js?v=bfd0955a'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1'; export function buildFolderForm(body, item) { @@ -43,7 +43,7 @@ export function buildFolderForm(body, item) { ${t('folder.name')} ${item?.label || t('folder.namePh')} - +
${t('folder.addApps')} @@ -52,7 +52,7 @@ export function buildFolderForm(body, item) { ${t('folder.selectApps')} ${raw(CHEV_SVG)} - +
@@ -165,7 +165,7 @@ export function buildAppForm(body, item) {
${t('app.authentication')}${tog('auth-en', !!(act.params || act.headers), t('app.authentication'))}
-
Add to URL (query params)
+
${t('app.addToUrl')} (query params)
${t('app.addToHeader')}
@@ -178,7 +178,7 @@ export function buildAppForm(body, item) {

${t('app.labelOrderTip')}

- +
${t('app.combineValues')}${tog('act-combine', !!act.combine || !!state.slegacySum, t('app.combineValues'))}

${t('app.combineTip')}

@@ -256,9 +256,7 @@ export function buildAppForm(body, item) { el('act-add-label')?.addEventListener('click', addActLabel); const savedStatus = el('bst'); if (savedStatus && state.spaths.length) - savedStatus.textContent = t(state.spaths.length === 1 ? 'app.labelsSaved' : 'app.labelsSavedPlural', { - n: state.spaths.length, - }); + savedStatus.textContent = t('app.labelsSaved', { count: state.spaths.length }); syncActMode(); } @@ -388,10 +386,10 @@ function renderActLabels(host) { setHtml( card, html`${_valueSelect(i, path)} - ${_ieRow(`ie-albl-name-${i}`, _optRow(t('app.labelText')), `albl-name-${i}`, l.name, t('app.labelPh'), 'text', t('app.labelText'))} + ${_ieRow(`ie-albl-name-${i}`, _optRow(t('app.labelText')), `albl-name-${i}`, l.name, t('app.labelPh'))}
- ${_ieRow(`ie-albl-unit-${i}`, _optRow(t('app.unit')), `albl-unit-${i}`, l.unit, t('app.unitPh'), 'text', t('app.unit'))} - ${_ieRow(`ie-albl-min-${i}`, _optRow(t('app.badgeMin')), `albl-min-${i}`, l.min, t('app.badgeMinPh'), 'number', t('app.badgeMin'))}`, + ${_ieRow(`ie-albl-unit-${i}`, _optRow(t('app.unit')), `albl-unit-${i}`, l.unit, t('app.unitPh'))} + ${_ieRow(`ie-albl-min-${i}`, _optRow(t('app.badgeMin')), `albl-min-${i}`, l.min, t('app.badgeMinPh'), 'number')}`, ); host.appendChild(card); initInlineEdit(`ie-albl-name-${i}`, `albl-name-${i}`, { placeholder: t('app.labelPh') }); @@ -430,7 +428,7 @@ const GRIP_SVG = function addActLabel() { captureActLabels(); if (state.spaths.length >= MAX_LABELS) { - toast(t('app.labelMax', { n: MAX_LABELS }), 'err'); + toast(t('app.labelMax', { count: MAX_LABELS }), 'err'); return; } const free = actValueOptions().find(o => !state.spaths.includes(o.path)); @@ -490,12 +488,11 @@ function wireActLabelDrag(host) { } } -/** The inline-edit row markup. `label` may carry markup, so the button's - accessible name comes from `aria`: an attribute cannot hold an element. */ -function _ieRow(rowId, label, inpId, val, ph, type = 'text', aria) { +/** The inline-edit row markup. The pencil's accessible name is set by + initInlineEdit, from the row's own label. */ +function _ieRow(rowId, label, inpId, val, ph, type = 'text') { const has = val != null && val !== ''; - const name = aria == null ? String(label) : aria; - return html`
${label}${has ? val : ph}
`; + return html`
${label}${has ? val : ph}
`; } function wireIcon() { @@ -752,10 +749,10 @@ function kvRowEl(host, rows, row, ph) { setHtml( el, html` - - - - `, + + + + `, ); const kEl = qi('.kv-k', el), vEl = qi('.kv-v', el), diff --git a/public/js/admin-auth.js b/public/js/admin-auth.js index 91be26e..df85525 100644 --- a/public/js/admin-auth.js +++ b/public/js/admin-auth.js @@ -1,7 +1,7 @@ -import { ag, ap } from '/js/admin-shared.js?v=e9afcefb'; -import { t } from '/js/i18n.js?v=d056c9c5'; +import { ag, ap } from '/js/admin-shared.js?v=1d330931'; +import { t } from '/js/i18n.js?v=83239bf4'; import { pwStrength } from '/js/password-strength.js?v=42f45ac7'; -import { el, inp as inpById, qa } from '/js/utils.js?v=26566e09'; +import { el, inp as inpById, qa } from '/js/utils.js?v=8ca7ce3c'; export async function checkAuth(onLogin) { try { diff --git a/public/js/admin-color-control.js b/public/js/admin-color-control.js index 67bd024..e5818c7 100644 --- a/public/js/admin-color-control.js +++ b/public/js/admin-color-control.js @@ -1,7 +1,7 @@ -import { PE_SVG, initInlineEdit, toast } from '/js/admin-shared.js?v=e9afcefb'; -import { t } from '/js/i18n.js?v=d056c9c5'; +import { PE_SVG, initInlineEdit, toast } from '/js/admin-shared.js?v=1d330931'; +import { t } from '/js/i18n.js?v=83239bf4'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { qa, q } from '/js/utils.js?v=26566e09'; +import { qa, q } from '/js/utils.js?v=8ca7ce3c'; const CC_SWATCHES = ['#1c1c1e', '#8e8e93', '#f2f2f7', '#ff393c', '#ffcd00', '#35c759', '#0289ff', '#cb30df']; export const BADGE_DEFAULT = '#1e6ef4'; @@ -127,11 +127,11 @@ export function renderColorControl( const swatch = h => html``; const swatches = semantic - ? html` - - + ? html` + + ${['#ff393c', '#ffcd00', '#35c759', '#0289ff', '#cb30df'].map(swatch)}` - : html` + : html` ${swatchColors.map(swatch)}`; const wrap = document.createElement('div'); const slider = (label, cls, id, max, val, lo, hi) => html` @@ -143,7 +143,7 @@ export function renderColorControl( ${slider('Hue', 'hsb-range hsb-hue', `${idPrefix}-h`, 360, init.h, _ccIco.hueLo, _ccIco.hueHi)} ${slider('Saturation', 'hsb-range', `${idPrefix}-s`, 100, init.s, _ccIco.satLo, _ccIco.satHi)} ${slider('Brightness', 'hsb-range', `${idPrefix}-v`, 100, init.v, _ccIco.brLo, _ccIco.brHi)} -
Color Code#rrggbb or any CSS color
`, +
${t('appearance.colorCode')}#rrggbb or any CSS color
`, ); const rows = /** @type {HTMLElement[]} */ ([...wrap.children]); rows.forEach(r => container.appendChild(r)); diff --git a/public/js/admin-logic.js b/public/js/admin-logic.js index 34b7f8c..e81bf8d 100644 --- a/public/js/admin-logic.js +++ b/public/js/admin-logic.js @@ -251,10 +251,6 @@ export function rejectionLines(rejections, { withName = true } = {}) { return out; } -export function refusedNoticeKey(count) { - return count === 1 ? 'widgetCfg.refused' : 'widgetCfg.refusedPlural'; -} - /* Long errors need longer than short confirmations. 60ms per character is about 16 characters a second, under a slow reading speed. */ export function toastMs(message) { diff --git a/public/js/admin-settings.js b/public/js/admin-settings.js index 1353601..d331ccc 100644 --- a/public/js/admin-settings.js +++ b/public/js/admin-settings.js @@ -1,8 +1,8 @@ -import { toast, ag, ap } from '/js/admin-shared.js?v=e9afcefb'; +import { toast, ag, ap } from '/js/admin-shared.js?v=1d330931'; import { pwStrength } from '/js/password-strength.js?v=42f45ac7'; -import { t } from '/js/i18n.js?v=d056c9c5'; -import { shouldWritePassword, settingsSaveBlocker, clearsStoredPassword, BLOCK } from '/js/admin-logic.js?v=ddfc6f80'; -import { el, inp, q, qa, setUserText } from '/js/utils.js?v=26566e09'; +import { t } from '/js/i18n.js?v=83239bf4'; +import { shouldWritePassword, settingsSaveBlocker, clearsStoredPassword, BLOCK } from '/js/admin-logic.js?v=d17394da'; +import { el, inp, q, qa, setUserText } from '/js/utils.js?v=8ca7ce3c'; /* Mirrors the server's rule: auth cannot be switched on with no password. */ let _passwordSet = false; diff --git a/public/js/admin-shared.js b/public/js/admin-shared.js index 8e291bd..3b49bb1 100644 --- a/public/js/admin-shared.js +++ b/public/js/admin-shared.js @@ -1,7 +1,7 @@ /* Stateless helpers shared by the admin modules. Mutable state stays out. */ -import { nextActiveIndex, recoversSession, toastHoldMs } from '/js/admin-logic.js?v=ddfc6f80'; -import { el, qa, q } from '/js/utils.js?v=26566e09'; -import { t } from '/js/i18n.js?v=d056c9c5'; +import { nextActiveIndex, recoversSession, toastHoldMs } from '/js/admin-logic.js?v=d17394da'; +import { el, qa, q } from '/js/utils.js?v=8ca7ce3c'; +import { t } from '/js/i18n.js?v=83239bf4'; export const API = ''; @@ -155,6 +155,10 @@ export function initInlineEdit(rowId, inputId, { type = 'text', placeholder = '' if (labelEl) { if (!labelEl.id) labelEl.id = `${rowId}-rl`; inp.setAttribute('aria-labelledby', labelEl.id); + /* The pencil opens this row, so it is named after this row. Taking the name + from the label keeps the two in one language, and in step when either + changes. */ + pen.setAttribute('aria-label', t('common.editNamed', { name: labelEl.textContent.trim() })); } row.insertBefore(inp, pen); diff --git a/public/js/admin-widget-form.js b/public/js/admin-widget-form.js index 03d6e59..efab3e7 100644 --- a/public/js/admin-widget-form.js +++ b/public/js/admin-widget-form.js @@ -1,16 +1,10 @@ import { state } from '/js/admin-state.js?v=c23e6346'; -import { PE_SVG, CHEV_SVG, initInlineEdit } from '/js/admin-shared.js?v=e9afcefb'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=a68e6a57'; +import { PE_SVG, CHEV_SVG, initInlineEdit } from '/js/admin-shared.js?v=1d330931'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=77017460'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { - sizesForView, - widgetConfigMode, - rejectionLines, - refusedNoticeKey, - carriesTypedValues, -} from '/js/admin-logic.js?v=ddfc6f80'; -import { t } from '/js/i18n.js?v=d056c9c5'; -import { q, qi, qa } from '/js/utils.js?v=26566e09'; +import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=d17394da'; +import { t } from '/js/i18n.js?v=83239bf4'; +import { q, qi, qa } from '/js/utils.js?v=8ca7ce3c'; const SIZE_ICONS = { small: @@ -66,7 +60,7 @@ function _renderWidgetForm(body) { shell, html`
${t('widgetCfg.name')}${state._wlabel ? state._wlabel : t('widgetCfg.namePh')}
-
${t('widgetCfg.type')}
${raw(CHEV_SVG)}
`, +
${t('widgetCfg.type')}
${raw(CHEV_SVG)}
`, ); body.appendChild(shell); initInlineEdit('ie-wname', 'f-wlabel', { @@ -86,7 +80,7 @@ function _renderWidgetForm(body) { if (refused.length) { const note = document.createElement('p'); note.className = 'grp-tip'; - note.textContent = t(refusedNoticeKey(refused.length), { n: refused.length }); + note.textContent = t('widgetCfg.refused', { count: refused.length }); body.appendChild(note); appendRejectionReasons(body, refused); } @@ -176,7 +170,7 @@ function _renderCustomConfig(body) { body.appendChild(card); setHtml( card, - html`
Iframe URL *${state._customUrl ? state._customUrl : 'https://app.example.com/widget.html'}
`, + html`
${t('widgetCfg.iframeUrl')} *${state._customUrl ? state._customUrl : 'https://app.example.com/widget.html'}
`, ); const tip = document.createElement('p'); tip.className = 'grp-tip'; @@ -211,10 +205,10 @@ function _renderCustomConfig(body) { setHtml( adv, html` -
Referrer Policy
${raw(CHEV_SVG)}
-
Allow (feature policy)${o.allow ? o.allow : 'autoplay; fullscreen'}
-
Allow Fullscreen
-
Refresh Interval (ms)${o.refreshInterval ? o.refreshInterval : 'e.g. 2000'}
`, +
${t('widgetCfg.referrerPolicy')}
${raw(CHEV_SVG)}
+
${t('widgetCfg.allowFeaturePolicy')}${o.allow ? o.allow : 'autoplay; fullscreen'}
+
${t('widgetCfg.allowFullscreen')}
+
${t('widgetCfg.refreshInterval')} (ms)${o.refreshInterval ? o.refreshInterval : 'e.g. 2000'}
`, ); const sync = () => { state._iframeOpts.referrerPolicy = qi('#if-referrer', adv).value || undefined; diff --git a/public/js/admin.js b/public/js/admin.js index ea78f3e..15785d5 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -1,7 +1,7 @@ -import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=aa32f5ed'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=2191f31e'; +import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=1d7010c3'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=d1c7d163'; import { applyDrop, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=ebe3e806'; -import { reorderItems, resolveAdminSection } from '/js/admin-logic.js?v=ddfc6f80'; +import { reorderItems, resolveAdminSection } from '/js/admin-logic.js?v=d17394da'; import { buildAppItem, claimFolderChildren, @@ -10,12 +10,12 @@ import { snapshotItems, upsertItem, } from '/js/admin-save-logic.js?v=48a9e055'; -import { loadSettings, showBgFields, showBgFit, showWallpaperFile } from '/js/admin-settings.js?v=455deefb'; -import { ag, ap, initInlineEdit, setReauthHandler, toast } from '/js/admin-shared.js?v=e9afcefb'; +import { loadSettings, showBgFields, showBgFit, showWallpaperFile } from '/js/admin-settings.js?v=f5343e07'; +import { ag, ap, initInlineEdit, setReauthHandler, toast } from '/js/admin-shared.js?v=1d330931'; import { state } from '/js/admin-state.js?v=c23e6346'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=5a2dc8e8'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=c549d05d'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=d056c9c5'; +import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=83239bf4'; import { iconChain, loadLocalIcons, resolveIcon } from '/js/icons.js?v=69c2b9bd'; import { clearSkipTls, @@ -29,10 +29,10 @@ import { import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=28416a75'; import { confirmModal, openModal as openDialog, promptModal } from '/js/modal.js?v=ff76dc56'; import { readMode, watchSystemTheme, writeMode } from '/js/theme.js?v=db4192cd'; -import { el, inp, q, qa, clr as rc, sanitizeCssUrl, setUserText, tgt } from '/js/utils.js?v=26566e09'; +import { el, inp, q, qa, clr as rc, sanitizeCssUrl, setUserText, tgt } from '/js/utils.js?v=8ca7ce3c'; import { widgetGlyph } from '/js/widget-glyphs.js?v=b5036986'; -import { normalizeColorInput } from '/js/admin-color-control.js?v=32111fda'; -import { parseYamlTolerant, YamlLiteError } from '/js/yaml-lite.js?v=cceca788'; +import { normalizeColorInput } from '/js/admin-color-control.js?v=bfd0955a'; +import { parseYamlTolerant, YamlLiteError } from '/js/yaml-lite.js?v=1907cce7'; /* A class rather than a bare media query. Some phones report a wider CSS viewport than they have. The rule lives in layout.js, shared with the @@ -53,6 +53,7 @@ async function load() { state.items = c.items || []; state._settings = c.settings || {}; await initI18n(c.settings?.language || 'en'); + document.title = t('nav.pageTitle'); initVersion(); syncThemeLabel(); try { @@ -1459,7 +1460,7 @@ el('imp').onchange = async e => { } const lead = document.createElement('p'); lead.className = 'dlg-lead'; - lead.textContent = t('import.confirm', { n: d.items.length, added, updated, deleted }); + lead.textContent = t('import.confirm', { count: d.items.length, added, updated, deleted }); const ok = await confirmModal({ title: t('import.confirmTitle'), body: lead, diff --git a/public/js/badge-logic.js b/public/js/badge-logic.js index 2c45da1..87bfb81 100644 --- a/public/js/badge-logic.js +++ b/public/js/badge-logic.js @@ -7,17 +7,24 @@ const EN = { 'status.healthy': 'Status: healthy', 'status.pending': '{count} pending', 'status.stale': '(may be out of date)', - 'status.moreValues': '{n} more badge', - 'status.moreValuesPlural': '{n} more badges', + 'status.moreValues_one': '{count} more badge', + 'status.moreValues_other': '{count} more badges', 'status.containerNotFound': 'Container not found', 'status.containerState': 'Container {state}', 'status.pingFailed': 'Ping failed: {error}', 'status.pingReturned': 'Ping returned {status}', }; -/** @param {string} key @param {Record} [vars] */ +/** English, with the same counted-message resolution the real translator does. + @param {string} key @param {Record} [vars] */ function _fallback(key, vars) { - const s = EN[key] || key; + const count = vars && vars.count; + let s = null; + if (typeof count === 'number' && Number.isFinite(count)) { + const category = new Intl.PluralRules('en').select(count); + s = EN[`${key}_${category}`] ?? EN[`${key}_other`] ?? null; + } + if (s == null) s = EN[key] || key; return vars ? s.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m)) : s; } @@ -249,7 +256,7 @@ export function computeBadgeVisual({ const more = Math.max(0, rows.length - 1); if (more) { cls += ' has-more'; - aria = aria + '. ' + tr(more === 1 ? 'status.moreValues' : 'status.moreValuesPlural', { n: more }); + aria = aria + '. ' + tr('status.moreValues', { count: more }); } const nextColor = more ? rows[1].color : ''; diff --git a/public/js/dashboard.js b/public/js/dashboard.js index 51ea896..92ca2d9 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -14,6 +14,7 @@ import { mk, mkWrap as _mkWrap, mountScaledWidget, + pageDir, q, qa, qi, @@ -21,14 +22,14 @@ import { setUserText, teardownWidgets, titleWhenTruncated, -} from '/js/utils.js?v=26566e09'; -import { initSpotlight } from '/js/spotlight.js?v=4352b315'; +} from '/js/utils.js?v=8ca7ce3c'; +import { initSpotlight } from '/js/spotlight.js?v=fe9e1b53'; import { html, setHtml, raw } from '/js/html.js?v=c71f8903'; -import { initI18n, t, currentLang } from '/js/i18n.js?v=d056c9c5'; +import { initI18n, t, currentLang } from '/js/i18n.js?v=83239bf4'; import { pwStrength, passwordMismatch } from '/js/password-strength.js?v=42f45ac7'; import { sanitizeItemLinks } from '/js/link-url.js?v=54adb40f'; -import { initUI, mkFolder, openFolderDesktop, openFolderMobile, buildMobile } from '/js/ui.js?v=d3561e4b'; -import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=c6430afc'; +import { initUI, mkFolder, openFolderDesktop, openFolderMobile, buildMobile } from '/js/ui.js?v=88773f11'; +import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=41a929ac'; import { formatNumber } from '/js/format-number.js?v=e2165e12'; import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=08aae50f'; import { @@ -309,6 +310,7 @@ function mkIcon(item) { if (showLabel) { const l = mk('div'); l.className = 'ilabel'; + l.dir = 'auto'; l.style.width = iw + 12 + 'px'; setUserText(l, item.label || item.id); a.appendChild(l); @@ -463,7 +465,7 @@ function goTo(n, dotEls, announce = true) { storeSet(PAGE_STORE, String(pg)); const strip = el('pages'); syncPageInert(pg); - const t = `translateX(-${pg * 100}vw)`; + const t = `translateX(${-pageDir() * pg * 100}vw)`; strip.style.transform = strip.style.webkitTransform = t; strip.style.willChange = 'transform'; strip.addEventListener( @@ -867,8 +869,8 @@ async function boot() { if (el('spot').classList.contains('on')) return; /* The overlay covers the page it would scroll behind. */ if (document.querySelector('.folder-overlay')) return; - if (e.key === 'ArrowRight') goTo(pg + 1); - if (e.key === 'ArrowLeft') goTo(pg - 1); + if (e.key === 'ArrowRight') goTo(pg + pageDir()); + if (e.key === 'ArrowLeft') goTo(pg - pageDir()); }); let _dMx = 0, _dDragging = false; @@ -886,7 +888,7 @@ async function boot() { twice for one gesture. */ if (Date.now() - _lastTouch < COMPAT_POINTER_MS) return; const dx = e.clientX - _dMx; - if (Math.abs(dx) > 60) goTo(pg + (dx < 0 ? 1 : -1)); + if (Math.abs(dx) > 60) goTo(pg + (dx < 0 ? 1 : -1) * pageDir()); }); let _dTx = 0; document.addEventListener( @@ -905,7 +907,7 @@ async function boot() { _lastTouch = Date.now(); if (MOB) return; const dx = e.changedTouches[0].clientX - _dTx; - if (Math.abs(dx) > 50) goTo(pg + (dx < 0 ? 1 : -1)); + if (Math.abs(dx) > 50) goTo(pg + (dx < 0 ? 1 : -1) * pageDir()); }, { passive: true }, ); diff --git a/public/js/i18n.js b/public/js/i18n.js index 4721036..243baa6 100644 --- a/public/js/i18n.js +++ b/public/js/i18n.js @@ -5,23 +5,60 @@ import { setHtml } from '/js/html.js?v=c71f8903'; import { i18nMarkup } from '/js/i18n-markup.js?v=8c90e1dd'; -/* Only list a locale whose file exists, or the selector offers one that cannot +/* The locale registry: the one place a supported language is defined. + + `code` is a BCP 47 tag and the catalog filename. `name` is the language's own + name, shown in the selector. `english` is its English name, for documentation + and for reporting. `dir` is the writing direction. `status` is 'source' for + the language the strings are written in, and 'machine' for a catalog produced + by machine translation and not yet checked by a speaker. + + Only list a locale whose file exists, or the selector offers one that cannot render. */ export const LANGUAGES = [ - { code: 'en', name: 'English', dir: 'ltr' }, - { code: 'fa', name: 'فارسی', dir: 'rtl' }, - { code: 'zh-Hans', name: '简体中文', dir: 'ltr' }, - { code: 'es', name: 'Español', dir: 'ltr' }, - { code: 'de', name: 'Deutsch', dir: 'ltr' }, - { code: 'fr', name: 'Français', dir: 'ltr' }, + { code: 'en', name: 'English', english: 'English', dir: 'ltr', status: 'source' }, + { code: 'fa', name: 'فارسی', english: 'Persian', dir: 'rtl', status: 'machine' }, + { code: 'zh-Hans', name: '简体中文', english: 'Chinese (Simplified)', dir: 'ltr', status: 'machine' }, + { code: 'es', name: 'Español', english: 'Spanish', dir: 'ltr', status: 'machine' }, + { code: 'de', name: 'Deutsch', english: 'German', dir: 'ltr', status: 'machine' }, + { code: 'fr', name: 'Français', english: 'French', dir: 'ltr', status: 'machine' }, ]; +/** The source locale. It is the fallback for every key, and the only catalog + whose text is written rather than translated. */ +export const SOURCE_LANG = 'en'; + +/** @param {string} code @returns {boolean} Whether the registry lists it. */ +export const isSupported = code => LANGUAGES.some(l => l.code === code); + +/* Direction for a code the registry does not list, taken from the locale data + the runtime already carries rather than guessed from the string. */ +function runtimeDir(code) { + try { + /* Two spellings of the same locale data: `getTextInfo()` is the current + one, `textInfo` is what older runtimes carry. Neither is in the ambient + type for Locale. */ + const loc = /** @type {any} */ (new Intl.Locale(code).maximize()); + const info = typeof loc.getTextInfo === 'function' ? loc.getTextInfo() : loc.textInfo; + const d = info && info.direction; + if (d === 'rtl' || d === 'ltr') return d; + } catch { + /* Not a well-formed tag, or a runtime without text info. */ + } + return null; +} + +/* Read only when the runtime carries no text info for the tag. */ const RTL = new Set(['fa', 'ar', 'he', 'ur', 'ps', 'sd', 'ug', 'yi']); +/** @param {string} [code] @returns {'ltr'|'rtl'} */ export function dirFor(code) { const known = LANGUAGES.find(l => l.code === code); - if (known && known.dir) return known.dir; - return RTL.has(String(code || '').split('-')[0]) ? 'rtl' : 'ltr'; + if (known && known.dir === 'rtl') return 'rtl'; + if (known) return 'ltr'; + const tag = String(code || ''); + if (!tag) return 'ltr'; + return runtimeDir(tag) === 'rtl' || RTL.has(tag.split('-')[0]) ? 'rtl' : 'ltr'; } /* Null prototype on all three. The keys are catalog keys and English source @@ -54,12 +91,73 @@ async function fetchCatalog(code) { } } +/* Two development locales. Neither is in the registry, so neither is offered in + the selector; both are reached by adding ?lang= to the page URL. + + PSEUDO_LANG rewrites the English catalog: it accents the letters, pads the + text by about 40% and brackets each message. Text that clips, wraps badly or + was concatenated from fragments then shows up on any screen, in a language + the reader can still read. + + KEY_LANG loads no catalog, so every string renders as its own key. It answers + "which key is this on screen" and "what here is not translated at all". */ +export const PSEUDO_LANG = 'en-XA'; +export const KEY_LANG = 'cimode'; + +const ACCENTS = { a: 'á', e: 'é', i: 'í', o: 'ó', u: 'ú', A: 'Á', E: 'É', I: 'Í', O: 'Ó', U: 'Ú' }; + +/* Split on placeholders and tags so neither is accented or padded. Renaming a + placeholder would drop the value out of the sentence. */ +const KEEP = /(\{\w+\}|<\/?[a-zA-Z][a-zA-Z0-9]*\s*\/?>)/g; + +/** @param {string} value @returns {string} */ +export function pseudo(value) { + const parts = String(value == null ? '' : value).split(KEEP); + let body = ''; + let letters = 0; + for (let i = 0; i < parts.length; i++) { + if (i % 2) { + body += parts[i]; + continue; + } + body += parts[i].replace(/[aeiouAEIOU]/g, c => ACCENTS[c]); + letters += parts[i].length; + } + return `[${body}${'\u00b7'.repeat(Math.ceil(letters * 0.4))}]`; +} + +/* The page URL, not a setting: a development locale must not survive a reload + into someone else's session or be persisted to the config. */ +function devLocale() { + try { + const v = new URLSearchParams(location.search).get('lang'); + return v === PSEUDO_LANG || v === KEY_LANG ? v : null; + } catch { + return null; + } +} + export async function initI18n(code) { code = code || 'en'; + const dev = devLocale(); base = (await fetchCatalog('en')) || Object.create(null); - const loaded = code === 'en' ? base : await fetchCatalog(code); - active = loaded || base; - current = loaded && code !== 'en' ? code : 'en'; + + if (dev === KEY_LANG) { + base = Object.create(null); + active = base; + current = 'en'; + } else if (dev === PSEUDO_LANG) { + const mapped = Object.create(null); + for (const k of Object.keys(base)) mapped[k] = pseudo(base[k]); + base = mapped; + active = mapped; + current = PSEUDO_LANG; + } else { + const loaded = code === 'en' ? base : await fetchCatalog(code); + active = loaded || base; + current = loaded && code !== 'en' ? code : 'en'; + } + const el = document.documentElement; el.setAttribute('lang', current); el.setAttribute('dir', dirFor(current)); @@ -75,7 +173,8 @@ export function getLang() { data-i18n="key" -> textContent data-i18n-html="key" -> markup, limited to the tags i18n-markup.js allows data-i18n-ph="key" -> placeholder - data-i18n-al="key" -> aria-label */ + data-i18n-al="key" -> aria-label + data-i18n-title="key" -> title */ function translateDOM(root) { root = root || document; root.querySelectorAll('[data-i18n]').forEach(el => { @@ -91,10 +190,57 @@ function translateDOM(root) { root.querySelectorAll('[data-i18n-al]').forEach(el => { el.setAttribute('aria-label', t(el.getAttribute('data-i18n-al'))); }); + root.querySelectorAll('[data-i18n-title]').forEach(el => { + el.setAttribute('title', t(el.getAttribute('data-i18n-title'))); + }); +} + +/* Plural rules are the locale's own. Persian and French both put zero in the + `one` category, so choosing on `count === 1` is wrong in two of the six + languages shipped, and wrong in a different way in any language with `few` or + `many`. */ +/** @type {Map} */ +const pluralRules = new Map(); + +/** The CLDR plural category for a count in a locale. + @param {string} locale @param {number} count + @returns {Intl.LDMLPluralRule} */ +export function pluralCategory(locale, count) { + let rules = pluralRules.get(locale); + if (!rules) { + try { + rules = new Intl.PluralRules(locale); + } catch { + return count === 1 ? 'one' : 'other'; + } + pluralRules.set(locale, rules); + } + return rules.select(count); +} + +/* A counted message is stored as `key_`, one entry per category the + language uses. `other` is required, so it answers for a category a catalog + does not carry. */ +function lookupPlural(key, count) { + const category = pluralCategory(current, count); + for (const map of [active, base]) { + for (const suffix of [category, 'other']) { + const v = map[key + '_' + suffix]; + if (v != null) return v; + } + } + return null; } +/** Translate a key. Pass a numeric `count` for a counted message: it selects + the plural form and fills `{count}`. + @param {string} key @param {Record} [vars] + @returns {string} */ export function t(key, vars) { - let s = active[key] != null ? active[key] : base[key] != null ? base[key] : key; - if (vars) s = String(s).replace(/\{(\w+)\}/g, (m, k) => (vars[k] != null ? vars[k] : m)); + const count = vars ? vars.count : undefined; + let s = /** @type {string|null} */ (null); + if (typeof count === 'number' && Number.isFinite(count)) s = lookupPlural(key, count); + if (s == null) s = active[key] != null ? active[key] : base[key] != null ? base[key] : key; + if (vars) s = String(s).replace(/\{(\w+)\}/g, (m, k) => (vars[k] != null ? String(vars[k]) : m)); return s; } diff --git a/public/js/spotlight.js b/public/js/spotlight.js index 02e369f..d66a600 100644 --- a/public/js/spotlight.js +++ b/public/js/spotlight.js @@ -1,6 +1,6 @@ -import { mk, clr, el, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=26566e09'; +import { mk, clr, el, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=8ca7ce3c'; import { wrapTab } from '/js/dialog.js?v=05935547'; -import { t } from '/js/i18n.js?v=d056c9c5'; +import { t } from '/js/i18n.js?v=83239bf4'; /* Attached to the window so a re-open can undo the previous one. */ const _w = /** @type {any} */ (window); diff --git a/public/js/ui.js b/public/js/ui.js index 494c5de..16bf7c2 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -5,13 +5,14 @@ import { clr, mkWrap as _mkWrap, mountScaledWidget, + pageDir, teardownWidgets, el, q, qa, setUserText, -} from '/js/utils.js?v=26566e09'; -import { t, currentLang } from '/js/i18n.js?v=d056c9c5'; +} from '/js/utils.js?v=8ca7ce3c'; +import { t, currentLang } from '/js/i18n.js?v=83239bf4'; import { trapFocus } from '/js/dialog.js?v=05935547'; import { toneForColor } from '/js/label-contrast.js?v=38adb276'; import { mobileMetrics, gridColumnWidth, gridCellCount } from '/js/mobile-metrics.js?v=424d5d41'; @@ -139,6 +140,7 @@ export function mkFolder(item) { if (showLabel) { const l = mk('div'); l.className = 'ilabel'; + l.dir = 'auto'; l.style.width = iw + 12 + 'px'; setUserText(l, item.label || t('type.folder')); a.appendChild(l); @@ -188,6 +190,7 @@ export function openFolderDesktop(folder) { if (showLabel) { const l = mk('div'); l.className = 'ilabel'; + l.dir = 'auto'; l.style.width = iw + 12 + 'px'; setUserText(l, child.label || child.id); a.appendChild(l); @@ -316,6 +319,7 @@ function mFolder(item, cw, rh, isz, ir, im, sc) { if (showLabel) { const l = mk('div'); l.className = 'dyn-fold-label'; + l.dir = 'auto'; css(l, { '--lfs': Math.max(9, Math.round(9 * sc)) + 'px', '--lw': cw - 4 + 'px' }); setUserText(l, item.label || t('type.folder')); a.appendChild(l); @@ -418,7 +422,7 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) { let dotEls = []; function gotoPage(n) { curPage = Math.max(0, Math.min(pages.length - 1, n)); - strip.style.transform = strip.style.webkitTransform = `translateX(-${curPage * pageW}px)`; + strip.style.transform = strip.style.webkitTransform = `translateX(${-pageDir() * curPage * pageW}px)`; dotEls.forEach((d, j) => d.classList.toggle('on', j === curPage)); /* A page that has scrolled off stays focusable, so Tab would leave the visible page for tiles nobody can see. */ @@ -452,6 +456,7 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) { if (showLabel) { const l = mk('div'); l.className = 'dyn-fold-inner-label'; + l.dir = 'auto'; css(l, { '--lfs': Math.max(11, Math.round(11 * ptScale)) + 'px', '--fiw': folderIconW + 'px' }); setUserText(l, child.label || child.id); a.appendChild(l); @@ -520,7 +525,7 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) { if (!swiping) return; swiping = false; const dx = e.changedTouches[0].clientX - tx0; - if (Math.abs(dx) > Math.round(30 * ptScale)) gotoPage(curPage + (dx < 0 ? 1 : -1)); + if (Math.abs(dx) > Math.round(30 * ptScale)) gotoPage(curPage + (dx < 0 ? 1 : -1) * pageDir()); }, { passive: false }, ); @@ -695,6 +700,7 @@ export function buildMobile() { if (showLabel) { const l = mk('div'); l.className = 'dyn-mob-label'; + l.dir = 'auto'; css(l, { '--lfs': Math.max(9, Math.round(9 * sc)) + 'px', '--lw': '100%' }); setUserText(l, item.label || item.id); a.appendChild(l); @@ -839,7 +845,7 @@ export function buildMobile() { clearMobWidgets(); /* tap on the home background dismisses any active sled */ if (txOpenedWithFolder || folderOverlayMob) return; const dx = e.changedTouches[0].clientX - tx; - if (Math.abs(dx) > 40) goTo(st().pg + (dx < 0 ? 1 : -1)); + if (Math.abs(dx) > 40) goTo(st().pg + (dx < 0 ? 1 : -1) * pageDir()); }; document.addEventListener('touchstart', st()._mobTsCleanup, { passive: true }); document.addEventListener('touchend', st()._mobTeCleanup, { passive: true }); diff --git a/public/js/utils.js b/public/js/utils.js index b33c4f8..dc1c2e7 100644 --- a/public/js/utils.js +++ b/public/js/utils.js @@ -37,6 +37,10 @@ export { esc } from '/js/html.js?v=c71f8903'; alignment as well as bidi, so a Latin name on the block dragged its whole row to the other edge: a title and its subtitle ended up on opposite sides. + A centred label sets `dir` on the block as well, because alignment cannot move + centred text. Without it the block's direction decides which end is cut, and + WebKit ignores unicode-bidi:plaintext for that. + @param {HTMLElement} node @param {string} text @returns {HTMLElement} */ export const setUserText = (node, text) => { node.textContent = ''; @@ -46,6 +50,15 @@ export const setUserText = (node, text) => { return node; }; +/* A page strip is laid out in the page's direction, so the next page sits to the + left in one direction and to the right in the other. translateX has no logical + form, and a strip moved the wrong way leaves the screen: 1 where a page + advances leftwards, -1 where it advances rightwards. + + The same number mirrors the inputs. A leftward drag and the right arrow ask + for the next page in one direction and the previous page in the other. */ +export const pageDir = () => (getComputedStyle(document.documentElement).direction === 'rtl' ? -1 : 1); + /* A tile label is one line and ellipsises. The full name is on the anchor's accessible name either way, so this is for a pointer: a tooltip only where the text is actually cut, because one on every tile is noise. diff --git a/public/js/widget-config-form.js b/public/js/widget-config-form.js index 386143d..bd96599 100644 --- a/public/js/widget-config-form.js +++ b/public/js/widget-config-form.js @@ -2,10 +2,10 @@ back. Each builder returns { el, get, control, liveValue }. See docs/widgets.md. */ -import { t } from '/js/i18n.js?v=d056c9c5'; +import { t } from '/js/i18n.js?v=83239bf4'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { wireChecklist } from '/js/admin-shared.js?v=e9afcefb'; -import { renderColorControl } from '/js/admin-color-control.js?v=32111fda'; +import { wireChecklist } from '/js/admin-shared.js?v=1d330931'; +import { renderColorControl } from '/js/admin-color-control.js?v=bfd0955a'; import { seedCarried, applyOptionSet, @@ -13,9 +13,9 @@ import { requiredFieldMissing, groupBounds, visibleFieldFlags, -} from '/js/admin-logic.js?v=ddfc6f80'; +} from '/js/admin-logic.js?v=d17394da'; import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1'; -import { qi } from '/js/utils.js?v=26566e09'; +import { qi } from '/js/utils.js?v=8ca7ce3c'; const PE = ''; @@ -33,7 +33,7 @@ function _ieRow(field, value, inputType) { row.className = 'row ie-row'; setHtml( row, - html`${field.label}${_tag(field)}${has ? value : ph}`, + html`${field.label}${_tag(field)}${has ? value : ph}`, ); const rv = row.querySelector('.rv'), inp = qi('.row-inp', row), @@ -94,7 +94,7 @@ function _secret(field, isSet) { const display = isSet ? t('common.configured') : t('common.notSet'); setHtml( row, - html`${field.label}${_tag(field)}${display}`, + html`${field.label}${_tag(field)}${display}`, ); const rv = row.querySelector('.rv'), inp = qi('.row-inp', row), @@ -265,9 +265,7 @@ function _picklist(field, value, ctx, size) { try { opts = await _fetchOptions(field, ctx); paint(); - status.textContent = opts.length - ? t(opts.length === 1 ? 'widgetCfg.loaded' : 'widgetCfg.loadedPlural', { n: opts.length }) - : t('widgetCfg.noOptions'); + status.textContent = opts.length ? t('widgetCfg.loaded', { count: opts.length }) : t('widgetCfg.noOptions'); status.className = 'row-status ok'; } catch (e) { const advice = optionsErrorAdvice(e); @@ -350,9 +348,7 @@ function _select(field, value, ctx, config = {}) { chosen = sel.value || chosen; paint(); syncCarried(); - status.textContent = opts.length - ? t(opts.length === 1 ? 'widgetCfg.loaded' : 'widgetCfg.loadedPlural', { n: opts.length }) - : t('widgetCfg.noOptions'); + status.textContent = opts.length ? t('widgetCfg.loaded', { count: opts.length }) : t('widgetCfg.noOptions'); status.className = 'row-status ok'; } catch (e) { const advice = optionsErrorAdvice(e); diff --git a/public/js/yaml-lite.js b/public/js/yaml-lite.js index 0eafa55..b610be8 100644 --- a/public/js/yaml-lite.js +++ b/public/js/yaml-lite.js @@ -53,7 +53,7 @@ function flowNode(p, depth) { if (c === undefined) throw new YamlLiteError('unterminated flow collection', p.line); if (c === '[' || c === '{') return flowCollection(p, depth); if (c === '"' || c === "'") return flowQuoted(p); - let start = p.i; + const start = p.i; while (p.i < p.s.length && !/[,\]}]/.test(p.s[p.i])) { /* A colon only ends the key when a space or a closing bracket follows it, matching the block form, so "http://host:8080" stays one value. */ @@ -136,7 +136,7 @@ function parseFlow(s, line, ctx) { /** @param {string} raw @param {number} line @param {Ctx} ctx */ function scalar(raw, line, ctx) { - let s = raw.trim(); + const s = raw.trim(); if (s === '') return ''; const anchor = /^&(\S+)(?:\s+([\s\S]*))?$/.exec(s); if (anchor) { diff --git a/public/widgets/clock/analog.html b/public/widgets/clock/analog.html index 2b4dff0..0ff6554 100644 --- a/public/widgets/clock/analog.html +++ b/public/widgets/clock/analog.html @@ -44,6 +44,10 @@ try { return new Intl.DateTimeFormat(_lang, { weekday: 'long', month: 'long', day: 'numeric' }); } catch { return new Intl.DateTimeFormat('en', { weekday: 'long', month: 'long', day: 'numeric' }); } })(); +const _dateShortFmt = (() => { + try { return new Intl.DateTimeFormat(_lang, { weekday: 'short', month: 'short', day: 'numeric' }); } + catch { return new Intl.DateTimeFormat('en', { weekday: 'short', month: 'short', day: 'numeric' }); } +})(); function _a11yLabel(now){ const hh=String(now.getHours()).padStart(2,'0'), mm=String(now.getMinutes()).padStart(2,'0'); return `${hh}:${mm}, ${_dateFmt.format(now)}`; @@ -134,9 +138,7 @@ document.getElementById('widget').setAttribute('aria-label', _a11yLabel(now)); if(_showDate && _dateText){ - const D=['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; - const M=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; - _dateText.textContent=`${D[now.getDay()]}, ${M[now.getMonth()]} ${now.getDate()}`; + _dateText.textContent=_dateShortFmt.format(now); } } diff --git a/public/widgets/clock/digital.html b/public/widgets/clock/digital.html index 3e0c2d0..d986ef5 100644 --- a/public/widgets/clock/digital.html +++ b/public/widgets/clock/digital.html @@ -67,6 +67,10 @@ try { return new Intl.DateTimeFormat(_lang, { weekday: 'long', month: 'long', day: 'numeric' }); } catch { return new Intl.DateTimeFormat('en', { weekday: 'long', month: 'long', day: 'numeric' }); } })(); +const _dateShortFmt = (() => { + try { return new Intl.DateTimeFormat(_lang, { weekday: 'short', month: 'short', day: 'numeric' }); } + catch { return new Intl.DateTimeFormat('en', { weekday: 'short', month: 'short', day: 'numeric' }); } +})(); function _a11yLabel(now){ const hh=String(now.getHours()).padStart(2,'0'), mm=String(now.getMinutes()).padStart(2,'0'); return `${hh}:${mm}, ${_dateFmt.format(now)}`; @@ -177,10 +181,7 @@ hd.forEach((n,i) => { applyDigit(_hSVGs[i], n, _lastH[i]); _lastH[i] = n; }); md.forEach((n,i) => { applyDigit(_mSVGs[i], n, _lastM[i]); _lastM[i] = n; }); if (_showDate) { - const D = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; - const M = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; - document.getElementById('date-line').textContent = - `${D[now.getDay()]}, ${M[now.getMonth()]} ${now.getDate()}`; + document.getElementById('date-line').textContent = _dateShortFmt.format(now); } if (_tickId) clearTimeout(_tickId); _tickId = setTimeout(tick, (60 - now.getSeconds()) * 1000); diff --git a/public/widgets/dns/i18n/fa.json b/public/widgets/dns/i18n/fa.json index 28c31a3..b8651f3 100644 --- a/public/widgets/dns/i18n/fa.json +++ b/public/widgets/dns/i18n/fa.json @@ -5,7 +5,7 @@ "dnsUrl.placeholder": "dns-host:80", "dnsUrl.hint": "نام کانتینر و پورت، یا یک نشانی کامل.", "dnsHref.label": "نشانی کلیک", - "dnsHref.hint": "جایی که با کلیک روی ابزارک باز می‌شود. برای غیرفعال کردن خالی بگذارید.", + "dnsHref.hint": "جایی که با کلیک روی ویجت باز می‌شود. برای غیرفعال کردن خالی بگذارید.", "dnsUser.label": "نام کاربری", "dnsPass.label": "گذرواژه", "dnsPiholePassword.label": "گذرواژه Pi-hole", diff --git a/public/widgets/github/i18n/fa.json b/public/widgets/github/i18n/fa.json index 9d7a0b7..cde42c6 100644 --- a/public/widgets/github/i18n/fa.json +++ b/public/widgets/github/i18n/fa.json @@ -8,9 +8,9 @@ "githubUser.label": "نام کاربری GitHub", "githubUser.placeholder": "octocat", "githubToken.label": "توکن دسترسی شخصی", - "githubToken.hint": "توکن کلاسیک با دسترسی repo و read:user (یا توکن دقیق با همان سطح خواندن). فقط همین ابزارک از آن استفاده می‌کند.", + "githubToken.hint": "توکن کلاسیک با دسترسی repo و read:user (یا توکن دقیق با همان سطح خواندن). فقط همین ویجت از آن استفاده می‌کند.", "githubHref.label": "نشانی کلیک", - "githubHref.hint": "جایی که با کلیک روی ابزارک باز می‌شود. برای غیرفعال کردن خالی بگذارید.", + "githubHref.hint": "جایی که با کلیک روی ویجت باز می‌شود. برای غیرفعال کردن خالی بگذارید.", "githubPrFilters.label": "پالایه‌های درخواست ادغام", "githubPrFilters.hint": "کدام درخواست‌های باز نمایش داده شوند. چند پالایه با هم ترکیب می‌شوند.", "githubPrFilters.opt.created": "ساخته‌شده", diff --git a/public/widgets/github/pullrequests.html b/public/widgets/github/pullrequests.html index 23cc919..a7d33a1 100644 --- a/public/widgets/github/pullrequests.html +++ b/public/widgets/github/pullrequests.html @@ -142,7 +142,7 @@ const count = document.createElement('div'); count.className = 'small-count'; count.id = 'small-count'; count.textContent = '—'; const sub = document.createElement('div'); sub.className = 'small-sub'; sub.id = 'small-sub'; - sub.textContent = 'Pull Requests'; + sub.textContent = wt('view.prs.label', 'Pull Requests'); if (mobile) { count.style.fontSize = '54px'; sub.style.fontSize = '16px'; diff --git a/public/widgets/system-summary/i18n/fa.json b/public/widgets/system-summary/i18n/fa.json index 8c53f6d..f73cc12 100644 --- a/public/widgets/system-summary/i18n/fa.json +++ b/public/widgets/system-summary/i18n/fa.json @@ -31,9 +31,9 @@ "slots.primary.placeholder": "/", "slots.secondary.label": "دومین مسیر اتصال", "slots.secondary.placeholder": "/mnt/data", - "slots.fsPrimary.label": "نخستین سامانهٔ پرونده", + "slots.fsPrimary.label": "نخستین سیستم فایل", "slots.fsPrimary.placeholder": "root", - "slots.fsSecondary.label": "دومین سامانهٔ پرونده", + "slots.fsSecondary.label": "دومین سیستم فایل", "slots.fsSecondary.placeholder": "media", "slots.sharePrimary.label": "نخستین اشتراک", "slots.sharePrimary.placeholder": "media", diff --git a/src/content/docs/docs/advanced-configuration.md b/src/content/docs/docs/advanced-configuration.md index 005a85c..0ada4f7 100644 --- a/src/content/docs/docs/advanced-configuration.md +++ b/src/content/docs/docs/advanced-configuration.md @@ -22,6 +22,7 @@ Every variable is optional. The defaults are what the container ships with. | `CONFIG_PATH` | `/data/apps.json` | Where the config file lives. | | `ICONS_PATH` | `/icons` | Where uploaded icons are written. | | `WIDGETS_PATH` | `/usr/share/nginx/html/widgets` | Where widget folders are read from. A wrong path loads an empty registry and every widget reports as unknown. | +| `NODE_OPTIONS` | `--max-old-space-size=192` | The heap ceiling for the API. Node sizes its heap from the host's memory, not from the container's limit, so without this the API is killed instead of collecting. Keep it near half of the container's memory limit, and raise both together. | | `PORT` | `80` | Only for a hosting platform that reads `PORT` to decide where to route. It must be `80`, the port the container serves on. It does not move anything inside the container. To reach the dashboard on another port, change the published port instead. | If `CONFIG_PATH` or `ICONS_PATH` points at a folder that does not exist, the container logs a warning at startup. Writes to that path fail until the folder exists.