whereto-bike 0.0.6 → 0.0.8

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "whereto-bike",
3
3
  "type": "module",
4
- "version": "0.0.6",
4
+ "version": "0.0.8",
5
5
  "description": "Open-source cycling platform — the CMS for cycling",
6
6
  "license": "AGPL-3.0",
7
7
  "repository": {
@@ -44,7 +44,7 @@ jobs:
44
44
  uses: actions/cache@v5
45
45
  with:
46
46
  path: .astro
47
- key: astro-${{ hashFiles('blog/**') }}
47
+ key: astro-${{ github.run_id }}
48
48
  restore-keys: astro-
49
49
 
50
50
  - name: Resolve VIDEO_PREFIX from wrangler config
@@ -56,7 +56,7 @@ jobs:
56
56
  uses: actions/cache@v5
57
57
  with:
58
58
  path: .astro
59
- key: astro-${{ hashFiles('blog/**') }}
59
+ key: astro-${{ github.run_id }}
60
60
  restore-keys: astro-
61
61
 
62
62
  - name: Restore dist cache
@@ -65,7 +65,7 @@ function run(cmd, opts = {}) {
65
65
 
66
66
  function commandExists(cmd) {
67
67
  try {
68
- safeExec(`which ${cmd}`, { stdio: 'pipe' });
68
+ safeExec(`command -v ${cmd}`, { stdio: 'pipe', shell: true });
69
69
  return true;
70
70
  } catch {
71
71
  return false;
@@ -11,7 +11,7 @@ const config = getCityConfig();
11
11
  const { published, filterTags, routeTagsMap } = await loadHomepageData();
12
12
  ---
13
13
 
14
- <p class="site-identity-line">{t('home.identity', locale, { display_name: config.display_name })}</p>
14
+ <p class="site-identity-line">{t('home.identity', locale, { name: config.name })}</p>
15
15
  <TagFilter tags={filterTags} />
16
16
  <ul class="routes-grid">
17
17
  {published.map((route, i) => {
@@ -13,6 +13,7 @@ import type { StravaImportResult } from './StravaActivityBrowser';
13
13
  import { useEditorState } from './useEditorState';
14
14
  import { useFormValidation } from './useFormValidation';
15
15
  import { useDragDrop } from '../../lib/hooks';
16
+ import { paths } from '../../lib/paths';
16
17
  import { useUnsavedGuard } from '../../lib/hooks/use-unsaved-guard';
17
18
  import { slugify } from '../../lib/slug';
18
19
  import SlugEditor from './SlugEditor';
@@ -118,8 +119,8 @@ export default function RideEditor({ initialData, cdnUrl, videosCdnUrl, videoPre
118
119
  );
119
120
  if (!res.ok || cancelled) return;
120
121
  const data = await res.json();
121
- const code = data?.address?.country_code;
122
- if (code && !cancelled) setCountry(code.toUpperCase());
122
+ const countryName = data?.address?.country;
123
+ if (countryName && !cancelled) setCountry(countryName);
123
124
  } catch { /* Nominatim failures are non-critical */ }
124
125
  })();
125
126
 
@@ -424,7 +425,7 @@ export default function RideEditor({ initialData, cdnUrl, videosCdnUrl, videoPre
424
425
  <EditorActions
425
426
  error={error} githubUrl={githubUrl} saved={saved} saving={saving}
426
427
  onSave={handleSave} contentType="ride"
427
- viewLink={initialData.tour_slug ? `/tours/${initialData.tour_slug}/${initialData.slug}` : `/rides/${initialData.slug}`}
428
+ viewLink={paths.ride(initialData.slug, initialData.tour_slug)}
428
429
  showLicenseNotice={false}
429
430
  />
430
431
  </div>
package/src/i18n/en.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "footer.community": "Built by people who ride here",
8
8
  "footer.powered_by": "made with whereto.bike",
9
9
  "home.welcome": "New to biking? Experienced rider? Find your new favourite ride and explore nature!",
10
- "home.identity": "Community-maintained cycling routes for {display_name}. Built by people who ride here.",
10
+ "home.identity": "Community-maintained cycling routes for {name}. Built by people who ride here.",
11
11
  "home.suggest_route": "Know a route that's missing? You can add it — share a ride you love with other cyclists.",
12
12
  "filter.choose_tag": "choose a tag to filter:",
13
13
  "route.elevation_gain": "Elevation Gain",
package/src/i18n/es.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "footer.community": "Creado por personas que pedalean aquí",
8
8
  "footer.powered_by": "hecho con whereto.bike",
9
9
  "home.welcome": "¿Nuevo en el ciclismo? ¿Ciclista experimentado? ¡Encuentra tu nuevo recorrido favorito y explora la naturaleza!",
10
- "home.identity": "Rutas ciclistas mantenidas por la comunidad para {display_name}. Creado por personas que pedalean aquí.",
10
+ "home.identity": "Rutas ciclistas mantenidas por la comunidad para {name}. Creado por personas que pedalean aquí.",
11
11
  "home.suggest_route": "¿Conoces una ruta que falta? Agrégala — comparte un recorrido que te guste con otros ciclistas.",
12
12
  "filter.choose_tag": "elige una etiqueta para filtrar:",
13
13
  "route.elevation_gain": "Desnivel positivo",
@@ -49,8 +49,11 @@ const routes = [
49
49
  { pattern: '/api/auth/strava/callback', entrypoint: view('api/auth/strava-callback.ts') },
50
50
  // Content-type admin pages and API endpoints (from registry)
51
51
  ...contentTypeRoutes,
52
- // Admin index — serves the primary content type's list page per instance type
53
- { pattern: '/admin', entrypoint: view(adminIndexView()) },
52
+ // Admin index — serves the primary content type's list page per instance type.
53
+ // Skipped when a content type already claims /admin via adminListRoute (e.g. routes on wiki).
54
+ ...(!contentTypeRoutes.some(r => r.pattern === '/admin')
55
+ ? [{ pattern: '/admin', entrypoint: view(adminIndexView()) }]
56
+ : []),
54
57
  // Non-content-type admin pages
55
58
  { pattern: '/admin/history', entrypoint: view('admin/history.astro') },
56
59
  { pattern: '/admin/users', entrypoint: view('admin/users.astro') },
@@ -48,6 +48,7 @@ export const contentTypes: ContentTypeConfig[] = [
48
48
  label: 'Routes',
49
49
  featureGate: 'hasRoutes',
50
50
  ops: routeOps,
51
+ adminListRoute: { pattern: '/admin', entrypoint: 'admin/index.astro' },
51
52
  adminDetailRoutes: [
52
53
  { pattern: '/admin/routes/new', entrypoint: 'admin/route-new.astro' },
53
54
  { pattern: '/admin/routes/[slug]', entrypoint: 'admin/route-detail.astro' },
package/src/lib/csp.ts CHANGED
@@ -10,7 +10,7 @@ function originFrom(raw: string | undefined, fallback: string): string {
10
10
 
11
11
  export function cspOrigins() {
12
12
  const config = getCityConfig();
13
- const cdn = originFrom(process.env.R2_PUBLIC_URL || config.cdn_url, config.cdn_url);
13
+ const cdn = originFrom(config.cdn_url, config.cdn_url);
14
14
  const videos = originFrom(config.videos_cdn_url, config.videos_cdn_url);
15
15
 
16
16
  return { cdn, videos };
@@ -23,6 +23,7 @@ import {
23
23
  rideDateToIso,
24
24
  nameFromFilename,
25
25
  extractDateFromPath,
26
+ adjustTourYears,
26
27
  buildSlug,
27
28
  } from './rides';
28
29
  import { readRideFile } from './ride-file-reader';
@@ -73,8 +74,8 @@ export interface RideStats {
73
74
  by_year: Record<string, { rides: number; distance_km: number; elevation_m: number }>;
74
75
  by_country: Record<string, { rides: number; distance_km: number }>;
75
76
  records: {
76
- longest_ride?: { slug: string; name: string; distance_km: number };
77
- most_elevation?: { slug: string; name: string; elevation_m: number };
77
+ longest_ride?: { slug: string; name: string; distance_km: number; tour_slug?: string };
78
+ most_elevation?: { slug: string; name: string; elevation_m: number; tour_slug?: string };
78
79
  longest_tour?: { slug: string; name: string; distance_km: number; days: number };
79
80
  };
80
81
  }
@@ -122,6 +123,20 @@ export async function loadAdminRideData(): Promise<AdminRideData> {
122
123
  }
123
124
  }
124
125
 
126
+ // Adjust years for multi-year tours (e.g. Dec 2022 → Jan 2023)
127
+ const adjustedDates = new Map<string, { year: number; month: number; day: number }>();
128
+ for (const tour of tours) {
129
+ const tourDates: { gpxPath: string; date: { year: number; month: number; day: number } }[] = [];
130
+ for (const ridePath of tour.ridePaths) {
131
+ const date = extractDateFromPath(ridePath);
132
+ if (date) tourDates.push({ gpxPath: ridePath, date });
133
+ }
134
+ adjustTourYears(tourDates.map(td => td.date));
135
+ for (const td of tourDates) {
136
+ adjustedDates.set(td.gpxPath, td.date);
137
+ }
138
+ }
139
+
125
140
  // Load tour-level metadata
126
141
  const tourMeta = new Map<string, { name: string; description?: string; renderedDescription?: string; country?: string }>();
127
142
  for (const tour of tours) {
@@ -165,7 +180,7 @@ export async function loadAdminRideData(): Promise<AdminRideData> {
165
180
  const digest = computeFileDigest([gpxAbsPath, sidecarPath, mediaYmlPath]);
166
181
 
167
182
  // Compute slug early for cache key
168
- const date = extractDateFromPath(gpxRelPath);
183
+ const date = adjustedDates.get(gpxRelPath) || extractDateFromPath(gpxRelPath);
169
184
  if (!date) continue;
170
185
  const gpxFilename = path.basename(gpxRelPath);
171
186
  const slug = buildSlug(date, gpxFilename, !!tourInfo);
@@ -188,7 +203,7 @@ export async function loadAdminRideData(): Promise<AdminRideData> {
188
203
  const parsed = readRideFile(ridesDir, gpxRelPath, tourInfo?.slug);
189
204
  if (!parsed) continue;
190
205
 
191
- const isoDate = rideDateToIso(parsed.date);
206
+ const isoDate = rideDateToIso(date);
192
207
 
193
208
  // Compute content hash from raw file contents
194
209
  const contentHash = computeRideContentHash(
@@ -401,8 +416,8 @@ function buildStats(
401
416
  by_year: byYear,
402
417
  by_country: byCountry,
403
418
  records: {
404
- longest_ride: longestRide ? { slug: longestRide.slug, name: longestRide.name, distance_km: longestRide.distance_km } : undefined,
405
- most_elevation: mostElevation ? { slug: mostElevation.slug, name: mostElevation.name, elevation_m: mostElevation.elevation_m } : undefined,
419
+ longest_ride: longestRide ? { slug: longestRide.slug, name: longestRide.name, distance_km: longestRide.distance_km, tour_slug: longestRide.tour_slug } : undefined,
420
+ most_elevation: mostElevation ? { slug: mostElevation.slug, name: mostElevation.name, elevation_m: mostElevation.elevation_m, tour_slug: mostElevation.tour_slug } : undefined,
406
421
  longest_tour: longestTour ? { slug: longestTour.slug, name: longestTour.name, distance_km: longestTour.total_distance_km, days: longestTour.days } : undefined,
407
422
  },
408
423
  };
@@ -187,6 +187,49 @@ export function buildSlug(date: RideDate, gpxFilename: string, isTour?: boolean)
187
187
  return `${yyyy}-${mm}-${dd}-${slug}`;
188
188
  }
189
189
 
190
+ /**
191
+ * Adjust years for rides in a multi-year tour.
192
+ *
193
+ * When a tour directory lives under a single YYYY/ folder but spans a year
194
+ * boundary (e.g. December 2022 → January 2023), all rides initially get the
195
+ * directory year. This function detects the year rollover by finding the
196
+ * largest internal gap between sorted months. If months [1,2,3,10,11,12] are
197
+ * present, the gap between 3 and 10 (7 months) indicates the tour runs
198
+ * Oct→Mar across a year boundary — months 1–3 become year + 1.
199
+ *
200
+ * Only considers non-circular gaps (between consecutive sorted months).
201
+ * The wrap-around gap from the highest to lowest month is ignored — it's
202
+ * always large for any clustered set of months and would cause false positives.
203
+ */
204
+ export function adjustTourYears(dates: RideDate[]): void {
205
+ if (dates.length < 2) return;
206
+
207
+ const months = [...new Set(dates.map(d => d.month))].sort((a, b) => a - b);
208
+ if (months.length < 2) return;
209
+
210
+ // Find the largest gap between consecutive sorted months (non-circular).
211
+ // A gap > 6 means the months split into two clusters across a year boundary.
212
+ let maxGap = 0;
213
+ let gapAfterMonth = 0;
214
+ for (let i = 1; i < months.length; i++) {
215
+ const gap = months[i] - months[i - 1];
216
+ if (gap > maxGap) {
217
+ maxGap = gap;
218
+ gapAfterMonth = months[i - 1];
219
+ }
220
+ }
221
+
222
+ if (maxGap <= 6) return;
223
+
224
+ // Months at or before gapAfterMonth are the "early" cluster (next year).
225
+ // Months after gapAfterMonth are the "late" cluster (directory year).
226
+ for (const date of dates) {
227
+ if (date.month <= gapAfterMonth) {
228
+ date.year += 1;
229
+ }
230
+ }
231
+ }
232
+
190
233
  /** Build an ISO date string from a RideDate. */
191
234
  export function rideDateToIso(date: RideDate): string {
192
235
  const mm = String(date.month).padStart(2, '0');
@@ -253,6 +296,21 @@ export function rideLoader(): Loader {
253
296
  }
254
297
  }
255
298
 
299
+ // Adjust years for multi-year tours (e.g. Dec 2022 → Jan 2023)
300
+ const adjustedDates = new Map<string, RideDate>();
301
+ for (const tour of tours) {
302
+ const tourDates: { gpxPath: string; date: RideDate }[] = [];
303
+ for (const ridePath of tour.ridePaths) {
304
+ const date = extractDateFromPath(ridePath);
305
+ if (date) tourDates.push({ gpxPath: ridePath, date });
306
+ }
307
+ const dates = tourDates.map(td => td.date);
308
+ adjustTourYears(dates);
309
+ for (const td of tourDates) {
310
+ adjustedDates.set(td.gpxPath, td.date);
311
+ }
312
+ }
313
+
256
314
  // Load privacy zone config once (if configured for this city)
257
315
  const privacyZone = getCityConfig().privacy_zone;
258
316
 
@@ -265,7 +323,7 @@ export function rideLoader(): Loader {
265
323
  const mediaYmlDigestPath = gpxAbsPath.replace(/\.gpx$/i, '-media.yml');
266
324
 
267
325
  // Incremental caching — need date+slug before checking digest
268
- const date = extractDateFromPath(gpxRelPath);
326
+ const date = adjustedDates.get(gpxRelPath) || extractDateFromPath(gpxRelPath);
269
327
  if (!date) {
270
328
  logger.warn(`Could not extract date from path: ${gpxRelPath}`);
271
329
  continue;
@@ -10,6 +10,7 @@ import { CITY, VIDEO_PREFIX } from '../../lib/config/config';
10
10
  import { getCityConfig, isBlogInstance } from '../../lib/config/city-config';
11
11
  import { loadDetailPageData } from '../../lib/content/load-admin-content.server';
12
12
  import { rideDetailFromCache } from '../../lib/models/ride-model';
13
+ import { paths } from '@/lib/paths';
13
14
  import { t } from '@/i18n';
14
15
  import tours from 'virtual:bike-app/tours';
15
16
  import { db } from '@/lib/get-db';
@@ -91,7 +92,7 @@ try {
91
92
  title={title}
92
93
  backLink="/admin/rides"
93
94
  backLabel="Back to rides"
94
- viewLink={slug ? (rideData.tour_slug ? `/tours/${rideData.tour_slug}/${slug}` : `/rides/${slug}`) : undefined}
95
+ viewLink={slug ? paths.ride(slug, rideData.tour_slug) : undefined}
95
96
  hideTitle
96
97
  user={Astro.locals.user}
97
98
  currentTab="routes"
@@ -7,7 +7,9 @@ import { routeShape } from '../../lib/route-insights';
7
7
  import { difficultyLabel, scoreRoute } from '../../lib/difficulty';
8
8
  import { paths, routeSlug } from '../../lib/paths';
9
9
  import { defaultLocale } from '../../lib/i18n/locale-utils';
10
- import { loadBuildPlan, filterByBuildPlan } from '../../lib/content/build-plan.server';
10
+ // NOTE: This is a single-output endpoint (not parameterized), so it must NOT
11
+ // use filterByBuildPlan — doing so would produce a partial index in incremental
12
+ // mode, overwriting the full one from the previous build.
11
13
 
12
14
  export const prerender = true;
13
15
 
@@ -21,7 +23,7 @@ export const GET: APIRoute = async ({ currentLocale }) => {
21
23
  const config = getCityConfig();
22
24
  const locale = currentLocale || defaultLocale();
23
25
  const allRoutes = await getCollection('routes');
24
- const published = filterByBuildPlan(allRoutes.filter(isPublished), loadBuildPlan(), 'route');
26
+ const published = allRoutes.filter(isPublished);
25
27
 
26
28
  const allScores = published
27
29
  .map(r => scoreRoute(r))
@@ -3,6 +3,7 @@ export const prerender = true;
3
3
  import Base from '@/layouts/Base.astro';
4
4
  import stats from 'virtual:bike-app/ride-stats';
5
5
  import { getCityConfig } from '@/lib/config/city-config';
6
+ import { paths } from '@/lib/paths';
6
7
  import { t } from '@/i18n';
7
8
 
8
9
  const config = getCityConfig();
@@ -55,7 +56,7 @@ const countryKeys = Object.keys(stats.by_country).sort((a, b) =>
55
56
  {stats.records.longest_ride && (
56
57
  <div class="stats-record-card">
57
58
  <span class="stats-record-card--label">{t('stats.longest_ride', locale)}</span>
58
- <a href={`/rides/${stats.records.longest_ride.slug}`} class="stats-record-card--value">
59
+ <a href={paths.ride(stats.records.longest_ride.slug, stats.records.longest_ride.tour_slug)} class="stats-record-card--value">
59
60
  {stats.records.longest_ride.name}
60
61
  </a>
61
62
  <span class="stats-record-card--detail">{stats.records.longest_ride.distance_km} km</span>
@@ -64,7 +65,7 @@ const countryKeys = Object.keys(stats.by_country).sort((a, b) =>
64
65
  {stats.records.most_elevation && (
65
66
  <div class="stats-record-card">
66
67
  <span class="stats-record-card--label">{t('stats.most_elevation', locale)}</span>
67
- <a href={`/rides/${stats.records.most_elevation.slug}`} class="stats-record-card--value">
68
+ <a href={paths.ride(stats.records.most_elevation.slug, stats.records.most_elevation.tour_slug)} class="stats-record-card--value">
68
69
  {stats.records.most_elevation.name}
69
70
  </a>
70
71
  <span class="stats-record-card--detail">{stats.records.most_elevation.elevation_m.toLocaleString()} m</span>
@@ -294,8 +294,8 @@ interface _RideStats {
294
294
  by_year: Record<string, { rides: number; distance_km: number; elevation_m: number }>;
295
295
  by_country: Record<string, { rides: number; distance_km: number }>;
296
296
  records: {
297
- longest_ride?: { slug: string; name: string; distance_km: number };
298
- most_elevation?: { slug: string; name: string; elevation_m: number };
297
+ longest_ride?: { slug: string; name: string; distance_km: number; tour_slug?: string };
298
+ most_elevation?: { slug: string; name: string; elevation_m: number; tour_slug?: string };
299
299
  longest_tour?: { slug: string; name: string; distance_km: number; days: number };
300
300
  };
301
301
  }
package/sync.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * - scripts/setup.js — interactive setup helper
12
12
  * - astro.config.mjs — Astro config
13
13
  * - tsconfig.json — TypeScript config
14
+ * - package.json — scripts only (merged, preserving name/deps)
14
15
  */
15
16
 
16
17
  import fs from 'node:fs';
@@ -101,6 +102,22 @@ for (const rel of sourceFiles) {
101
102
  }
102
103
  }
103
104
 
105
+ // --- Sync package.json scripts from template ---
106
+ const templatePkgPath = path.join(templateRoot, 'package.json.tpl');
107
+ if (fs.existsSync(templatePkgPath)) {
108
+ const templatePkg = JSON.parse(renderTemplate(fs.readFileSync(templatePkgPath, 'utf-8'), vars));
109
+ const blogPkgPath = path.join(cwd, 'package.json');
110
+ const blogPkg = JSON.parse(fs.readFileSync(blogPkgPath, 'utf-8'));
111
+
112
+ const before = JSON.stringify(blogPkg.scripts);
113
+ blogPkg.scripts = { ...blogPkg.scripts, ...templatePkg.scripts };
114
+ if (JSON.stringify(blogPkg.scripts) !== before) {
115
+ fs.writeFileSync(blogPkgPath, JSON.stringify(blogPkg, null, 2) + '\n');
116
+ console.log(' updated package.json (scripts)');
117
+ updated++;
118
+ }
119
+ }
120
+
104
121
  // --- Summary ---
105
122
  if (updated === 0) {
106
123
  console.log(' Everything is up to date.');