@tuhama/translation-manager 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/manager.js CHANGED
@@ -1,192 +1,47 @@
1
- const fs = require('fs-extra');
2
- const path = require('path');
3
- const lodash = require('lodash');
4
-
5
- /**
6
- * Scans the target directory for translation files.
7
- * Supports standard locales folders and auto-detection.
8
- */
9
- async function scanTranslations(targetDir, config = {}) {
10
- const defaultPaths = [
11
- 'public/locales',
12
- 'src/locales',
13
- 'src/i18n',
14
- 'locales'
15
- ];
16
-
17
- let localesDir = config.path;
18
-
19
- if (!localesDir) {
20
- // Auto-detect
21
- for (const p of defaultPaths) {
22
- const fullPath = path.resolve(targetDir, p);
23
- if (await fs.pathExists(fullPath) && (await fs.stat(fullPath)).isDirectory()) {
24
- localesDir = fullPath;
25
- break;
26
- }
27
- }
28
- } else {
29
- localesDir = path.resolve(targetDir, localesDir);
30
- }
31
-
32
- if (!localesDir || !(await fs.pathExists(localesDir))) {
33
- throw new Error('Could not find translation directory. Please specify it in the config.');
34
- }
35
-
36
- const files = await fs.readdir(localesDir);
37
- const jsonFiles = files.filter(f => f.endsWith('.json'));
38
-
39
- const translations = {};
40
- const languages = [];
41
-
42
- for (const file of jsonFiles) {
43
- const lang = path.basename(file, '.json');
44
- languages.push(lang);
45
- const content = await fs.readJson(path.join(localesDir, file));
46
- translations[lang] = content;
47
- }
48
-
49
- // Collect all keys
50
- const allKeys = new Set();
51
- const flattenKeys = (obj, prefix = '') => {
52
- Object.keys(obj).forEach(key => {
53
- const fullKey = prefix ? `${prefix}.${key}` : key;
54
- if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
55
- flattenKeys(obj[key], fullKey);
56
- } else {
57
- allKeys.add(fullKey);
58
- }
59
- });
60
- };
61
- languages.forEach(lang => flattenKeys(translations[lang]));
62
-
63
- // Calculate missing translations per key
64
- const results = {};
65
- allKeys.forEach(key => {
66
- const missing = [];
67
- languages.forEach(lang => {
68
- const val = lodash.get(translations[lang], key);
69
- if (val === undefined || val === '') {
70
- missing.push(lang);
71
- }
72
- });
73
- results[key] = {
74
- missing
75
- };
76
- });
77
-
78
- return {
79
- localesDir,
80
- languages,
81
- translations,
82
- allKeys: Array.from(allKeys).sort(),
83
- results
84
- };
85
- }
86
-
87
- /**
88
- * Normalizes all translation files by synchronizing keys and sorting alphabetically.
89
- */
90
- async function normalizeTranslations(localesDir) {
91
- const files = await fs.readdir(localesDir);
92
- const jsonFiles = files.filter(f => f.endsWith('.json'));
93
-
94
- // 1. Collect all unique keys
95
- const allKeys = new Set();
96
- const translations = {};
97
- const languages = [];
98
-
99
- for (const file of jsonFiles) {
100
- const lang = path.basename(file, '.json');
101
- languages.push(lang);
102
- const content = await fs.readJson(path.join(localesDir, file));
103
- translations[lang] = content;
104
-
105
- const flattenKeys = (obj, prefix = '') => {
106
- Object.keys(obj).forEach(key => {
107
- const fullKey = prefix ? `${prefix}.${key}` : key;
108
- if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
109
- flattenKeys(obj[key], fullKey);
110
- } else {
111
- allKeys.add(fullKey);
112
- }
113
- });
114
- };
115
- flattenKeys(content);
116
- }
117
-
118
- // 2. Sync all files with the universal key set and sort
119
- for (const lang of languages) {
120
- const content = translations[lang];
121
- allKeys.forEach(key => {
122
- if (lodash.get(content, key) === undefined) {
123
- lodash.set(content, key, '');
124
- }
125
- });
126
-
127
- const sortedContent = sortObject(content);
128
- const filePath = path.join(localesDir, `${lang}.json`);
129
- await fs.writeJson(filePath, sortedContent, { spaces: 2 });
130
- }
131
- }
132
-
133
- /**
134
- * Recursively sorts object keys alphabetically.
135
- */
136
- function sortObject(obj) {
137
- if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
138
- return obj;
139
- }
140
-
141
- const sorted = {};
142
- Object.keys(obj).sort().forEach(key => {
143
- sorted[key] = sortObject(obj[key]);
144
- });
145
- return sorted;
146
- }
147
-
148
- /**
149
- * Saves a translation key across all language files.
150
- */
151
- async function saveTranslation(localesDir, key, values) {
152
- const files = await fs.readdir(localesDir);
153
- const jsonFiles = files.filter(f => f.endsWith('.json'));
154
-
155
- for (const file of jsonFiles) {
156
- const lang = path.basename(file, '.json');
157
- const filePath = path.join(localesDir, file);
158
- const content = await fs.readJson(filePath);
159
-
160
- // Use lodash.set to handle nested keys
161
- if (values[lang] !== undefined) {
162
- lodash.set(content, key, values[lang]);
163
- }
164
-
165
- await fs.writeJson(filePath, content, { spaces: 2 });
166
- }
167
- }
168
-
169
- /**
170
- * Deletes a translation key across all language files.
171
- */
172
- async function deleteTranslation(localesDir, key) {
173
- const files = await fs.readdir(localesDir);
174
- const jsonFiles = files.filter(f => f.endsWith('.json'));
175
-
176
- for (const file of jsonFiles) {
177
- const filePath = path.join(localesDir, file);
178
- const content = await fs.readJson(filePath);
179
-
180
- // Use lodash.unset to handle nested keys
181
- lodash.unset(content, key);
182
-
183
- await fs.writeJson(filePath, content, { spaces: 2 });
184
- }
185
- }
186
-
187
- module.exports = {
188
- scanTranslations,
189
- saveTranslation,
190
- deleteTranslation,
191
- normalizeTranslations
192
- };
1
+ const path = require('path');
2
+ const TranslatorManager = require('./core/TranslatorManager');
3
+
4
+ /**
5
+ * Facade for the new SOLID core architecture.
6
+ * Maintains backward compatibility while delegating to the TranslatorManager.
7
+ */
8
+
9
+ async function scanTranslations(targetDir, config = {}) {
10
+ const manager = new TranslatorManager(targetDir, config);
11
+ return await manager.scan();
12
+ }
13
+
14
+ async function findUnusedKeys(targetDir, localesDir, allKeys, config = {}) {
15
+ const Scanner = require('./core/Scanner');
16
+ const scanner = new Scanner(targetDir, localesDir, config);
17
+ return await scanner.findUnusedKeys(allKeys);
18
+ }
19
+
20
+ async function normalizeTranslations(localesDir) {
21
+ const manager = new TranslatorManager(path.dirname(localesDir), { path: path.basename(localesDir) });
22
+ await manager.normalize();
23
+ }
24
+
25
+ async function saveTranslation(localesDir, key, values) {
26
+ const manager = new TranslatorManager(path.dirname(localesDir), { path: path.basename(localesDir) });
27
+ await manager.saveTranslation(key, values);
28
+ }
29
+
30
+ async function deleteTranslation(localesDir, key) {
31
+ const manager = new TranslatorManager(path.dirname(localesDir), { path: path.basename(localesDir) });
32
+ await manager.deleteTranslations([key]);
33
+ }
34
+
35
+ async function deleteMultipleTranslations(localesDir, keys) {
36
+ const manager = new TranslatorManager(path.dirname(localesDir), { path: path.basename(localesDir) });
37
+ await manager.deleteTranslations(keys);
38
+ }
39
+
40
+ module.exports = {
41
+ scanTranslations,
42
+ findUnusedKeys,
43
+ saveTranslation,
44
+ deleteTranslation,
45
+ deleteMultipleTranslations,
46
+ normalizeTranslations
47
+ };
package/src/server.js CHANGED
@@ -1,64 +1,130 @@
1
- const express = require('express');
2
- const cors = require('cors');
3
- const path = require('path');
4
- const history = require('express-history-api-fallback');
5
- const { scanTranslations, saveTranslation, deleteTranslation, normalizeTranslations } = require('./manager');
6
-
7
- function startServer(targetDir, port = 3000, config = {}) {
8
- const app = express();
9
- app.use(cors());
10
- app.use(express.json());
11
-
12
- // API endpoints
13
- app.get('/api/translations', async (req, res) => {
14
- try {
15
- const data = await scanTranslations(targetDir, config);
16
- res.json(data);
17
- } catch (err) {
18
- res.status(500).json({ error: err.message });
19
- }
20
- });
21
-
22
- app.post('/api/translations', async (req, res) => {
23
- try {
24
- const { key, values } = req.body;
25
- const data = await scanTranslations(targetDir, config);
26
- await saveTranslation(data.localesDir, key, values);
27
- res.json({ success: true });
28
- } catch (err) {
29
- res.status(500).json({ error: err.message });
30
- }
31
- });
32
-
33
- app.delete('/api/translations', async (req, res) => {
34
- try {
35
- const { key } = req.body;
36
- const data = await scanTranslations(targetDir, config);
37
- await deleteTranslation(data.localesDir, key);
38
- res.json({ success: true });
39
- } catch (err) {
40
- res.status(500).json({ error: err.message });
41
- }
42
- });
43
-
44
- app.post('/api/normalize', async (req, res) => {
45
- try {
46
- const data = await scanTranslations(targetDir, config);
47
- await normalizeTranslations(data.localesDir);
48
- res.json({ success: true });
49
- } catch (err) {
50
- res.status(500).json({ error: err.message });
51
- }
52
- });
53
-
54
- // Serve static files from the build folder
55
- const buildPath = path.resolve(__dirname, '../web/dist');
56
- app.use(express.static(buildPath));
57
- app.use(history('index.html', { root: buildPath }));
58
-
59
- app.listen(port, () => {
60
- console.log(`\x1b[32m✔\x1b[0m Translation Manager UI is running at http://localhost:${port}`);
61
- });
62
- }
63
-
64
- module.exports = { startServer };
1
+ const express = require('express');
2
+ const cors = require('cors');
3
+ const path = require('path');
4
+ const history = require('express-history-api-fallback');
5
+ const TranslatorManager = require('./core/TranslatorManager');
6
+
7
+ /**
8
+ * Starts the translation manager server.
9
+ */
10
+ function startServer(targetDir, port = 3000, config = {}) {
11
+ const app = express();
12
+ const manager = new TranslatorManager(targetDir, config);
13
+
14
+ app.use(cors());
15
+ app.use(express.json());
16
+
17
+ // API endpoints
18
+ app.get('/api/translations', async (req, res) => {
19
+ try {
20
+ const data = await manager.scan();
21
+ res.json(data);
22
+ } catch (err) {
23
+ res.status(500).json({ error: err.message });
24
+ }
25
+ });
26
+
27
+ app.post('/api/translations', async (req, res) => {
28
+ try {
29
+ const { key, values } = req.body;
30
+ await manager.saveTranslation(key, values);
31
+ res.json({ success: true });
32
+ } catch (err) {
33
+ res.status(500).json({ error: err.message });
34
+ }
35
+ });
36
+
37
+ app.delete('/api/translations', async (req, res) => {
38
+ try {
39
+ const { key } = req.body;
40
+ await manager.deleteTranslations(key);
41
+ res.json({ success: true });
42
+ } catch (err) {
43
+ res.status(500).json({ error: err.message });
44
+ }
45
+ });
46
+
47
+ app.post('/api/delete-keys', async (req, res) => {
48
+ try {
49
+ const { keys } = req.body;
50
+ await manager.deleteTranslations(keys);
51
+ res.json({ success: true });
52
+ } catch (err) {
53
+ res.status(500).json({ error: err.message });
54
+ }
55
+ });
56
+
57
+ app.post('/api/normalize', async (req, res) => {
58
+ try {
59
+ await manager.normalize();
60
+ res.json({ success: true });
61
+ } catch (err) {
62
+ res.status(500).json({ error: err.message });
63
+ }
64
+ });
65
+
66
+ app.post('/api/translate', async (req, res) => {
67
+ try {
68
+ const { text, targetLang, sourceLang } = req.body;
69
+ const translatedText = await manager.translateSingle(text, targetLang, sourceLang);
70
+ res.json({ translatedText });
71
+ } catch (err) {
72
+ res.status(500).json({ error: err.message });
73
+ }
74
+ });
75
+
76
+ app.get('/api/bulk-translate/scan', async (req, res) => {
77
+ try {
78
+ const { sourceLang } = req.query;
79
+ const report = await manager.getBulkTranslateReport(sourceLang || 'en');
80
+ res.json(report);
81
+ } catch (err) {
82
+ res.status(500).json({ error: err.message });
83
+ }
84
+ });
85
+
86
+ app.post('/api/bulk-translate/execute', async (req, res) => {
87
+ try {
88
+ const { sourceLang } = req.body;
89
+ const preview = await manager.bulkTranslate(sourceLang || 'en');
90
+ res.json(preview);
91
+ } catch (err) {
92
+ res.status(500).json({ error: err.message });
93
+ }
94
+ });
95
+
96
+ app.post('/api/bulk-save', async (req, res) => {
97
+ try {
98
+ const { data } = req.body;
99
+ await manager.saveBulkTranslations(data);
100
+ res.json({ success: true });
101
+ } catch (err) {
102
+ res.status(500).json({ error: err.message });
103
+ }
104
+ });
105
+
106
+ app.get('/api/config', (req, res) => {
107
+ res.json(manager.config);
108
+ });
109
+
110
+ app.post('/api/settings', async (req, res) => {
111
+ try {
112
+ const { settings } = req.body;
113
+ await manager.saveConfig(settings);
114
+ res.json({ success: true, config: manager.config });
115
+ } catch (err) {
116
+ res.status(500).json({ error: err.message });
117
+ }
118
+ });
119
+
120
+ // Serve static files from the build folder
121
+ const buildPath = path.resolve(__dirname, '../web/dist');
122
+ app.use(express.static(buildPath));
123
+ app.use(history('index.html', { root: buildPath }));
124
+
125
+ app.listen(port, () => {
126
+ console.log(`\x1b[32m✔\x1b[0m Translation Manager UI is running at http://localhost:${port}`);
127
+ });
128
+ }
129
+
130
+ module.exports = { startServer };
@@ -0,0 +1 @@
1
+ :root{--bg-color:#0b0e14;--sidebar-bg:#111827cc;--accent-color:#7c3aed;--accent-hover:#6d28d9;--text-color:#f3f4f6;--text-muted:#9ca3af;--border-color:#ffffff14;--glass-bg:#ffffff08;--glass-blur:blur(20px);--green:#10b981;--red:#ef4444;--transition:all .25s ease}*{box-sizing:border-box;margin:0;padding:0}body{background-color:var(--bg-color);color:var(--text-color);-webkit-font-smoothing:antialiased;font-family:Inter,system-ui,-apple-system,sans-serif;line-height:1.6}#root{height:100vh;display:flex}.app-container{grid-template-columns:350px 1fr;width:100%;height:100vh;display:grid}.sidebar{background:var(--sidebar-bg);-webkit-backdrop-filter:var(--glass-blur);backdrop-filter:var(--glass-blur);border-right:1px solid var(--border-color);flex-direction:column;display:flex;overflow:hidden}.sidebar-header{border-bottom:1px solid var(--border-color);padding:32px 24px 20px}.brand-row{align-items:center;gap:16px;margin-bottom:24px;display:flex}.logo{filter:drop-shadow(0 0 10px #7c3aed66);border-radius:10px;width:42px;height:42px}.sidebar-header h1{background:linear-gradient(135deg,#a78bfa 0%,#3b82f6 100%);-webkit-text-fill-color:transparent;-webkit-background-clip:text;background-clip:text;margin-bottom:0;font-size:1.5rem;font-weight:800}.search-box{margin-bottom:16px;position:relative}.search-box input{border:1px solid var(--border-color);color:#fff;background:#0003;border-radius:10px;width:100%;padding:12px 16px;font-size:.95rem}.sidebar-actions{gap:8px;margin-top:16px;display:flex}.create-btn{border:1px solid var(--accent-color);color:var(--accent-color);cursor:pointer;transition:var(--transition);background:#7c3aed1a;border-radius:10px;flex:2;padding:11px;font-weight:600}.normalize-btn{border:1px solid var(--green);color:var(--green);cursor:pointer;transition:var(--transition);background:#10b9811a;border-radius:10px;flex:1;justify-content:center;align-items:center;padding:11px;font-size:.85rem;font-weight:600;display:flex}.normalize-btn:hover{background:var(--green);color:#fff}.clean-btn{color:#f59e0b;cursor:pointer;transition:var(--transition);background:#f59e0b1a;border:1px solid #f59e0b;border-radius:10px;flex:1;justify-content:center;align-items:center;padding:11px;font-size:.85rem;font-weight:600;display:flex}.clean-btn:hover{color:#fff;background:#f59e0b}.create-btn:hover{background:var(--accent-color);color:#fff}.tree-view{flex:1;padding:16px;overflow-y:auto}.key-list{list-style:none}.key-list li{cursor:pointer;transition:var(--transition);color:var(--text-muted);border-radius:8px;justify-content:space-between;align-items:center;margin-bottom:4px;padding:10px 14px;font-size:.9rem;display:flex}.key-list li.unused{opacity:.5;font-style:italic}.key-list li.unused span:after{content:" (unused)";opacity:.7;margin-left:4px;font-size:.7rem}.key-list li span{white-space:nowrap;text-overflow:ellipsis;flex:1;overflow:hidden}.key-list li:hover{color:#fff;background:#ffffff0d}.key-list li.active{color:#a78bfa;background:#7c3aed26;font-weight:600}.key-list li.incomplete span{color:var(--text-muted)}.warning-dot{filter:drop-shadow(0 0 5px #ef444480);margin-left:8px;font-size:.8rem}.delete-icon{opacity:0;color:var(--red);cursor:pointer;background:0 0;border:none;padding:0 4px;font-size:1.25rem}.key-list li:hover .delete-icon{opacity:1}.editor{justify-content:center;align-items:flex-start;padding:40px;display:flex;overflow-y:auto}.editor-form{width:100%;max-width:800px;-webkit-backdrop-filter:var(--glass-blur);backdrop-filter:var(--glass-blur);border:1px solid var(--border-color);background:#1f293766;border-radius:20px;padding:40px;box-shadow:0 25px 50px -12px #00000080}.form-group{text-align:left;margin-bottom:24px}.form-group.missing label{color:var(--red)}.form-group.missing input,.form-group.missing textarea{border-color:#ef44444d}.missing-label{margin-left:4px;font-size:.75rem;font-style:italic;font-weight:400}.form-group input,.form-group textarea{border:1px solid var(--border-color);color:#fff;background:#0000004d;border-radius:10px;width:100%;padding:14px;font-family:inherit;font-size:1rem}.form-group textarea{resize:vertical;min-height:100px}.form-actions{gap:16px;margin-top:32px;display:flex}.primary-btn{background:var(--accent-color);color:#fff;cursor:pointer;transition:var(--transition);border:none;border-radius:10px;padding:14px 28px;font-weight:600}.primary-btn:hover{background:var(--accent-hover);transform:translateY(-1px)}.secondary-btn{border:1px solid var(--border-color);color:var(--text-muted);cursor:pointer;background:0 0;border-radius:10px;padding:14px 28px;font-weight:600}.empty-state{text-align:center;max-width:500px}.hero-icon{filter:drop-shadow(0 0 20px #8b5cf64d);margin-bottom:24px;font-size:4rem}.empty-state h2{margin-bottom:12px;font-size:2rem}.empty-state p{color:var(--text-muted);margin-bottom:32px}.modal-overlay{-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);z-index:1000;background:#000000b3;justify-content:center;align-items:center;padding:20px;display:flex;position:fixed;inset:0}.modal-content{border:1px solid var(--border-color);background:#1f2937;border-radius:20px;flex-direction:column;width:100%;max-width:600px;max-height:80vh;display:flex;overflow:hidden;box-shadow:0 25px 50px -12px #00000080}.modal-header{border-bottom:1px solid var(--border-color);justify-content:space-between;align-items:center;padding:24px;display:flex}.modal-body{flex:1;padding:24px;overflow-y:auto}.cleanup-item{cursor:pointer;background:#ffffff08;border-radius:10px;align-items:center;gap:12px;margin-bottom:8px;padding:12px;display:flex}.cleanup-item:hover{background:#ffffff0f}.cleanup-item input{accent-color:var(--accent-color)}.maybe-used-warning{color:#f59e0b;align-items:center;gap:4px;margin-left:auto;font-size:.8rem;font-style:italic;display:flex}.modal-footer{border-top:1px solid var(--border-color);justify-content:flex-end;gap:12px;padding:20px;display:flex}.sidebar-tools{gap:8px;margin-top:12px;display:flex}.tool-btn{border:1px solid var(--border-color);color:var(--text-muted);cursor:pointer;transition:var(--transition);background:#ffffff08;border-radius:8px;flex:1;justify-content:center;align-items:center;gap:6px;padding:8px 12px;font-size:.8rem;font-weight:500;display:flex}.tool-btn:hover{color:#fff;background:#ffffff14;border-color:#fff3}.tool-btn.icon-text{flex:2}.editor-header-row{justify-content:space-between;align-items:center;margin-bottom:32px;display:flex}.magic-btn{color:#a78bfa;background:#7c3aed1a;border-color:#7c3aed4d;padding:10px 16px;font-size:.85rem}.magic-btn:hover{background:var(--accent-color);color:#fff}.label-row{justify-content:space-between;align-items:center;margin-bottom:8px;display:flex}.label-row label{margin-bottom:0}.icon-btn{cursor:pointer;transition:var(--transition);background:0 0;border:none;border-radius:6px;justify-content:center;align-items:center;padding:4px;display:flex}.icon-btn:hover{background:#ffffff1a}.magic-wand{opacity:.6;font-size:1.1rem}.form-group:hover .magic-wand{opacity:1}.alert{border-radius:10px;margin-bottom:20px;padding:12px 16px;font-size:.9rem}.alert-success{color:#34d399;background:#10b9811a;border:1px solid #10b9814d}.alert-error{color:#f87171;background:#ef44441a;border:1px solid #ef44444d}.wizard-step{margin-bottom:24px}.wizard-step label{color:var(--text-color);margin-bottom:12px;font-weight:600;display:block}.source-select{border:1px solid var(--border-color);color:#fff;background:#0000004d;border-radius:10px;width:100%;padding:12px;font-size:1rem}.report-summary{border:1px solid var(--border-color);background:#0003;border-radius:12px;padding:16px}.report-summary ul{color:var(--text-muted);margin-top:12px;padding-left:20px}.preview-container{border:1px solid var(--border-color);background:#0003;border-radius:12px;max-height:400px;padding:16px;overflow-y:auto}.lang-preview{margin-bottom:24px}.lang-preview h3{color:var(--accent-color);border-bottom:1px solid var(--border-color);margin-bottom:12px;padding-bottom:4px;font-size:.85rem}.preview-list{flex-direction:column;gap:8px;display:flex}.preview-item{gap:8px;font-size:.85rem;display:flex}.preview-key{color:var(--text-muted);font-weight:500}.preview-value{color:var(--text-color)}.settings-input{letter-spacing:2px}.help-text{color:var(--text-muted);margin-top:8px;font-size:.8rem}.help-text a{color:var(--accent-color);text-decoration:none}.help-text a:hover{text-decoration:underline}.success-text{color:var(--green);font-weight:500}.approve-btn{background:var(--green)}.approve-btn:hover{background:#059669}.tool-btn.warning{color:#f59e0b;background:#f59e0b1a;border-color:#f59e0b4d}.tool-btn.warning:hover{color:#fff;background:#f59e0b}.missing-keys-list{margin-top:16px;list-style:none}.missing-key-item{border:1px solid var(--border-color);background:#ffffff08;border-radius:12px;justify-content:space-between;align-items:center;margin-bottom:8px;padding:14px;display:flex}.key-path{color:#a78bfa;font-family:JetBrains Mono,Fira Code,monospace;font-size:.85rem}.warning-box{color:#f59e0b;background:#f59e0b14;border:1px solid #f59e0b33;border-radius:12px;margin-bottom:24px;padding:16px;font-size:.85rem}.sm{padding:8px 14px;font-size:.8rem}