Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions web/eslint-rules/no-literal-ui-string.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const baseline = JSON.parse(fs.readFileSync(new URL('../i18n-literal-baseline.json', import.meta.url), 'utf8'));
const componentsRoot = fileURLToPath(new URL('../js/components/', import.meta.url));
const visibleAttributes = new Set(['title', 'aria-label', 'placeholder', 'alt']);

function candidate(value) {
const normalized = value.replace(/\s+/g, ' ').trim();
return /[A-Za-z]/.test(normalized) ? normalized : null;
}

// Only inspect string branches that become JSX children. Calls such as t('key'),
// class names, API paths, and logging arguments are outside this rule's scope.
function expressionStrings(node) {
if (!node) return [];
if (node.type === 'Literal' && typeof node.value === 'string') return [node.value];
if (node.type === 'TemplateLiteral') return node.quasis.map((part) => part.value.cooked || '');
if (node.type === 'ConditionalExpression') {
return [...expressionStrings(node.consequent), ...expressionStrings(node.alternate)];
}
if (node.type === 'LogicalExpression') return expressionStrings(node.right);
if (node.type === 'BinaryExpression' && node.operator === '+') {
return [...expressionStrings(node.left), ...expressionStrings(node.right)];
}
return [];
}

export default {
meta: {
type: 'problem',
docs: { description: 'Disallow new user-visible JSX literals' },
schema: [],
},
create(context) {
const file = path.relative(componentsRoot, context.filename).replaceAll(path.sep, '/');
const allowed = baseline[file] || {};
const seen = new Map();

function check(node, text) {
const value = candidate(text);
if (!value) return;
const count = (seen.get(value) || 0) + 1;
seen.set(value, count);
if (process.env.I18N_LITERAL_BASELINE === '1' || count > (allowed[value] || 0)) {
context.report({ node, message: `Untranslated UI string: ${JSON.stringify(value)}` });
}
}

return {
JSXText(node) { check(node, node.value); },
JSXAttribute(node) {
if (visibleAttributes.has(node.name.name) && node.value?.type === 'Literal') {
check(node, node.value.value);
}
},
JSXExpressionContainer(node) {
if (node.parent.type === 'JSXAttribute') return;
for (const value of expressionStrings(node.expression)) check(node, value);
},
};
},
};
8 changes: 8 additions & 0 deletions web/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/
import jsxA11y from 'eslint-plugin-jsx-a11y';
import reactHooks from 'eslint-plugin-react-hooks';
import noLiteralUiString from './eslint-rules/no-literal-ui-string.js';

const recommendedRules = jsxA11y.flatConfigs.recommended.rules;

Expand Down Expand Up @@ -45,4 +46,11 @@ export default [
'react-hooks/rules-of-hooks': 'off',
},
},
{
files: ['js/components/**/*.js', 'js/components/**/*.jsx'],
// Existing technical labels and examples are counted by file in the
// baseline. New JSX text or visible-attribute literals fail lint.
plugins: { 'lightnvr-i18n': { rules: { 'no-literal-ui-string': noLiteralUiString } } },
rules: { 'lightnvr-i18n/no-literal-ui-string': 'error' },
},
];
124 changes: 124 additions & 0 deletions web/i18n-literal-baseline.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
{
"preact/BatchDownloadModal.jsx": {
"recordings.zip": 1
},
"preact/EventRoutingView.jsx": {
"· QoS": 1
},
"preact/FisheyeEptzCanvas.jsx": {
"ePTZ": 1
},
"preact/Footer.jsx": {
"LightNVR ©": 1
},
"preact/HLSVideoCell.jsx": {
"go2rtc": 1,
"HLS": 2
},
"preact/Header.jsx": {
"LightNVR": 1,
"v": 1
},
"preact/HealthView.jsx": {
"ms": 1
},
"preact/LoginView.jsx": {
"LightNVR": 1
},
"preact/MSEVideoCell.jsx": {
"PTZ": 1,
"MSE": 1
},
"preact/SetupWizard.jsx": {
"/var/lib/lightnvr/recordings": 1,
"/var/lib/lightnvr/lightnvr.db": 1
},
"preact/StreamCard.jsx": {
"ONVIF": 2
},
"preact/StreamConfigModal.jsx": {
"http://192.168.1.100/": 1,
"http://": 1,
"https://": 1,
"outdoor,critical": 1,
"admin": 1,
"Profile_1": 1,
"H.264 (AVC)": 1,
"H.265 (HEVC)": 1,
"TCP": 1,
"UDP": 1,
"[api_detection]": 1,
"rtmp://a.rtmp.youtube.com/live2/xxxx-xxxx-xxxx-xxxx-xxxx": 1
},
"preact/StreamsView.jsx": {
"admin": 1,
"password": 1,
"x": 1
},
"preact/WebRTCVideoCell.jsx": {
"WebRTC": 1
},
"preact/events/EventDestinationEditor.jsx": {
"mqtt.example.net": 1,
"lightnvr/v1/status/{installation_uuid}/{destination_uuid}": 1,
"/etc/lightnvr/certs/broker-ca.pem": 1,
"/etc/lightnvr/certs/client.pem": 1,
"/etc/lightnvr/certs/client.key": 1
},
"preact/events/EventRouteEditor.jsx": {
"person, vehicle": 1,
"entrance, loading-dock": 1,
"America/New_York": 1
},
"preact/fleet/FleetTable.jsx": {
"FPS": 1
},
"preact/fleet/OrganizationManager.jsx": {
"#2563eb": 1
},
"preact/settings/AuthTab.jsx": {
"X-Forwarded-For": 1,
"X-Real-IP": 1
},
"preact/settings/DetectionTab.jsx": {
"/var/lib/lightnvr/models": 1,
"default": 1
},
"preact/settings/GeneralTab.jsx": {
"lightnvr": 1,
"LOG_USER": 1,
"LOG_DAEMON": 1,
"LOG_LOCAL0": 1,
"LOG_LOCAL1": 1,
"LOG_LOCAL2": 1,
"LOG_LOCAL3": 1,
"LOG_LOCAL4": 1,
"LOG_LOCAL5": 1,
"LOG_LOCAL6": 1,
"LOG_LOCAL7": 1
},
"preact/settings/Go2rtcTab.jsx": {
"/usr/local/bin/go2rtc": 1,
"/etc/lightnvr/go2rtc": 1,
"stun.l.google.com:19302": 1,
"override.yaml": 1
},
"preact/settings/MqttTab.jsx": {
"localhost": 1,
"lightnvr": 2,
"homeassistant": 1
},
"preact/settings/StorageTab.jsx": {
".bak": 1,
"/usr/local/bin/lightnvr-post-backup": 1
},
"preact/users/AddUserModal.jsx": {
"(CIDR)": 1
},
"preact/users/AuditHistoryModal.jsx": {
"recordings.export": 1
},
"preact/users/EditUserModal.jsx": {
"(CIDR)": 1
}
}
4 changes: 3 additions & 1 deletion web/js/components/preact/AsyncButton.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
import { Fragment } from 'preact';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { useAsyncAction } from '../../hooks/useAsyncAction.js';
import { useI18n } from '../../i18n.js';

/**
* Inline 16x16 spinner icon, rotated via Tailwind's `animate-spin`.
Expand Down Expand Up @@ -131,6 +132,7 @@ export function AsyncButton({
type = 'button',
...rest
}) {
const { t } = useI18n();
const { run, pending, error } = useAsyncAction(onClick || (() => Promise.resolve()));

// Track the brief success flash.
Expand Down Expand Up @@ -241,7 +243,7 @@ export function AsyncButton({
content = (
<span class="inline-flex items-center justify-center gap-2">
<SpinnerIcon />
<span class="sr-only">Working…</span>
<span class="sr-only">{t('recordings.working')}</span>
</span>
);
} else if (showSuccess) {
Expand Down
Loading
Loading