tripkit 1.0.1 → 1.1.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/CHANGELOG.md +10 -0
- package/README.md +10 -0
- package/convert.js +84 -13
- package/package.json +2 -1
- package/renderers/html/tripkit-renderer.html +16 -1
- package/validate.js +159 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to TripKit are documented here. Versioning follows [SemVer](https://semver.org/).
|
|
4
4
|
|
|
5
|
+
## [1.1.0] — 2026-04-30
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **`tripkit validate <trip.yaml>`** — schema validator that checks required fields, lat/lng ranges, valid stop types, hex colors, day numbering (sequential, status enum), `trip.total_days` / `trip.total_stops` consistency, optional routes structure, and theme fields. Warnings are advisory; errors block render.
|
|
9
|
+
- Pre-render validation is now automatic. Pass `--no-validate` to bypass.
|
|
10
|
+
- Mobile polish at iPhone widths (≤480px): collapsible map legend (tap to expand), tighter day-nav, smaller hero, repositioned map controls. No regression at desktop or tablet sizes.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- CSS cascade bug: previous mobile media query was placed before base `.legend` rules, so overrides silently lost the cascade. Moved to end of stylesheet where it belongs.
|
|
14
|
+
|
|
5
15
|
## [1.0.1] — 2026-04-30
|
|
6
16
|
|
|
7
17
|
### Fixed
|
package/README.md
CHANGED
|
@@ -65,6 +65,16 @@ node convert.js examples/oregon-spring-2026.yaml my-trip.html
|
|
|
65
65
|
open my-trip.html
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
+
### Validate before you render
|
|
69
|
+
|
|
70
|
+
TripKit ships with a schema validator that catches data bugs before they reach the renderer:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
npx tripkit validate my-trip.yaml
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
It checks required fields, lat/lng ranges, valid stop types, hex colors, day numbering, and warns when `trip.total_stops` doesn't match the actual count. Validation also runs automatically before each render — pass `--no-validate` to skip it.
|
|
77
|
+
|
|
68
78
|
### Option 3: With an AI Agent (everyone)
|
|
69
79
|
|
|
70
80
|
1. Start a conversation with your preferred AI agent (Claude recommended)
|
package/convert.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* TripKit CLI — Convert YAML trip data to interactive HTML
|
|
3
|
+
* TripKit CLI — Convert YAML trip data to interactive HTML, or validate a YAML file.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const fs = require('fs');
|
|
@@ -14,25 +14,31 @@ try {
|
|
|
14
14
|
process.exit(1);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
const { validate } = require('./validate');
|
|
18
|
+
|
|
17
19
|
const invokedAs = path.basename(process.argv[1] || 'tripkit') === 'convert.js'
|
|
18
20
|
? 'node convert.js'
|
|
19
21
|
: 'tripkit';
|
|
20
22
|
|
|
21
23
|
const args = process.argv.slice(2);
|
|
22
|
-
|
|
24
|
+
|
|
25
|
+
function printHelp() {
|
|
23
26
|
console.log(`
|
|
24
|
-
TripKit —
|
|
27
|
+
TripKit — AI-friendly trip planning toolkit
|
|
25
28
|
|
|
26
29
|
Usage:
|
|
27
|
-
${invokedAs} <trip.yaml> [output.html]
|
|
28
|
-
|
|
29
|
-
Example:
|
|
30
|
-
${invokedAs} my-trip.yaml my-trip.html
|
|
30
|
+
${invokedAs} <trip.yaml> [output.html] Render YAML to interactive HTML
|
|
31
|
+
${invokedAs} validate <trip.yaml> Check a trip YAML for schema errors
|
|
31
32
|
|
|
32
33
|
Flags:
|
|
33
34
|
-h, --help Show this help
|
|
34
35
|
-v, --version Show version
|
|
36
|
+
--no-validate Skip the pre-render validation pass (renders even with warnings)
|
|
35
37
|
`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (args.length === 0 || args[0] === '-h' || args[0] === '--help') {
|
|
41
|
+
printHelp();
|
|
36
42
|
process.exit(0);
|
|
37
43
|
}
|
|
38
44
|
|
|
@@ -41,12 +47,78 @@ if (args[0] === '-v' || args[0] === '--version') {
|
|
|
41
47
|
process.exit(0);
|
|
42
48
|
}
|
|
43
49
|
|
|
44
|
-
|
|
45
|
-
const
|
|
50
|
+
// --- ANSI color helpers (auto-disabled when not a TTY or NO_COLOR is set) ---
|
|
51
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
52
|
+
const c = useColor
|
|
53
|
+
? { red: s => `\x1b[31m${s}\x1b[0m`, yellow: s => `\x1b[33m${s}\x1b[0m`, green: s => `\x1b[32m${s}\x1b[0m`, dim: s => `\x1b[2m${s}\x1b[0m`, bold: s => `\x1b[1m${s}\x1b[0m` }
|
|
54
|
+
: { red: s => s, yellow: s => s, green: s => s, dim: s => s, bold: s => s };
|
|
55
|
+
|
|
56
|
+
function loadYaml(inputFile) {
|
|
57
|
+
if (!fs.existsSync(inputFile)) {
|
|
58
|
+
console.error(c.red(`Error: file not found: ${inputFile}`));
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
return yaml.load(fs.readFileSync(inputFile, 'utf8'));
|
|
63
|
+
} catch (e) {
|
|
64
|
+
console.error(c.red(`YAML parse error in ${inputFile}:`));
|
|
65
|
+
console.error(` ${e.message}`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function reportFindings(errors, warnings) {
|
|
71
|
+
errors.forEach(({ path: p, message }) => {
|
|
72
|
+
console.error(` ${c.red('✖')} ${c.bold(p)} — ${message}`);
|
|
73
|
+
});
|
|
74
|
+
warnings.forEach(({ path: p, message }) => {
|
|
75
|
+
console.error(` ${c.yellow('⚠')} ${c.bold(p)} — ${message}`);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// === SUBCOMMAND: validate ===
|
|
80
|
+
if (args[0] === 'validate') {
|
|
81
|
+
const inputFile = args[1];
|
|
82
|
+
if (!inputFile) {
|
|
83
|
+
console.error(c.red('Usage: ') + `${invokedAs} validate <trip.yaml>`);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
const tripData = loadYaml(inputFile);
|
|
87
|
+
const { errors, warnings } = validate(tripData);
|
|
88
|
+
if (errors.length === 0 && warnings.length === 0) {
|
|
89
|
+
console.log(c.green('✓') + ` ${inputFile} is valid`);
|
|
90
|
+
process.exit(0);
|
|
91
|
+
}
|
|
92
|
+
console.log(c.bold(`${inputFile}`));
|
|
93
|
+
reportFindings(errors, warnings);
|
|
94
|
+
console.log('');
|
|
95
|
+
console.log(` ${errors.length} error${errors.length === 1 ? '' : 's'}, ${warnings.length} warning${warnings.length === 1 ? '' : 's'}`);
|
|
96
|
+
process.exit(errors.length > 0 ? 1 : 0);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// === DEFAULT: render ===
|
|
100
|
+
const skipValidate = args.includes('--no-validate');
|
|
101
|
+
const positional = args.filter(a => !a.startsWith('-'));
|
|
102
|
+
const inputFile = positional[0];
|
|
103
|
+
const outputFile = positional[1] || inputFile.replace(/\.ya?ml$/, '.html');
|
|
46
104
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
105
|
+
const tripData = loadYaml(inputFile);
|
|
106
|
+
|
|
107
|
+
if (!skipValidate) {
|
|
108
|
+
const { errors, warnings } = validate(tripData);
|
|
109
|
+
if (errors.length > 0) {
|
|
110
|
+
console.error(c.red(`✖ Validation failed for ${inputFile}:`));
|
|
111
|
+
reportFindings(errors, warnings);
|
|
112
|
+
console.error('');
|
|
113
|
+
console.error(c.dim(`Fix the errors above, or pass --no-validate to render anyway.`));
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
if (warnings.length > 0) {
|
|
117
|
+
console.error(c.yellow(`⚠ Validation warnings for ${inputFile}:`));
|
|
118
|
+
reportFindings([], warnings);
|
|
119
|
+
console.error('');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
50
122
|
|
|
51
123
|
// Read HTML template
|
|
52
124
|
const templatePath = path.join(__dirname, 'renderers', 'html', 'tripkit-renderer.html');
|
|
@@ -59,7 +131,6 @@ template = template.replace(
|
|
|
59
131
|
`const TRIP_DATA = ${jsonData};`
|
|
60
132
|
);
|
|
61
133
|
|
|
62
|
-
// Write output
|
|
63
134
|
fs.writeFileSync(outputFile, template, 'utf8');
|
|
64
135
|
console.log(`✅ Generated: ${outputFile}`);
|
|
65
136
|
console.log(` ${tripData.trip.title} — ${tripData.trip.total_days} days, ${tripData.trip.total_stops} stops`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tripkit",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Open-source framework for AI-assisted trip planning with beautiful interactive visualizers",
|
|
5
5
|
"main": "convert.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"convert.js",
|
|
11
|
+
"validate.js",
|
|
11
12
|
"renderers/html/tripkit-renderer.html",
|
|
12
13
|
"schema/tripkit.schema.yaml",
|
|
13
14
|
"examples/oregon-spring-2026.yaml",
|
|
@@ -18,6 +18,7 @@ html,body{height:100%;overflow:hidden}
|
|
|
18
18
|
body{font-family:var(--font);background:var(--bg);color:var(--text)}
|
|
19
19
|
.app{display:grid;grid-template-columns:460px 1fr;height:100vh}
|
|
20
20
|
@media(max-width:960px){.app{grid-template-columns:1fr;grid-template-rows:55vh 45vh}}
|
|
21
|
+
.legend-toggle{display:none}
|
|
21
22
|
.sidebar{display:flex;flex-direction:column;background:var(--card);border-right:1px solid var(--border);overflow:hidden}
|
|
22
23
|
.sidebar-scroll{overflow-y:auto;flex:1;scroll-behavior:smooth}
|
|
23
24
|
.sidebar-scroll::-webkit-scrollbar{width:5px}
|
|
@@ -93,6 +94,18 @@ body{font-family:var(--font);background:var(--bg);color:var(--text)}
|
|
|
93
94
|
border:2px solid #1b5e3b;font-family:var(--font);cursor:pointer;transition:transform .12s;letter-spacing:-.02em}
|
|
94
95
|
.hm:hover{transform:scale(1.15)}
|
|
95
96
|
.pp-conf{font-size:11px;font-weight:600;color:var(--accent);background:var(--accent-soft);padding:4px 8px;border-radius:4px;display:inline-block;margin-top:6px;letter-spacing:.02em}
|
|
97
|
+
@media(max-width:480px){
|
|
98
|
+
.hero{height:140px}.hero-text{padding:12px 14px}.hero-text h1{font-size:20px}
|
|
99
|
+
.day-nav{padding:8px 10px;gap:1px}.day-btn{padding:5px 8px;font-size:10px}
|
|
100
|
+
.day-panel{padding:12px 14px 32px}
|
|
101
|
+
.legend{bottom:auto;top:52px;left:8px;right:auto;padding:6px 10px;font-size:9px;line-height:1.6;max-width:60%}
|
|
102
|
+
.legend.collapsed .lr,.legend.collapsed > div:not(.legend-toggle){display:none}
|
|
103
|
+
.legend-toggle{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:10px;font-weight:600;user-select:none;color:var(--text)}
|
|
104
|
+
.legend-toggle::after{content:'▾';font-size:9px;transition:transform .15s;color:var(--muted)}
|
|
105
|
+
.legend.collapsed .legend-toggle::after{transform:rotate(-90deg)}
|
|
106
|
+
.map-ctrl{top:8px;right:8px}.mbtn{padding:5px 8px;font-size:9px}
|
|
107
|
+
.fullbtn{bottom:10px;right:8px;padding:5px 10px;font-size:10px}
|
|
108
|
+
}
|
|
96
109
|
</style>
|
|
97
110
|
</head>
|
|
98
111
|
<body>
|
|
@@ -394,7 +407,7 @@ const TRIP_DATA = {};
|
|
|
394
407
|
|
|
395
408
|
// --- Legend ---
|
|
396
409
|
const legendEl = document.getElementById('legend');
|
|
397
|
-
let legendHTML = '<
|
|
410
|
+
let legendHTML = '<div class="legend-toggle"><span>Route segments</span></div>';
|
|
398
411
|
days.forEach(d => {
|
|
399
412
|
legendHTML += `<div class="lr"><i style="background:${d.color || '#666'}"></i>Day ${d.number} · ${d.title.split('→')[0].replace('✅ ','').trim()}</div>`;
|
|
400
413
|
});
|
|
@@ -404,6 +417,8 @@ const TRIP_DATA = {};
|
|
|
404
417
|
legendHTML += `Booked hotels (${hotels.length})</div></div>`;
|
|
405
418
|
}
|
|
406
419
|
legendEl.innerHTML = legendHTML;
|
|
420
|
+
if (window.matchMedia('(max-width: 480px)').matches) legendEl.classList.add('collapsed');
|
|
421
|
+
legendEl.querySelector('.legend-toggle')?.addEventListener('click', () => legendEl.classList.toggle('collapsed'));
|
|
407
422
|
|
|
408
423
|
// --- Fit bounds ---
|
|
409
424
|
const allPts = days.flatMap(d => (d.stops || []).filter(s => s.lat && s.lng).map(s => [s.lat, s.lng]));
|
package/validate.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TripKit YAML validator.
|
|
3
|
+
*
|
|
4
|
+
* Exports `validate(data)` returning { errors, warnings } — each is a list of
|
|
5
|
+
* { path, message } objects. Errors block render; warnings are advisory.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const STOP_TYPES = new Set(['hike', 'scenic', 'food', 'city', 'activity', 'beach', 'museum', 'shopping']);
|
|
9
|
+
const DAY_STATUS = new Set(['completed', 'active', 'upcoming']);
|
|
10
|
+
const MAP_STYLES = new Set(['terrain', 'satellite', 'topo', 'street']);
|
|
11
|
+
const HEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
12
|
+
|
|
13
|
+
function isFiniteNumber(n) { return typeof n === 'number' && Number.isFinite(n); }
|
|
14
|
+
function isNonEmptyString(s) { return typeof s === 'string' && s.trim().length > 0; }
|
|
15
|
+
|
|
16
|
+
function validate(data) {
|
|
17
|
+
const errors = [];
|
|
18
|
+
const warnings = [];
|
|
19
|
+
const err = (path, message) => errors.push({ path, message });
|
|
20
|
+
const warn = (path, message) => warnings.push({ path, message });
|
|
21
|
+
|
|
22
|
+
if (!data || typeof data !== 'object') {
|
|
23
|
+
err('(root)', 'YAML did not parse to an object');
|
|
24
|
+
return { errors, warnings };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// --- trip ---
|
|
28
|
+
const trip = data.trip;
|
|
29
|
+
if (!trip || typeof trip !== 'object') {
|
|
30
|
+
err('trip', 'missing trip metadata block');
|
|
31
|
+
} else {
|
|
32
|
+
if (!isNonEmptyString(trip.title)) err('trip.title', 'required, non-empty string');
|
|
33
|
+
if (trip.total_days != null && !Number.isInteger(trip.total_days)) {
|
|
34
|
+
err('trip.total_days', `must be an integer, got ${typeof trip.total_days}`);
|
|
35
|
+
}
|
|
36
|
+
if (trip.total_stops != null && !Number.isInteger(trip.total_stops)) {
|
|
37
|
+
err('trip.total_stops', `must be an integer, got ${typeof trip.total_stops}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// --- days ---
|
|
42
|
+
const days = data.days;
|
|
43
|
+
if (!Array.isArray(days) || days.length === 0) {
|
|
44
|
+
err('days', 'must be a non-empty array');
|
|
45
|
+
return { errors, warnings };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let actualStops = 0;
|
|
49
|
+
days.forEach((d, di) => {
|
|
50
|
+
const dp = `days[${di}]`;
|
|
51
|
+
if (!d || typeof d !== 'object') {
|
|
52
|
+
err(dp, 'must be an object');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (!Number.isInteger(d.number)) err(`${dp}.number`, 'required integer');
|
|
56
|
+
else if (d.number !== di + 1) warn(`${dp}.number`, `expected ${di + 1} (sequential), got ${d.number}`);
|
|
57
|
+
if (!isNonEmptyString(d.title)) err(`${dp}.title`, 'required, non-empty string');
|
|
58
|
+
if (!isNonEmptyString(d.date)) err(`${dp}.date`, 'required, non-empty string');
|
|
59
|
+
if (d.status != null && !DAY_STATUS.has(d.status)) {
|
|
60
|
+
err(`${dp}.status`, `must be one of ${[...DAY_STATUS].join(', ')}; got "${d.status}"`);
|
|
61
|
+
}
|
|
62
|
+
if (d.color != null && !HEX.test(d.color)) {
|
|
63
|
+
err(`${dp}.color`, `must be a hex color (#abc or #aabbcc); got "${d.color}"`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// stops
|
|
67
|
+
if (d.stops != null) {
|
|
68
|
+
if (!Array.isArray(d.stops)) {
|
|
69
|
+
err(`${dp}.stops`, 'must be an array');
|
|
70
|
+
} else {
|
|
71
|
+
d.stops.forEach((s, si) => {
|
|
72
|
+
const sp = `${dp}.stops[${si}]`;
|
|
73
|
+
actualStops++;
|
|
74
|
+
if (!s || typeof s !== 'object') { err(sp, 'must be an object'); return; }
|
|
75
|
+
if (!isNonEmptyString(s.name)) err(`${sp}.name`, 'required, non-empty string');
|
|
76
|
+
if (!isFiniteNumber(s.lat)) err(`${sp}.lat`, 'required finite number');
|
|
77
|
+
else if (s.lat < -90 || s.lat > 90) err(`${sp}.lat`, `out of range [-90, 90]: ${s.lat}`);
|
|
78
|
+
if (!isFiniteNumber(s.lng)) err(`${sp}.lng`, 'required finite number');
|
|
79
|
+
else if (s.lng < -180 || s.lng > 180) err(`${sp}.lng`, `out of range [-180, 180]: ${s.lng}`);
|
|
80
|
+
if (s.type != null && !STOP_TYPES.has(s.type)) {
|
|
81
|
+
err(`${sp}.type`, `must be one of ${[...STOP_TYPES].join(', ')}; got "${s.type}"`);
|
|
82
|
+
}
|
|
83
|
+
if (s.kid_friendly != null && typeof s.kid_friendly !== 'boolean') {
|
|
84
|
+
err(`${sp}.kid_friendly`, 'must be boolean');
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// lodging
|
|
91
|
+
if (d.lodging && typeof d.lodging === 'object') {
|
|
92
|
+
const lp = `${dp}.lodging`;
|
|
93
|
+
const { lat, lng } = d.lodging;
|
|
94
|
+
if (lat != null && (!isFiniteNumber(lat) || lat < -90 || lat > 90)) {
|
|
95
|
+
err(`${lp}.lat`, `must be a number in [-90, 90]; got ${lat}`);
|
|
96
|
+
}
|
|
97
|
+
if (lng != null && (!isFiniteNumber(lng) || lng < -180 || lng > 180)) {
|
|
98
|
+
err(`${lp}.lng`, `must be a number in [-180, 180]; got ${lng}`);
|
|
99
|
+
}
|
|
100
|
+
if (d.lodging.booked != null && typeof d.lodging.booked !== 'boolean') {
|
|
101
|
+
err(`${lp}.booked`, 'must be boolean');
|
|
102
|
+
}
|
|
103
|
+
const isHome = d.lodging.name === 'Home';
|
|
104
|
+
if (d.lodging.booked && !isHome && !isNonEmptyString(d.lodging.confirmation)) {
|
|
105
|
+
warn(`${lp}.confirmation`, 'lodging is marked booked but confirmation is empty');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// --- cross-check totals ---
|
|
111
|
+
if (trip && Number.isInteger(trip.total_days) && trip.total_days !== days.length) {
|
|
112
|
+
warn('trip.total_days', `declared ${trip.total_days}, actual days = ${days.length}`);
|
|
113
|
+
}
|
|
114
|
+
if (trip && Number.isInteger(trip.total_stops) && trip.total_stops !== actualStops) {
|
|
115
|
+
warn('trip.total_stops', `declared ${trip.total_stops}, actual stops = ${actualStops}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// --- routes (optional) ---
|
|
119
|
+
if (data.routes != null) {
|
|
120
|
+
if (!Array.isArray(data.routes)) {
|
|
121
|
+
err('routes', 'must be an array when present');
|
|
122
|
+
} else {
|
|
123
|
+
data.routes.forEach((r, ri) => {
|
|
124
|
+
const rp = `routes[${ri}]`;
|
|
125
|
+
if (!Number.isInteger(r.day)) err(`${rp}.day`, 'required integer');
|
|
126
|
+
else if (r.day < 1 || r.day > days.length) {
|
|
127
|
+
err(`${rp}.day`, `references day ${r.day}, but only ${days.length} days exist`);
|
|
128
|
+
}
|
|
129
|
+
if (r.color != null && !HEX.test(r.color)) err(`${rp}.color`, `must be hex; got "${r.color}"`);
|
|
130
|
+
if (!Array.isArray(r.points) || r.points.length < 2) {
|
|
131
|
+
err(`${rp}.points`, 'must be an array of at least 2 [lat, lng] pairs');
|
|
132
|
+
} else {
|
|
133
|
+
r.points.forEach((pt, pi) => {
|
|
134
|
+
if (!Array.isArray(pt) || pt.length !== 2 || !isFiniteNumber(pt[0]) || !isFiniteNumber(pt[1])) {
|
|
135
|
+
err(`${rp}.points[${pi}]`, 'must be [lat, lng] number pair');
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// --- theme (optional) ---
|
|
144
|
+
if (data.theme && typeof data.theme === 'object') {
|
|
145
|
+
if (data.theme.accent_color != null && !HEX.test(data.theme.accent_color)) {
|
|
146
|
+
err('theme.accent_color', `must be hex; got "${data.theme.accent_color}"`);
|
|
147
|
+
}
|
|
148
|
+
if (data.theme.dark_mode != null && typeof data.theme.dark_mode !== 'boolean') {
|
|
149
|
+
err('theme.dark_mode', 'must be boolean');
|
|
150
|
+
}
|
|
151
|
+
if (data.theme.map_style != null && !MAP_STYLES.has(data.theme.map_style)) {
|
|
152
|
+
err('theme.map_style', `must be one of ${[...MAP_STYLES].join(', ')}; got "${data.theme.map_style}"`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { errors, warnings };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = { validate };
|