whereto-bike 0.0.1 → 0.0.2

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.
@@ -6,6 +6,7 @@ export default {
6
6
  messages: {
7
7
  hardcodedCity: "Don't hardcode city name '{{value}}'. Import CITY from src/lib/config.ts.",
8
8
  hardcodedLocale: "Don't hardcode locale '{{value}}'. Derive from city config locales.",
9
+ cityFallbackDefault: "Don't use '{{value}}' as a fallback default for CITY. A silent default can cause content to be committed to the wrong directory. Make the value required instead.",
9
10
  },
10
11
  },
11
12
  create(context) {
@@ -28,7 +29,16 @@ export default {
28
29
  // Skip type annotations and enums
29
30
  if (node.parent.type === 'TSLiteralType') return;
30
31
 
32
+ // Detect `X || 'ottawa'` or `X ?? 'ottawa'` — a city name used as fallback.
33
+ // This is dangerous because the fallback silently applies when the env var
34
+ // is unset (e.g. in Cloudflare Workers at runtime), causing content to be
35
+ // committed to the wrong directory.
31
36
  if (CITY_NAMES.includes(val)) {
37
+ const p = node.parent;
38
+ if (p.type === 'LogicalExpression' && (p.operator === '||' || p.operator === '??') && p.right === node) {
39
+ context.report({ node, messageId: 'cityFallbackDefault', data: { value: node.value } });
40
+ return;
41
+ }
32
42
  context.report({ node, messageId: 'hardcodedCity', data: { value: node.value } });
33
43
  }
34
44
  // Only flag exact matches for locale codes (not substrings)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "whereto-bike",
3
3
  "type": "module",
4
- "version": "0.0.1",
4
+ "version": "0.0.2",
5
5
  "description": "Open-source cycling platform — the CMS for cycling",
6
6
  "license": "AGPL-3.0",
7
7
  "repository": {
@@ -11,7 +11,10 @@ import {
11
11
  eventSchema, organizerSchema, pageSchema,
12
12
  } from './schemas/index';
13
13
 
14
- const CITY_DIR = `${process.env.CONTENT_DIR || '.'}/${process.env.CITY || 'blog'}`;
14
+ // This file is exclusively for blog/club consumer repos. Default to 'blog'
15
+ // since that's the primary consumer — clubs override via CITY env var.
16
+ const city = process.env.CITY || 'blog';
17
+ const CITY_DIR = `${process.env.CONTENT_DIR || '.'}/${city}`;
15
18
  const mdPattern = ['**/*.md', '!**/*.??.md'];
16
19
 
17
20
  export const collections = {
@@ -4,12 +4,13 @@ import { routeLoader } from './loaders/routes';
4
4
  import { rideLoader } from './loaders/rides';
5
5
  import { pageLoader } from './loaders/pages';
6
6
  import { isBlogInstance } from './lib/city-config';
7
+ import { cityDir } from './lib/config';
7
8
  import {
8
9
  routeSchema, placeSchema, guideSchema,
9
10
  eventSchema, organizerSchema, pageSchema,
10
11
  } from './schemas/index';
11
12
 
12
- const CITY_DIR = `${process.env.CONTENT_DIR || '../bike-routes'}/${process.env.CITY || 'ottawa'}`;
13
+ const CITY_DIR = cityDir;
13
14
 
14
15
  // Exclude translation files (e.g. bike-crash.fr.md) — only load base language
15
16
  const mdPattern = ['**/*.md', '!**/*.??.md'];
package/src/env.d.ts CHANGED
@@ -20,6 +20,9 @@ declare global {
20
20
  /** App repo git branch, baked in at build time via vite.define */
21
21
  const __APP_BRANCH__: string;
22
22
 
23
+ /** City slug, baked in at build time via vite.define */
24
+ const __CITY__: string;
25
+
23
26
  // -- D1 (SQL database) types used by drizzle-orm/d1 --
24
27
 
25
28
  interface D1Result<T = unknown> {
@@ -77,6 +77,19 @@ function patchStaticCspStyles(rootDir: string) {
77
77
  }
78
78
  }
79
79
 
80
+ /**
81
+ * Auto-detect the city slug by scanning a content directory for subdirectories
82
+ * containing config.yml. If exactly one is found, return it. This lets consumer
83
+ * repos (blog, club) skip setting CITY= when the content layout is unambiguous.
84
+ */
85
+ function detectCity(contentDir: string): string | null {
86
+ if (!fs.existsSync(contentDir)) return null;
87
+ const candidates = fs.readdirSync(contentDir, { withFileTypes: true })
88
+ .filter(e => e.isDirectory() && fs.existsSync(path.join(contentDir, e.name, 'config.yml')))
89
+ .map(e => e.name);
90
+ return candidates.length === 1 ? candidates[0] : null;
91
+ }
92
+
80
93
  function detectBranch(): string {
81
94
  try {
82
95
  return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
@@ -98,6 +111,17 @@ export function wheretoBike(options?: WheretoBikeOptions): AstroIntegration[] {
98
111
  process.env.CITY = options.city;
99
112
  }
100
113
 
114
+ // Auto-detect CITY for consumer repos: scan the content directory for a single
115
+ // city folder containing config.yml. Blog repos always have blog/config.yml,
116
+ // club repos have their club folder — no need to set CITY explicitly.
117
+ if (!process.env.CITY && options?.consumerRoot) {
118
+ const contentDir = process.env.CONTENT_DIR || options.consumerRoot;
119
+ const detected = detectCity(contentDir);
120
+ if (detected) {
121
+ process.env.CITY = detected;
122
+ }
123
+ }
124
+
101
125
  const consumerRoot = options?.consumerRoot;
102
126
 
103
127
  // Derive i18n config from city config.yml (same logic as previous astro.config.mjs).
@@ -129,6 +153,7 @@ export function wheretoBike(options?: WheretoBikeOptions): AstroIntegration[] {
129
153
  },
130
154
  define: {
131
155
  __APP_BRANCH__: JSON.stringify(detectBranch()),
156
+ __CITY__: JSON.stringify(CITY),
132
157
  },
133
158
  plugins: [buildDataPlugin({ consumerRoot })],
134
159
  build: {
package/src/lib/config.ts CHANGED
@@ -1,5 +1,26 @@
1
1
  import path from 'node:path';
2
2
 
3
3
  export const CONTENT_DIR = process.env.CONTENT_DIR || path.resolve('..', 'bike-routes');
4
- export const CITY = process.env.CITY || 'ottawa';
4
+
5
+ /**
6
+ * City slug for this instance.
7
+ *
8
+ * At build time: read from process.env.CITY (set by shell or wheretoBike() options).
9
+ * In bundled Worker: inlined as a string literal via Vite define (__CITY__).
10
+ *
11
+ * Never defaults silently — an unset CITY would commit content to the wrong
12
+ * directory in the data repo (the bug that prompted this safeguard).
13
+ */
14
+ function resolveCity(): string {
15
+ // In the Vite-bundled server, __CITY__ is replaced with a string literal at build time.
16
+ // In Node.js (loaders, tests, scripts), it's undefined and we fall back to process.env.
17
+ if (typeof __CITY__ !== 'undefined') return __CITY__;
18
+ const fromEnv = process.env.CITY;
19
+ if (fromEnv) return fromEnv;
20
+ throw new Error(
21
+ 'CITY environment variable is required. Set CITY=ottawa (or blog, demo, etc.) before building or running.',
22
+ );
23
+ }
24
+
25
+ export const CITY: string = resolveCity();
5
26
  export const cityDir = path.join(CONTENT_DIR, CITY);