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 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
- if (args.length === 0 || args[0] === '-h' || args[0] === '--help') {
24
+
25
+ function printHelp() {
23
26
  console.log(`
24
- TripKit — Convert YAML trip data to interactive HTML
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
- const inputFile = args[0];
45
- const outputFile = args[1] || inputFile.replace(/\.ya?ml$/, '.html');
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
- // Read and parse YAML
48
- const yamlContent = fs.readFileSync(inputFile, 'utf8');
49
- const tripData = yaml.load(yamlContent);
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.1",
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 = '<b>Route segments</b>';
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 };