@mtldev514/retro-portfolio-maker 1.0.14 β 1.0.15
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/README.md +5 -2
- package/bin/cli.js +33 -0
- package/engine/admin/scripts/manager.py +7 -0
- package/engine/admin/scripts/validate_config.py +364 -0
- package/engine/admin.html +11 -0
- package/engine/validator.html +438 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@mtldev514/retro-portfolio-maker)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
This is what I always wanted. As someone with multiple interests, I always wanted to have a portfolio to display my many, many, many projects. I also have a very soft spot for the early 2000s.
|
|
7
|
+
|
|
8
|
+
I hope this portfolio manager will help you have a personal presence that speaks to your soul. Fully customizable. You can easily support many languages, and play with the theme to make it your own.
|
|
9
|
+
|
|
10
|
+
---
|
|
8
11
|
|
|
9
12
|
## π― Concept
|
|
10
13
|
|
package/bin/cli.js
CHANGED
|
@@ -83,6 +83,39 @@ program
|
|
|
83
83
|
}
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
+
// Validate command - Check configuration files
|
|
87
|
+
program
|
|
88
|
+
.command('validate')
|
|
89
|
+
.description('Validate all configuration and data files')
|
|
90
|
+
.option('--path <dir>', 'Portfolio content directory', '.')
|
|
91
|
+
.action(async (options) => {
|
|
92
|
+
try {
|
|
93
|
+
const { spawn } = require('child_process');
|
|
94
|
+
const enginePath = path.join(__dirname, '..', 'engine', 'admin', 'scripts');
|
|
95
|
+
const validatorPath = path.join(enginePath, 'validate_config.py');
|
|
96
|
+
|
|
97
|
+
console.log(chalk.cyan('π Running configuration validator...\n'));
|
|
98
|
+
|
|
99
|
+
const python = spawn('python3', [validatorPath, '--path', options.path], {
|
|
100
|
+
stdio: 'inherit',
|
|
101
|
+
env: { ...process.env, PORTFOLIO_CONTENT_ROOT: options.path }
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
python.on('close', (code) => {
|
|
105
|
+
process.exit(code);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
python.on('error', (err) => {
|
|
109
|
+
console.error(chalk.red('Error running validator:'), err.message);
|
|
110
|
+
console.log(chalk.yellow('\nMake sure Python 3 is installed and in your PATH'));
|
|
111
|
+
process.exit(1);
|
|
112
|
+
});
|
|
113
|
+
} catch (error) {
|
|
114
|
+
console.error(chalk.red('Error:'), error.message);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
86
119
|
// Deploy command - Deploy to GitHub Pages
|
|
87
120
|
program
|
|
88
121
|
.command('deploy')
|
|
@@ -11,7 +11,14 @@ from datetime import datetime
|
|
|
11
11
|
import cloudinary
|
|
12
12
|
import cloudinary.uploader
|
|
13
13
|
import requests
|
|
14
|
+
import sys
|
|
14
15
|
from dotenv import load_dotenv
|
|
16
|
+
|
|
17
|
+
# Ensure the current directory is in the path so we can import local modules
|
|
18
|
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
19
|
+
if current_dir not in sys.path:
|
|
20
|
+
sys.path.append(current_dir)
|
|
21
|
+
|
|
15
22
|
from config_loader import config
|
|
16
23
|
|
|
17
24
|
# Load environment variables
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Portfolio Configuration Validator
|
|
4
|
+
Validates all config files, data files, and translations for consistency and correctness.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import os
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, List, Tuple, Any
|
|
12
|
+
|
|
13
|
+
# Ensure the current directory is in the path so we can import local modules
|
|
14
|
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
15
|
+
if current_dir not in sys.path:
|
|
16
|
+
sys.path.append(current_dir)
|
|
17
|
+
|
|
18
|
+
from config_loader import ConfigLoader
|
|
19
|
+
|
|
20
|
+
# ANSI color codes for terminal output
|
|
21
|
+
class Colors:
|
|
22
|
+
HEADER = '\033[95m'
|
|
23
|
+
OKBLUE = '\033[94m'
|
|
24
|
+
OKCYAN = '\033[96m'
|
|
25
|
+
OKGREEN = '\033[92m'
|
|
26
|
+
WARNING = '\033[93m'
|
|
27
|
+
FAIL = '\033[91m'
|
|
28
|
+
ENDC = '\033[0m'
|
|
29
|
+
BOLD = '\033[1m'
|
|
30
|
+
UNDERLINE = '\033[4m'
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ConfigValidator:
|
|
34
|
+
def __init__(self, content_root=None):
|
|
35
|
+
self.content_root = Path(content_root) if content_root else Path('.')
|
|
36
|
+
self.config_dir = self.content_root / 'config'
|
|
37
|
+
self.data_dir = self.content_root / 'data'
|
|
38
|
+
self.lang_dir = self.content_root / 'lang'
|
|
39
|
+
|
|
40
|
+
self.errors = []
|
|
41
|
+
self.warnings = []
|
|
42
|
+
self.info = []
|
|
43
|
+
|
|
44
|
+
self.config_loader = ConfigLoader(content_root)
|
|
45
|
+
|
|
46
|
+
def add_error(self, message: str):
|
|
47
|
+
"""Add an error message"""
|
|
48
|
+
self.errors.append(f"{Colors.FAIL}β ERROR:{Colors.ENDC} {message}")
|
|
49
|
+
|
|
50
|
+
def add_warning(self, message: str):
|
|
51
|
+
"""Add a warning message"""
|
|
52
|
+
self.warnings.append(f"{Colors.WARNING}β WARNING:{Colors.ENDC} {message}")
|
|
53
|
+
|
|
54
|
+
def add_info(self, message: str):
|
|
55
|
+
"""Add an info message"""
|
|
56
|
+
self.info.append(f"{Colors.OKBLUE}βΉ INFO:{Colors.ENDC} {message}")
|
|
57
|
+
|
|
58
|
+
def validate_json_file(self, file_path: Path) -> Tuple[bool, Any]:
|
|
59
|
+
"""Validate that a file contains valid JSON"""
|
|
60
|
+
if not file_path.exists():
|
|
61
|
+
self.add_error(f"File not found: {file_path}")
|
|
62
|
+
return False, None
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
66
|
+
data = json.load(f)
|
|
67
|
+
return True, data
|
|
68
|
+
except json.JSONDecodeError as e:
|
|
69
|
+
self.add_error(f"Invalid JSON in {file_path.name}: {e}")
|
|
70
|
+
return False, None
|
|
71
|
+
except Exception as e:
|
|
72
|
+
self.add_error(f"Error reading {file_path.name}: {e}")
|
|
73
|
+
return False, None
|
|
74
|
+
|
|
75
|
+
def validate_media_types(self) -> bool:
|
|
76
|
+
"""Validate media-types.json"""
|
|
77
|
+
print(f"\n{Colors.HEADER}π Validating Media Types...{Colors.ENDC}")
|
|
78
|
+
|
|
79
|
+
file_path = self.config_dir / 'media-types.json'
|
|
80
|
+
valid, data = self.validate_json_file(file_path)
|
|
81
|
+
|
|
82
|
+
if not valid:
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
if 'mediaTypes' not in data:
|
|
86
|
+
self.add_error("media-types.json missing 'mediaTypes' array")
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
media_types = data['mediaTypes']
|
|
90
|
+
required_fields = ['id', 'name', 'icon', 'viewer', 'acceptedFormats']
|
|
91
|
+
|
|
92
|
+
for i, mt in enumerate(media_types):
|
|
93
|
+
for field in required_fields:
|
|
94
|
+
if field not in mt:
|
|
95
|
+
self.add_error(f"Media type #{i} missing required field: {field}")
|
|
96
|
+
|
|
97
|
+
# Validate acceptedFormats is an array
|
|
98
|
+
if 'acceptedFormats' in mt and not isinstance(mt['acceptedFormats'], list):
|
|
99
|
+
self.add_error(f"Media type '{mt.get('id', i)}': acceptedFormats must be an array")
|
|
100
|
+
|
|
101
|
+
print(f"{Colors.OKGREEN}β Found {len(media_types)} media types{Colors.ENDC}")
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
def validate_categories(self) -> bool:
|
|
105
|
+
"""Validate categories.json (content types)"""
|
|
106
|
+
print(f"\n{Colors.HEADER}π Validating Content Types...{Colors.ENDC}")
|
|
107
|
+
|
|
108
|
+
file_path = self.config_dir / 'categories.json'
|
|
109
|
+
valid, data = self.validate_json_file(file_path)
|
|
110
|
+
|
|
111
|
+
if not valid:
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
# Support both 'contentTypes' and 'categories' keys
|
|
115
|
+
content_types = data.get('contentTypes', data.get('categories', []))
|
|
116
|
+
|
|
117
|
+
if not content_types:
|
|
118
|
+
self.add_error("categories.json missing content types array")
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
required_fields = ['id', 'name', 'icon', 'mediaType', 'dataFile']
|
|
122
|
+
|
|
123
|
+
for i, ct in enumerate(content_types):
|
|
124
|
+
ct_id = ct.get('id', f'#{i}')
|
|
125
|
+
|
|
126
|
+
for field in required_fields:
|
|
127
|
+
if field not in ct:
|
|
128
|
+
self.add_error(f"Content type '{ct_id}' missing required field: {field}")
|
|
129
|
+
|
|
130
|
+
# Validate fields structure
|
|
131
|
+
if 'fields' in ct:
|
|
132
|
+
if 'required' not in ct['fields']:
|
|
133
|
+
self.add_warning(f"Content type '{ct_id}': missing 'fields.required' array")
|
|
134
|
+
if 'optional' not in ct['fields']:
|
|
135
|
+
self.add_warning(f"Content type '{ct_id}': missing 'fields.optional' array")
|
|
136
|
+
|
|
137
|
+
print(f"{Colors.OKGREEN}β Found {len(content_types)} content types{Colors.ENDC}")
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
def validate_languages(self) -> bool:
|
|
141
|
+
"""Validate languages.json"""
|
|
142
|
+
print(f"\n{Colors.HEADER}π Validating Languages...{Colors.ENDC}")
|
|
143
|
+
|
|
144
|
+
file_path = self.config_dir / 'languages.json'
|
|
145
|
+
valid, data = self.validate_json_file(file_path)
|
|
146
|
+
|
|
147
|
+
if not valid:
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
if 'supportedLanguages' not in data:
|
|
151
|
+
self.add_error("languages.json missing 'supportedLanguages' array")
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
if 'defaultLanguage' not in data:
|
|
155
|
+
self.add_error("languages.json missing 'defaultLanguage' field")
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
languages = data['supportedLanguages']
|
|
159
|
+
required_fields = ['code', 'name']
|
|
160
|
+
|
|
161
|
+
for lang in languages:
|
|
162
|
+
for field in required_fields:
|
|
163
|
+
if field not in lang:
|
|
164
|
+
self.add_error(f"Language entry missing required field: {field}")
|
|
165
|
+
|
|
166
|
+
print(f"{Colors.OKGREEN}β Found {len(languages)} supported languages{Colors.ENDC}")
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
def validate_app_config(self) -> bool:
|
|
170
|
+
"""Validate app.json"""
|
|
171
|
+
print(f"\n{Colors.HEADER}βοΈ Validating App Configuration...{Colors.ENDC}")
|
|
172
|
+
|
|
173
|
+
file_path = self.config_dir / 'app.json'
|
|
174
|
+
valid, data = self.validate_json_file(file_path)
|
|
175
|
+
|
|
176
|
+
if not valid:
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
# Check recommended fields
|
|
180
|
+
recommended_fields = ['name', 'author', 'api', 'github']
|
|
181
|
+
|
|
182
|
+
for field in recommended_fields:
|
|
183
|
+
if field not in data:
|
|
184
|
+
self.add_warning(f"app.json missing recommended field: {field}")
|
|
185
|
+
|
|
186
|
+
print(f"{Colors.OKGREEN}β App configuration valid{Colors.ENDC}")
|
|
187
|
+
return True
|
|
188
|
+
|
|
189
|
+
def validate_data_files(self) -> bool:
|
|
190
|
+
"""Validate all data files match category definitions"""
|
|
191
|
+
print(f"\n{Colors.HEADER}π Validating Data Files...{Colors.ENDC}")
|
|
192
|
+
|
|
193
|
+
# Load config first
|
|
194
|
+
if not self.config_loader.load_all():
|
|
195
|
+
self.add_error("Failed to load configuration")
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
content_types = self.config_loader.get_content_types()
|
|
199
|
+
|
|
200
|
+
for ct in content_types:
|
|
201
|
+
ct_id = ct['id']
|
|
202
|
+
data_file = self.config_loader.get_category_data_file(ct_id)
|
|
203
|
+
|
|
204
|
+
# Check if data file exists
|
|
205
|
+
if not os.path.exists(data_file):
|
|
206
|
+
self.add_warning(f"Data file not found for '{ct_id}': {data_file}")
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
# Validate JSON
|
|
210
|
+
valid, data = self.validate_json_file(Path(data_file))
|
|
211
|
+
if not valid:
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
# Data can be array or object with 'items' key
|
|
215
|
+
if isinstance(data, dict) and 'items' in data:
|
|
216
|
+
items = data['items']
|
|
217
|
+
elif isinstance(data, list):
|
|
218
|
+
items = data
|
|
219
|
+
else:
|
|
220
|
+
self.add_error(f"Data file '{ct_id}' must be an array or object with 'items' key")
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
# Validate required fields in each item
|
|
224
|
+
required_fields = ct.get('fields', {}).get('required', ['title', 'url'])
|
|
225
|
+
|
|
226
|
+
for i, item in enumerate(items):
|
|
227
|
+
for field in required_fields:
|
|
228
|
+
if field not in item:
|
|
229
|
+
self.add_warning(f"{ct_id}.json item #{i}: missing required field '{field}'")
|
|
230
|
+
|
|
231
|
+
# Check for ID field (recommended)
|
|
232
|
+
if 'id' not in item:
|
|
233
|
+
self.add_info(f"{ct_id}.json item #{i}: missing 'id' field (recommended)")
|
|
234
|
+
|
|
235
|
+
print(f"{Colors.OKGREEN}β {ct['icon']} {ct['name']}: {len(items)} items{Colors.ENDC}")
|
|
236
|
+
|
|
237
|
+
return True
|
|
238
|
+
|
|
239
|
+
def validate_translations(self) -> bool:
|
|
240
|
+
"""Validate translation files"""
|
|
241
|
+
print(f"\n{Colors.HEADER}π€ Validating Translations...{Colors.ENDC}")
|
|
242
|
+
|
|
243
|
+
# Get language codes
|
|
244
|
+
lang_codes = self.config_loader.get_language_codes()
|
|
245
|
+
|
|
246
|
+
if not lang_codes:
|
|
247
|
+
self.add_error("No language codes found")
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
# Load all translation files
|
|
251
|
+
translations = {}
|
|
252
|
+
for code in lang_codes:
|
|
253
|
+
lang_file = self.lang_dir / f'{code}.json'
|
|
254
|
+
valid, data = self.validate_json_file(lang_file)
|
|
255
|
+
|
|
256
|
+
if valid:
|
|
257
|
+
translations[code] = data
|
|
258
|
+
print(f"{Colors.OKGREEN}β {code}.json: {len(data)} keys{Colors.ENDC}")
|
|
259
|
+
else:
|
|
260
|
+
self.add_error(f"Failed to load translation file: {code}.json")
|
|
261
|
+
|
|
262
|
+
# Check for missing keys across languages
|
|
263
|
+
if len(translations) > 1:
|
|
264
|
+
all_keys = set()
|
|
265
|
+
for keys in translations.values():
|
|
266
|
+
all_keys.update(keys.keys())
|
|
267
|
+
|
|
268
|
+
for code, keys in translations.items():
|
|
269
|
+
missing = all_keys - set(keys.keys())
|
|
270
|
+
if missing:
|
|
271
|
+
self.add_warning(f"Language '{code}' missing {len(missing)} translation keys")
|
|
272
|
+
if len(missing) <= 5:
|
|
273
|
+
for key in missing:
|
|
274
|
+
self.add_info(f" Missing in '{code}': {key}")
|
|
275
|
+
|
|
276
|
+
return True
|
|
277
|
+
|
|
278
|
+
def validate_cross_references(self) -> bool:
|
|
279
|
+
"""Validate cross-references between configs"""
|
|
280
|
+
print(f"\n{Colors.HEADER}π Validating Cross-References...{Colors.ENDC}")
|
|
281
|
+
|
|
282
|
+
content_types = self.config_loader.get_content_types()
|
|
283
|
+
media_types = self.config_loader.get_media_types()
|
|
284
|
+
|
|
285
|
+
media_type_ids = {mt['id'] for mt in media_types}
|
|
286
|
+
|
|
287
|
+
for ct in content_types:
|
|
288
|
+
# Check if mediaType exists
|
|
289
|
+
if ct.get('mediaType') not in media_type_ids:
|
|
290
|
+
self.add_error(f"Content type '{ct['id']}' references unknown media type: {ct.get('mediaType')}")
|
|
291
|
+
|
|
292
|
+
print(f"{Colors.OKGREEN}β Cross-references valid{Colors.ENDC}")
|
|
293
|
+
return True
|
|
294
|
+
|
|
295
|
+
def run_validation(self) -> bool:
|
|
296
|
+
"""Run all validations"""
|
|
297
|
+
print(f"\n{Colors.BOLD}{Colors.HEADER}{'='*60}{Colors.ENDC}")
|
|
298
|
+
print(f"{Colors.BOLD}{Colors.HEADER}π Portfolio Configuration Validator{Colors.ENDC}")
|
|
299
|
+
print(f"{Colors.BOLD}{Colors.HEADER}{'='*60}{Colors.ENDC}")
|
|
300
|
+
print(f"Content root: {self.content_root.absolute()}")
|
|
301
|
+
|
|
302
|
+
# Run all validations
|
|
303
|
+
validations = [
|
|
304
|
+
self.validate_media_types,
|
|
305
|
+
self.validate_categories,
|
|
306
|
+
self.validate_languages,
|
|
307
|
+
self.validate_app_config,
|
|
308
|
+
self.validate_data_files,
|
|
309
|
+
self.validate_translations,
|
|
310
|
+
self.validate_cross_references
|
|
311
|
+
]
|
|
312
|
+
|
|
313
|
+
for validation in validations:
|
|
314
|
+
try:
|
|
315
|
+
validation()
|
|
316
|
+
except Exception as e:
|
|
317
|
+
self.add_error(f"Validation failed: {e}")
|
|
318
|
+
|
|
319
|
+
# Print summary
|
|
320
|
+
print(f"\n{Colors.BOLD}{Colors.HEADER}{'='*60}{Colors.ENDC}")
|
|
321
|
+
print(f"{Colors.BOLD}{Colors.HEADER}π Validation Summary{Colors.ENDC}")
|
|
322
|
+
print(f"{Colors.BOLD}{Colors.HEADER}{'='*60}{Colors.ENDC}\n")
|
|
323
|
+
|
|
324
|
+
# Print all messages
|
|
325
|
+
for msg in self.errors:
|
|
326
|
+
print(msg)
|
|
327
|
+
for msg in self.warnings:
|
|
328
|
+
print(msg)
|
|
329
|
+
for msg in self.info:
|
|
330
|
+
print(msg)
|
|
331
|
+
|
|
332
|
+
# Print totals
|
|
333
|
+
print(f"\n{Colors.BOLD}Results:{Colors.ENDC}")
|
|
334
|
+
print(f"{Colors.FAIL} Errors: {len(self.errors)}{Colors.ENDC}")
|
|
335
|
+
print(f"{Colors.WARNING} Warnings: {len(self.warnings)}{Colors.ENDC}")
|
|
336
|
+
print(f"{Colors.OKBLUE} Info: {len(self.info)}{Colors.ENDC}")
|
|
337
|
+
|
|
338
|
+
if len(self.errors) == 0 and len(self.warnings) == 0:
|
|
339
|
+
print(f"\n{Colors.BOLD}{Colors.OKGREEN}β All validations passed! Your configuration is perfect! π{Colors.ENDC}\n")
|
|
340
|
+
return True
|
|
341
|
+
elif len(self.errors) == 0:
|
|
342
|
+
print(f"\n{Colors.BOLD}{Colors.OKGREEN}β No critical errors found (warnings can be addressed later){Colors.ENDC}\n")
|
|
343
|
+
return True
|
|
344
|
+
else:
|
|
345
|
+
print(f"\n{Colors.BOLD}{Colors.FAIL}β Validation failed with {len(self.errors)} error(s){Colors.ENDC}\n")
|
|
346
|
+
return False
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def main():
|
|
350
|
+
import argparse
|
|
351
|
+
|
|
352
|
+
parser = argparse.ArgumentParser(description='Validate portfolio configuration files')
|
|
353
|
+
parser.add_argument('--path', help='Path to portfolio content directory', default='.')
|
|
354
|
+
|
|
355
|
+
args = parser.parse_args()
|
|
356
|
+
|
|
357
|
+
validator = ConfigValidator(args.path)
|
|
358
|
+
success = validator.run_validation()
|
|
359
|
+
|
|
360
|
+
sys.exit(0 if success else 1)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
if __name__ == '__main__':
|
|
364
|
+
main()
|
package/engine/admin.html
CHANGED
|
@@ -128,6 +128,17 @@
|
|
|
128
128
|
|
|
129
129
|
<hr style="margin: 20px 0;">
|
|
130
130
|
|
|
131
|
+
<div class="field-row" style="margin-top: 20px;">
|
|
132
|
+
<button onclick="window.location.href='validator.html?auto=true';" style="width: 100%; height: 50px; font-weight: bold; cursor: pointer; background: linear-gradient(to bottom, #00aa00, #008800); color: white; border: 2px outset #fff;">
|
|
133
|
+
π VALIDATE CONFIGURATION
|
|
134
|
+
</button>
|
|
135
|
+
<p class="admin-muted" style="margin-top: 5px;">
|
|
136
|
+
* Check all config files, data files, and translations for errors
|
|
137
|
+
</p>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<hr style="margin: 20px 0;">
|
|
141
|
+
|
|
131
142
|
<div class="field-row" style="margin-top: 20px;">
|
|
132
143
|
<button onclick="syncGitHub()" style="width: 100%; height: 40px; font-weight: bold; cursor: pointer;">
|
|
133
144
|
<img src="https://github.githubassets.com/favicons/favicon.svg" width="16"
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<title>CONFIG VALIDATOR - PORTFOLIO</title>
|
|
6
|
+
<link rel="stylesheet" href="style.css">
|
|
7
|
+
<link rel="stylesheet" href="admin.css">
|
|
8
|
+
<link rel="stylesheet" href="fonts.css">
|
|
9
|
+
<script src="js/config-loader.js"></script>
|
|
10
|
+
<style>
|
|
11
|
+
.validator-container {
|
|
12
|
+
padding: 20px;
|
|
13
|
+
max-height: calc(100vh - 200px);
|
|
14
|
+
overflow-y: auto;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.validation-section {
|
|
18
|
+
background: white;
|
|
19
|
+
border: 2px inset var(--admin-win-border-dark);
|
|
20
|
+
padding: 15px;
|
|
21
|
+
margin-bottom: 15px;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.validation-section h3 {
|
|
25
|
+
margin: 0 0 10px 0;
|
|
26
|
+
padding-bottom: 8px;
|
|
27
|
+
border-bottom: 2px solid var(--admin-win-border-dark);
|
|
28
|
+
font-size: 13px;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.validation-result {
|
|
32
|
+
margin: 8px 0;
|
|
33
|
+
padding: 8px;
|
|
34
|
+
font-size: 11px;
|
|
35
|
+
border-left: 4px solid #ccc;
|
|
36
|
+
background: #f9f9f9;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.validation-result.success {
|
|
40
|
+
border-left-color: #00aa00;
|
|
41
|
+
background: #f0fff0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.validation-result.error {
|
|
45
|
+
border-left-color: #cc0000;
|
|
46
|
+
background: #fff0f0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.validation-result.warning {
|
|
50
|
+
border-left-color: #ff8800;
|
|
51
|
+
background: #fffaf0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.validation-result.info {
|
|
55
|
+
border-left-color: #0080ff;
|
|
56
|
+
background: #f0f8ff;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.icon {
|
|
60
|
+
display: inline-block;
|
|
61
|
+
width: 16px;
|
|
62
|
+
font-weight: bold;
|
|
63
|
+
margin-right: 5px;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.summary {
|
|
67
|
+
background: var(--admin-win-bg);
|
|
68
|
+
border: 2px outset var(--admin-win-border-light);
|
|
69
|
+
padding: 15px;
|
|
70
|
+
margin-bottom: 20px;
|
|
71
|
+
font-weight: bold;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.summary.all-good {
|
|
75
|
+
background: linear-gradient(to bottom, #90ff90, #60ff60);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.summary.has-errors {
|
|
79
|
+
background: linear-gradient(to bottom, #ff9090, #ff6060);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.summary.has-warnings {
|
|
83
|
+
background: linear-gradient(to bottom, #ffdd90, #ffcc60);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.progress-bar {
|
|
87
|
+
width: 100%;
|
|
88
|
+
height: 20px;
|
|
89
|
+
background: white;
|
|
90
|
+
border: 2px inset var(--admin-win-border-dark);
|
|
91
|
+
margin: 10px 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.progress-bar-fill {
|
|
95
|
+
height: 100%;
|
|
96
|
+
background: linear-gradient(to right, #0080ff, #00a0ff);
|
|
97
|
+
transition: width 0.3s;
|
|
98
|
+
}
|
|
99
|
+
</style>
|
|
100
|
+
</head>
|
|
101
|
+
|
|
102
|
+
<body class="admin-page">
|
|
103
|
+
<div class="window">
|
|
104
|
+
<div class="title-bar">
|
|
105
|
+
<span>CONFIGURATION VALIDATOR</span>
|
|
106
|
+
<div class="title-bar-controls">
|
|
107
|
+
<button>_</button>
|
|
108
|
+
<button>[]</button>
|
|
109
|
+
<button onclick="window.location.href='admin.html#config'">X</button>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<div class="menu-bar">
|
|
114
|
+
<button onclick="location.href='admin.html#config'">Β« Back to Admin</button>
|
|
115
|
+
<button onclick="runValidation()" id="validateBtn">π Run Validation</button>
|
|
116
|
+
<button onclick="exportReport()">π Export Report</button>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<div id="summary" class="summary" style="display: none;"></div>
|
|
120
|
+
|
|
121
|
+
<div id="progress" style="display: none; padding: 0 20px;">
|
|
122
|
+
<div class="progress-bar">
|
|
123
|
+
<div class="progress-bar-fill" id="progressBar" style="width: 0%"></div>
|
|
124
|
+
</div>
|
|
125
|
+
<p id="progressText" style="text-align: center; font-size: 11px;"></p>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<div class="validator-container" id="results">
|
|
129
|
+
<p align="center" style="color: #666; font-size: 12px;">
|
|
130
|
+
Click "Run Validation" to check your configuration files
|
|
131
|
+
</p>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<div class="status-bar">
|
|
135
|
+
<span id="statusText">Ready</span>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<script>
|
|
140
|
+
const API_URL = 'http://127.0.0.1:5001';
|
|
141
|
+
|
|
142
|
+
let validationResults = {
|
|
143
|
+
errors: [],
|
|
144
|
+
warnings: [],
|
|
145
|
+
info: [],
|
|
146
|
+
success: []
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
async function runValidation() {
|
|
150
|
+
const btn = document.getElementById('validateBtn');
|
|
151
|
+
const results = document.getElementById('results');
|
|
152
|
+
const progress = document.getElementById('progress');
|
|
153
|
+
const progressBar = document.getElementById('progressBar');
|
|
154
|
+
const progressText = document.getElementById('progressText');
|
|
155
|
+
const summary = document.getElementById('summary');
|
|
156
|
+
|
|
157
|
+
// Reset
|
|
158
|
+
validationResults = { errors: [], warnings: [], info: [], success: [] };
|
|
159
|
+
results.innerHTML = '';
|
|
160
|
+
summary.style.display = 'none';
|
|
161
|
+
progress.style.display = 'block';
|
|
162
|
+
btn.disabled = true;
|
|
163
|
+
|
|
164
|
+
const validations = [
|
|
165
|
+
{ name: 'Media Types', fn: validateMediaTypes },
|
|
166
|
+
{ name: 'Content Types', fn: validateContentTypes },
|
|
167
|
+
{ name: 'Languages', fn: validateLanguages },
|
|
168
|
+
{ name: 'App Config', fn: validateAppConfig },
|
|
169
|
+
{ name: 'Data Files', fn: validateDataFiles },
|
|
170
|
+
{ name: 'Cross-References', fn: validateCrossReferences }
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
for (let i = 0; i < validations.length; i++) {
|
|
174
|
+
const validation = validations[i];
|
|
175
|
+
progressText.textContent = `Validating ${validation.name}...`;
|
|
176
|
+
progressBar.style.width = `${((i + 1) / validations.length) * 100}%`;
|
|
177
|
+
|
|
178
|
+
await validation.fn();
|
|
179
|
+
await new Promise(resolve => setTimeout(resolve, 200)); // Brief pause for UX
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
progress.style.display = 'none';
|
|
183
|
+
btn.disabled = false;
|
|
184
|
+
|
|
185
|
+
displayResults();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function validateMediaTypes() {
|
|
189
|
+
const section = createSection('π¬ Media Types');
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
await AppConfig.load();
|
|
193
|
+
const mediaTypes = AppConfig.getAllMediaTypes();
|
|
194
|
+
|
|
195
|
+
if (!mediaTypes || mediaTypes.length === 0) {
|
|
196
|
+
addError(section, 'No media types found');
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const requiredFields = ['id', 'name', 'icon', 'viewer', 'acceptedFormats'];
|
|
201
|
+
|
|
202
|
+
mediaTypes.forEach((mt, i) => {
|
|
203
|
+
requiredFields.forEach(field => {
|
|
204
|
+
if (!mt[field]) {
|
|
205
|
+
addError(section, `Media type #${i} missing required field: ${field}`);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (mt.acceptedFormats && !Array.isArray(mt.acceptedFormats)) {
|
|
210
|
+
addError(section, `Media type '${mt.id}': acceptedFormats must be an array`);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
addSuccess(section, `Found ${mediaTypes.length} media types`);
|
|
215
|
+
} catch (err) {
|
|
216
|
+
addError(section, `Failed to validate media types: ${err.message}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function validateContentTypes() {
|
|
221
|
+
const section = createSection('π Content Types');
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const contentTypes = AppConfig.getAllContentTypes();
|
|
225
|
+
|
|
226
|
+
if (!contentTypes || contentTypes.length === 0) {
|
|
227
|
+
addError(section, 'No content types found');
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const requiredFields = ['id', 'name', 'icon', 'mediaType', 'dataFile'];
|
|
232
|
+
|
|
233
|
+
contentTypes.forEach((ct, i) => {
|
|
234
|
+
requiredFields.forEach(field => {
|
|
235
|
+
if (!ct[field]) {
|
|
236
|
+
addError(section, `Content type '${ct.id || `#${i}`}' missing required field: ${field}`);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
if (ct.fields) {
|
|
241
|
+
if (!ct.fields.required) {
|
|
242
|
+
addWarning(section, `Content type '${ct.id}': missing 'fields.required' array`);
|
|
243
|
+
}
|
|
244
|
+
if (!ct.fields.optional) {
|
|
245
|
+
addWarning(section, `Content type '${ct.id}': missing 'fields.optional' array`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
addSuccess(section, `Found ${contentTypes.length} content types`);
|
|
251
|
+
} catch (err) {
|
|
252
|
+
addError(section, `Failed to validate content types: ${err.message}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function validateLanguages() {
|
|
257
|
+
const section = createSection('π Languages');
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
const langCodes = AppConfig.getLanguageCodes();
|
|
261
|
+
|
|
262
|
+
if (!langCodes || langCodes.length === 0) {
|
|
263
|
+
addError(section, 'No language codes found');
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
addSuccess(section, `Found ${langCodes.length} supported languages: ${langCodes.join(', ')}`);
|
|
268
|
+
|
|
269
|
+
const defaultLang = AppConfig.getDefaultLanguage();
|
|
270
|
+
if (!defaultLang) {
|
|
271
|
+
addError(section, 'No default language specified');
|
|
272
|
+
} else {
|
|
273
|
+
addInfo(section, `Default language: ${defaultLang}`);
|
|
274
|
+
}
|
|
275
|
+
} catch (err) {
|
|
276
|
+
addError(section, `Failed to validate languages: ${err.message}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function validateAppConfig() {
|
|
281
|
+
const section = createSection('βοΈ App Configuration');
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
const appName = AppConfig.getSetting('name');
|
|
285
|
+
const author = AppConfig.getSetting('author');
|
|
286
|
+
|
|
287
|
+
if (!appName) addWarning(section, 'App name not set in app.json');
|
|
288
|
+
if (!author) addWarning(section, 'Author not set in app.json');
|
|
289
|
+
|
|
290
|
+
addSuccess(section, 'App configuration loaded');
|
|
291
|
+
} catch (err) {
|
|
292
|
+
addError(section, `Failed to validate app config: ${err.message}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function validateDataFiles() {
|
|
297
|
+
const section = createSection('π Data Files');
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
const res = await fetch(`${API_URL}/api/content`);
|
|
301
|
+
const allContent = await res.json();
|
|
302
|
+
|
|
303
|
+
const contentTypes = AppConfig.getAllContentTypes();
|
|
304
|
+
|
|
305
|
+
for (const ct of contentTypes) {
|
|
306
|
+
const items = allContent[ct.id] || [];
|
|
307
|
+
const requiredFields = ct.fields?.required || ['title', 'url'];
|
|
308
|
+
|
|
309
|
+
if (items.length === 0) {
|
|
310
|
+
addInfo(section, `${ct.icon} ${ct.name}: No items yet`);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Validate items
|
|
315
|
+
items.forEach((item, i) => {
|
|
316
|
+
requiredFields.forEach(field => {
|
|
317
|
+
if (!item[field]) {
|
|
318
|
+
addWarning(section, `${ct.id}.json item #${i}: missing required field '${field}'`);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
if (!item.id) {
|
|
323
|
+
addInfo(section, `${ct.id}.json item #${i}: missing 'id' field (recommended)`);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
addSuccess(section, `${ct.icon} ${ct.name}: ${items.length} items`);
|
|
328
|
+
}
|
|
329
|
+
} catch (err) {
|
|
330
|
+
addError(section, `Failed to validate data files: ${err.message}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function validateCrossReferences() {
|
|
335
|
+
const section = createSection('π Cross-References');
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
const contentTypes = AppConfig.getAllContentTypes();
|
|
339
|
+
const mediaTypes = AppConfig.getAllMediaTypes();
|
|
340
|
+
|
|
341
|
+
const mediaTypeIds = new Set(mediaTypes.map(mt => mt.id));
|
|
342
|
+
|
|
343
|
+
contentTypes.forEach(ct => {
|
|
344
|
+
if (!mediaTypeIds.has(ct.mediaType)) {
|
|
345
|
+
addError(section, `Content type '${ct.id}' references unknown media type: ${ct.mediaType}`);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
addSuccess(section, 'All cross-references valid');
|
|
350
|
+
} catch (err) {
|
|
351
|
+
addError(section, `Failed to validate cross-references: ${err.message}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function createSection(title) {
|
|
356
|
+
const section = document.createElement('div');
|
|
357
|
+
section.className = 'validation-section';
|
|
358
|
+
section.innerHTML = `<h3>${title}</h3>`;
|
|
359
|
+
document.getElementById('results').appendChild(section);
|
|
360
|
+
return section;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function addResult(section, type, message) {
|
|
364
|
+
const result = document.createElement('div');
|
|
365
|
+
result.className = `validation-result ${type}`;
|
|
366
|
+
|
|
367
|
+
const icons = {
|
|
368
|
+
success: 'β',
|
|
369
|
+
error: 'β',
|
|
370
|
+
warning: 'β ',
|
|
371
|
+
info: 'βΉ'
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
result.innerHTML = `<span class="icon">${icons[type]}</span>${message}`;
|
|
375
|
+
section.appendChild(result);
|
|
376
|
+
|
|
377
|
+
validationResults[type === 'success' ? 'success' : type + 's'].push(message);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function addSuccess(section, message) { addResult(section, 'success', message); }
|
|
381
|
+
function addError(section, message) { addResult(section, 'error', message); }
|
|
382
|
+
function addWarning(section, message) { addResult(section, 'warning', message); }
|
|
383
|
+
function addInfo(section, message) { addResult(section, 'info', message); }
|
|
384
|
+
|
|
385
|
+
function displayResults() {
|
|
386
|
+
const summary = document.getElementById('summary');
|
|
387
|
+
const errorCount = validationResults.errors.length;
|
|
388
|
+
const warningCount = validationResults.warnings.length;
|
|
389
|
+
|
|
390
|
+
summary.style.display = 'block';
|
|
391
|
+
|
|
392
|
+
if (errorCount === 0 && warningCount === 0) {
|
|
393
|
+
summary.className = 'summary all-good';
|
|
394
|
+
summary.innerHTML = 'β ALL VALIDATIONS PASSED! Your configuration is perfect! π';
|
|
395
|
+
} else if (errorCount > 0) {
|
|
396
|
+
summary.className = 'summary has-errors';
|
|
397
|
+
summary.innerHTML = `β ${errorCount} ERROR${errorCount > 1 ? 'S' : ''} FOUND${warningCount > 0 ? ` (${warningCount} warning${warningCount > 1 ? 's' : ''})` : ''}`;
|
|
398
|
+
} else {
|
|
399
|
+
summary.className = 'summary has-warnings';
|
|
400
|
+
summary.innerHTML = `β ${warningCount} WARNING${warningCount > 1 ? 'S' : ''} (no critical errors)`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
document.getElementById('statusText').textContent = `Validation complete: ${errorCount} errors, ${warningCount} warnings`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function exportReport() {
|
|
407
|
+
const report = {
|
|
408
|
+
timestamp: new Date().toISOString(),
|
|
409
|
+
summary: {
|
|
410
|
+
errors: validationResults.errors.length,
|
|
411
|
+
warnings: validationResults.warnings.length,
|
|
412
|
+
info: validationResults.info.length,
|
|
413
|
+
success: validationResults.success.length
|
|
414
|
+
},
|
|
415
|
+
results: validationResults
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
|
|
419
|
+
const url = URL.createObjectURL(blob);
|
|
420
|
+
const a = document.createElement('a');
|
|
421
|
+
a.href = url;
|
|
422
|
+
a.download = `validation-report-${new Date().toISOString().split('T')[0]}.json`;
|
|
423
|
+
a.click();
|
|
424
|
+
URL.revokeObjectURL(url);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Auto-run on load if ?auto=true
|
|
428
|
+
window.addEventListener('DOMContentLoaded', async () => {
|
|
429
|
+
await AppConfig.load();
|
|
430
|
+
|
|
431
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
432
|
+
if (urlParams.get('auto') === 'true') {
|
|
433
|
+
runValidation();
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
</script>
|
|
437
|
+
</body>
|
|
438
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mtldev514/retro-portfolio-maker",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.15",
|
|
4
|
+
"description": "A portfolio manager for multi-passionate creators with a soft spot for early 2000s aesthetics. Display your many projects with soul.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"retro-portfolio": "./bin/cli.js"
|