@abreen/tada 1.11.3 → 1.13.4

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.
Files changed (85) hide show
  1. package/README.md +65 -64
  2. package/bin/tada.ts +61 -66
  3. package/build/build-manifest.ts +14 -3
  4. package/build/bundle.ts +0 -1
  5. package/build/config-files.ts +183 -0
  6. package/build/config-loader.ts +159 -0
  7. package/build/copy.ts +0 -83
  8. package/build/generate-content-assets.ts +75 -160
  9. package/build/globals.ts +48 -89
  10. package/build/log.ts +27 -18
  11. package/build/pagefind.ts +33 -26
  12. package/build/pipeline.ts +98 -79
  13. package/build/reachability.ts +51 -248
  14. package/build/serve.ts +35 -6
  15. package/build/site-variables.ts +20 -11
  16. package/build/source-model.ts +400 -0
  17. package/build/{watch/records.ts → source-records.ts} +111 -49
  18. package/build/template-globals.ts +91 -0
  19. package/build/templates.ts +50 -40
  20. package/build/test-helpers.ts +15 -0
  21. package/build/toc-plugin.ts +5 -1
  22. package/build/types.d.ts +9 -1
  23. package/build/util.ts +8 -0
  24. package/build/utils/code.ts +6 -16
  25. package/build/utils/content-files.ts +57 -126
  26. package/build/utils/final-html.ts +239 -0
  27. package/build/utils/markdown.ts +237 -17
  28. package/build/utils/paths.ts +2 -1
  29. package/build/utils/render.ts +108 -99
  30. package/build/utils/trace-core.ts +3 -7
  31. package/build/validate-config-links.ts +91 -14
  32. package/build/watch/assets.ts +0 -2
  33. package/build/watch/build-full.ts +14 -15
  34. package/build/watch/build-incremental.ts +15 -8
  35. package/build/watch/compiler-types.d.ts +6 -1
  36. package/build/watch/compiler.ts +27 -15
  37. package/build/watch/index.ts +2 -7
  38. package/build/watch/planner.ts +32 -16
  39. package/build/watch/reload.ts +4 -0
  40. package/build/watch/runtime.ts +29 -45
  41. package/build/watch/snapshot.ts +50 -402
  42. package/build/watch/validation.ts +6 -3
  43. package/build/watch-reachability-state.ts +16 -23
  44. package/build/watch-reload-client.ts +11 -4
  45. package/init/content/index.md +1 -1
  46. package/init/content/labs/02/index.md +61 -0
  47. package/init/content/labs/02/selection_sort.py +23 -0
  48. package/init/content/labs/index.md +4 -2
  49. package/init/content/lectures/01/index.md +1 -1
  50. package/init/content/markdown.md +1 -1
  51. package/package.json +3 -4
  52. package/src/_base.scss +67 -0
  53. package/src/anchor/README.md +9 -0
  54. package/src/anchor/index.ts +115 -16
  55. package/src/anchor/style.scss +70 -10
  56. package/src/code/index.ts +46 -18
  57. package/src/code.scss +2 -1
  58. package/src/globals.ts +47 -0
  59. package/src/index.ts +1 -0
  60. package/src/navigate/index.ts +2 -5
  61. package/src/navigate/lifecycle.ts +3 -1
  62. package/src/navigate/runtime.ts +6 -5
  63. package/src/page-update/index.ts +20 -16
  64. package/src/print/style.scss +0 -4
  65. package/src/search/index.ts +53 -25
  66. package/src/slides/index.ts +481 -0
  67. package/src/slides/style.scss +104 -0
  68. package/src/style.scss +8 -3
  69. package/src/timezone/index.ts +16 -20
  70. package/src/top/index.ts +2 -4
  71. package/src/trace/index.ts +116 -14
  72. package/src/trace/style.scss +1 -1
  73. package/src/util.ts +6 -2
  74. package/templates/_author.html +2 -2
  75. package/templates/_heading.html +1 -1
  76. package/templates/_nav.html +2 -2
  77. package/templates/_theme.scss +4 -3
  78. package/templates/_top.html +14 -14
  79. package/templates/default.html +9 -0
  80. package/types/dev.d.ts +1 -1
  81. package/types/site-variables.d.ts +4 -5
  82. package/watch/fs-commit.ts +63 -10
  83. package/build/apply-base-path-plugin.ts +0 -186
  84. package/build/content-watch.ts +0 -104
  85. package/build/validate-internal-links-plugin.ts +0 -207
package/README.md CHANGED
@@ -25,6 +25,7 @@ A static site generator. The successor to Presto.
25
25
  - Interactive execution traces via `renderTrace()`
26
26
  * Supports Java and Python source files
27
27
  * Renders step-by-step source highlighting, output, and memory diagrams
28
+ - Slides mode: separate content by `---` and present sections as full-screen slides
28
29
  - PDF files are copied into `dist/`
29
30
  - Text of each PDF page is extracted using `mutool` (if present) and indexed
30
31
  - External link handling (special visual treatment for external links)
@@ -118,7 +119,7 @@ Available flags: `--title`, `--symbol`, `--theme-color`, `--tint-hue`,
118
119
 
119
120
  ### `tada dev`
120
121
 
121
- Build the site for local development (using `site.dev.json`)
122
+ Build the site for local development (using `site.dev.yaml` by default)
122
123
  into the `dist/` directory.
123
124
 
124
125
  ### `tada serve`
@@ -137,7 +138,7 @@ builds (keeps the latest two versions).
137
138
 
138
139
  ### `tada prod`
139
140
 
140
- Build the site for production (uses `site.prod.json`). Each prod build is
141
+ Build the site for production (uses `site.prod.yaml` by default). Each prod build is
141
142
  saved to a versioned directory under `dist-prod/` (e.g., `dist-prod/v1/`,
142
143
  `dist-prod/v2/`) with a manifest file that records the SHA-256 hash of every
143
144
  output file.
@@ -160,7 +161,7 @@ The output directory will also include a `manifest.json` for the newer build.
160
161
  ## Development vs. production builds
161
162
 
162
163
  `tada dev` and `tada watch` build to the `dist/` directory using
163
- `site.dev.json` (typically `localhost` URLs). `tada watch` includes a
164
+ `site.dev.*` (typically `localhost` URLs). `tada watch` includes a
164
165
  development server with live reload; `tada serve` is a standalone server
165
166
  for previewing a `tada dev` build. Dev builds overwrite `dist/` each time.
166
167
 
@@ -196,31 +197,39 @@ manually.
196
197
 
197
198
  Build-time site config lives in:
198
199
 
199
- - `site.dev.json` (used by `tada dev` / `tada watch`)
200
- - `site.prod.json` (used by `tada prod`)
201
- - `nav.json` (navigation structure)
202
- - `authors.json` (author data)
203
-
204
- Example site configuration JSON file:
205
-
206
- ```json
207
- {
208
- "title": "Intro to Computer Science",
209
- "titlePostfix": " - CS 0",
210
- "symbol": "CS 0",
211
- "themeColor": "hsl(351 70% 40%)",
212
- "tintAmount": 0,
213
- "features": { "search": true, "favicon": true },
214
- "base": "https://example.edu",
215
- "basePath": "/cs0",
216
- "internalDomains": ["example.edu"],
217
- "defaultTimeZone": "America/New_York",
218
- "extensionToShikiLanguage": { "java": "java", "py": "python" },
219
- "shikiLanguages": ["java", "python"],
220
- "vars": {
221
- "staffEmail": "staff@example.edu"
222
- }
223
- }
200
+ - `site.dev.yaml` (used by `tada dev` / `tada watch`)
201
+ - `site.prod.yaml` (used by `tada prod`)
202
+ - `nav.yaml` (navigation structure)
203
+ - `authors.yaml` (author data)
204
+
205
+ Tada also accepts `.yml` and `.json` for each of these files. Keep only one
206
+ variant for each logical config name; for example, `nav.yaml` and `nav.json`
207
+ in the same site root is an error.
208
+
209
+ Example site configuration YAML file:
210
+
211
+ ```yaml
212
+ title: Intro to Computer Science
213
+ titlePostfix: " - CS 0"
214
+ symbol: CS 0
215
+ themeColor: hsl(351 70% 40%)
216
+ tintAmount: 0
217
+ features:
218
+ search: true
219
+ favicon: true
220
+ base: https://example.edu
221
+ basePath: /cs0
222
+ internalDomains:
223
+ - example.edu
224
+ defaultTimeZone: America/New_York
225
+ extensionToShikiLanguage:
226
+ java: java
227
+ py: python
228
+ shikiLanguages:
229
+ - java
230
+ - python
231
+ vars:
232
+ staffEmail: staff@example.edu
224
233
  ```
225
234
 
226
235
  | Field | Description |
@@ -244,54 +253,45 @@ Example site configuration JSON file:
244
253
  | `faviconFontWeight` | *Optional*, font weight used for favicon text (default `700`) |
245
254
  | `vars` | Arbitrary key/value variables exposed to templates/content as `vars.*` (e.g., `<%= vars.staffEmail %>`) |
246
255
 
247
- #### `nav.json`
256
+ #### `nav.yaml`
248
257
 
249
258
  Defines the site navigation structure. The file contains an array of section
250
259
  objects. Each section contains an array of link objects (internal or external,
251
260
  and whether the link is disabled). You should specify at least two sections,
252
261
  but three or more sections are supported.
253
262
 
254
- ```json
255
- [
256
- {
257
- "title": "Navigation",
258
- "links": [{ "text": "Home", "internal": "/index.html" }]
259
- },
260
- {
261
- "title": "Topics",
262
- "links": [
263
- { "text": "Lectures", "internal": "/lectures/index.html" },
264
- {
265
- "text": "Problem Sets",
266
- "internal": "/problem_sets/index.html",
267
- "disabled": true
268
- }
269
- ]
270
- },
271
- {
272
- "title": "Links",
273
- "links": [
274
- { "text": "Zoom", "external": "https://zoom.com" }
275
- ]
276
- }
277
- ]
263
+ ```yaml
264
+ - title: Navigation
265
+ links:
266
+ - text: Home
267
+ internal: /index.html
268
+ - title: Topics
269
+ links:
270
+ - text: Lectures
271
+ internal: /lectures/index.html
272
+ - text: Problem Sets
273
+ internal: /problem_sets/index.html
274
+ disabled: true
275
+ - title: Links
276
+ links:
277
+ - text: Zoom
278
+ external: https://zoom.com
278
279
  ```
279
280
 
280
- #### `authors.json`
281
+ #### `authors.yaml`
281
282
 
282
283
  Maps author handles (used in front matter `author` fields) to display names
283
284
  and avatars. Each key is a handle (e.g., `jsmith`) and each value is an object
284
285
  with `name`, `avatar`, and optionally `url`.
285
286
 
286
- ```json
287
- {
288
- "jsmith": { "name": "Jane Smith", "avatar": "/avatars/jsmith.jpg" },
289
- "ajones": {
290
- "name": "Alex Jones",
291
- "avatar": "/avatars/ajones.jpg",
292
- "url": "/staff/ajones.html"
293
- }
294
- }
287
+ ```yaml
288
+ jsmith:
289
+ name: Jane Smith
290
+ avatar: /avatars/jsmith.jpg
291
+ ajones:
292
+ name: Alex Jones
293
+ avatar: /avatars/ajones.jpg
294
+ url: /staff/ajones.html
295
295
  ```
296
296
 
297
297
  ## Content
@@ -313,9 +313,10 @@ list of variables parsed using the [`front-matter`][front-matter] library).
313
313
  |-------|-------------|
314
314
  | `title` (required) | Page title (`<title>` tag and page heading) |
315
315
  | `skip` | Set to `true` to skip building this page completely |
316
- | `author` | Author handle (e.g. `jsmith`) resolved to a full object via `authors.json` |
316
+ | `author` | Author handle (e.g. `jsmith`) resolved to a full object via the authors config |
317
317
  | `description` | Meta description for the page |
318
318
  | `toc` | Set to `true` to show a table of contents |
319
+ | `slides` | Set to `true` on Markdown pages to treat top-level `---` as slide separators and add presentation controls |
319
320
  | `parent` & `parentLabel` | URL and label for a breadcrumb link displayed above the title |
320
321
  | `published` | Year, month, and day of publishing (e.g, `2025-09-09`) |
321
322
 
package/bin/tada.ts CHANGED
@@ -19,6 +19,12 @@ import {
19
19
  getVersions,
20
20
  pruneOldVersions,
21
21
  } from '../build/build-manifest';
22
+ import {
23
+ getDefaultProjectConfigFilePath,
24
+ getDefaultSiteConfigPath,
25
+ resolveSiteConfigFile,
26
+ type SiteEnv,
27
+ } from '../build/config-files';
22
28
 
23
29
  const { version } = packageJson;
24
30
 
@@ -26,10 +32,11 @@ const SYSTEM_TIME_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
26
32
 
27
33
  const packageDir = path.resolve(import.meta.dir, '..');
28
34
 
29
- function requireSiteConfig(env: string): void {
30
- const configPath = path.resolve(process.cwd(), `site.${env}.json`);
31
- if (!fs.existsSync(configPath)) {
32
- console.error(`Error: Missing config file: site.${env}.json`);
35
+ function requireSiteConfig(env: SiteEnv): void {
36
+ try {
37
+ resolveSiteConfigFile(process.cwd(), env);
38
+ } catch (error) {
39
+ console.error(`Error: ${(error as Error).message}`);
33
40
  process.exit(1);
34
41
  }
35
42
  }
@@ -132,9 +139,6 @@ function printUsage() {
132
139
  console.log(
133
140
  ' [--port <n>] HTTP server port (default: 8080)',
134
141
  );
135
- console.log(
136
- ' [--ws-port <n>] WebSocket port for live reload (default: 35729)',
137
- );
138
142
  continue;
139
143
  } else if (cmd === 'serve') {
140
144
  console.log(` ${cmd.padEnd(18)} ${desc}`);
@@ -175,6 +179,10 @@ function copyDirRecursive(src: string, dest: string): void {
175
179
  }
176
180
  }
177
181
 
182
+ function stringifyYaml(value: unknown): string {
183
+ return Bun.YAML.stringify(value, null, 2) + '\n';
184
+ }
185
+
178
186
  const FLAG_TO_KEY: Record<string, string> = {
179
187
  title: 'title',
180
188
  symbol: 'symbol',
@@ -353,13 +361,13 @@ async function initCommand(args: string[]): Promise<void> {
353
361
  });
354
362
 
355
363
  fs.writeFileSync(
356
- path.join(projectDir, 'site.dev.json'),
357
- JSON.stringify(devConfig, null, 2) + '\n',
364
+ getDefaultSiteConfigPath(projectDir, 'dev'),
365
+ stringifyYaml(devConfig),
358
366
  );
359
367
 
360
368
  fs.writeFileSync(
361
- path.join(projectDir, 'site.prod.json'),
362
- JSON.stringify(prodConfig, null, 2) + '\n',
369
+ getDefaultSiteConfigPath(projectDir, 'prod'),
370
+ stringifyYaml(prodConfig),
363
371
  );
364
372
 
365
373
  if (bare) {
@@ -370,63 +378,53 @@ async function initCommand(args: string[]): Promise<void> {
370
378
  );
371
379
  fs.mkdirSync(path.join(projectDir, 'public'), { recursive: true });
372
380
  fs.writeFileSync(
373
- path.join(projectDir, 'nav.json'),
374
- JSON.stringify(
375
- [
376
- {
377
- title: 'Navigation',
378
- links: [{ text: 'Home', internal: '/index.html' }],
379
- },
380
- ],
381
- null,
382
- 2,
383
- ) + '\n',
381
+ getDefaultProjectConfigFilePath(projectDir, 'nav'),
382
+ stringifyYaml([
383
+ {
384
+ title: 'Navigation',
385
+ links: [{ text: 'Home', internal: '/index.html' }],
386
+ },
387
+ ]),
384
388
  );
385
389
  } else {
386
390
  // Create nav and authors data files in the project root
387
391
  fs.writeFileSync(
388
- path.join(projectDir, 'nav.json'),
389
- JSON.stringify(
390
- [
391
- {
392
- title: 'Navigation',
393
- links: [{ text: 'Home', internal: '/index.html' }],
394
- },
395
- {
396
- title: 'Topics',
397
- links: [
398
- { text: 'Lectures', internal: '/lectures/index.html' },
399
- { text: 'Labs', internal: '/labs/index.html' },
400
- {
401
- text: 'Problem Sets',
402
- internal: '/problem_sets/index.html',
403
- disabled: true,
404
- },
405
- ],
406
- },
407
- {
408
- title: 'Links',
409
- links: [
410
- { text: 'Zoom', external: 'https://zoom.com' },
411
- {
412
- text: 'Canvas',
413
- external: 'https://www.instructure.com/',
414
- disabled: true,
415
- },
416
- ],
417
- },
418
- ],
419
- null,
420
- 2,
421
- ) + '\n',
392
+ getDefaultProjectConfigFilePath(projectDir, 'nav'),
393
+ stringifyYaml([
394
+ {
395
+ title: 'Navigation',
396
+ links: [{ text: 'Home', internal: '/index.html' }],
397
+ },
398
+ {
399
+ title: 'Topics',
400
+ links: [
401
+ { text: 'Lectures', internal: '/lectures/index.html' },
402
+ { text: 'Labs', internal: '/labs/index.html' },
403
+ {
404
+ text: 'Problem Sets',
405
+ internal: '/problem_sets/index.html',
406
+ disabled: true,
407
+ },
408
+ ],
409
+ },
410
+ {
411
+ title: 'Links',
412
+ links: [
413
+ { text: 'Zoom', external: 'https://zoom.com' },
414
+ {
415
+ text: 'Canvas',
416
+ external: 'https://www.instructure.com/',
417
+ disabled: true,
418
+ },
419
+ ],
420
+ },
421
+ ]),
422
422
  );
423
423
  fs.writeFileSync(
424
- path.join(projectDir, 'authors.json'),
425
- JSON.stringify(
426
- { alex: { name: 'Alex Breen', avatar: '/avatars/alex.jpg' } },
427
- null,
428
- 2,
429
- ) + '\n',
424
+ getDefaultProjectConfigFilePath(projectDir, 'authors'),
425
+ stringifyYaml({
426
+ alex: { name: 'Alex Breen', avatar: '/avatars/alex.jpg' },
427
+ }),
430
428
  );
431
429
 
432
430
  // Copy content/ and public/ from the package
@@ -627,10 +625,7 @@ try {
627
625
  const { runWatch } = await import(
628
626
  path.join(packageDir, 'build/watch/index.ts')
629
627
  );
630
- await runWatch({
631
- httpPort: parsePortArg('--port'),
632
- wsPort: parsePortArg('--ws-port'),
633
- });
628
+ await runWatch({ httpPort: parsePortArg('--port') });
634
629
  break;
635
630
  }
636
631
 
@@ -1,10 +1,16 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ import { globals, type Globals } from './globals';
3
4
  import { toPosix } from './utils/paths';
4
5
 
5
6
  const EXCLUDED_DIRS = new Set(['pagefind']);
6
7
  const EXCLUDED_FILES = new Set(['tada.manifest.json']);
7
8
 
9
+ type ManifestGlobals = Pick<
10
+ Globals,
11
+ 'createSha256Hasher' | 'now' | 'readFileArrayBuffer' | 'toISOString'
12
+ >;
13
+
8
14
  export interface BuildManifest {
9
15
  schema: 1;
10
16
  build: number;
@@ -51,8 +57,12 @@ export function diffManifests(
51
57
  }
52
58
 
53
59
  export async function hashFile(filePath: string): Promise<string> {
54
- const buffer = await Bun.file(filePath).arrayBuffer();
55
- const hasher = new Bun.CryptoHasher('sha256');
60
+ const runtimeGlobals: Pick<
61
+ ManifestGlobals,
62
+ 'createSha256Hasher' | 'readFileArrayBuffer'
63
+ > = globals;
64
+ const buffer = await runtimeGlobals.readFileArrayBuffer(filePath);
65
+ const hasher = runtimeGlobals.createSha256Hasher();
56
66
  hasher.update(buffer);
57
67
  return hasher.digest('hex');
58
68
  }
@@ -120,11 +130,12 @@ export async function generateBuildManifest(
120
130
  manifestPath: string,
121
131
  buildVersion: number,
122
132
  ): Promise<void> {
133
+ const runtimeGlobals: Pick<ManifestGlobals, 'now' | 'toISOString'> = globals;
123
134
  const files = await walkAndHash(distDir);
124
135
  const manifest: BuildManifest = {
125
136
  schema: 1,
126
137
  build: buildVersion,
127
- buildTime: new Date().toISOString(),
138
+ buildTime: runtimeGlobals.toISOString(runtimeGlobals.now()),
128
139
  files,
129
140
  };
130
141
  fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
package/build/bundle.ts CHANGED
@@ -69,7 +69,6 @@ function createDefine(
69
69
  isDev = false,
70
70
  ): Record<string, string> {
71
71
  return {
72
- __SITE_BASE__: JSON.stringify(siteVariables.base),
73
72
  __SITE_BASE_PATH__: JSON.stringify(siteVariables.basePath),
74
73
  __SITE_TITLE_POSTFIX__: JSON.stringify(siteVariables.titlePostfix),
75
74
  __SITE_DEFAULT_TIMEZONE__: JSON.stringify(siteVariables.defaultTimeZone),
@@ -0,0 +1,183 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ export type SiteEnv = 'dev' | 'prod';
5
+ export type SiteConfigName = 'site.dev' | 'site.prod';
6
+ export type ProjectConfigName = 'nav' | 'authors';
7
+ export type ConfigName = SiteConfigName | ProjectConfigName;
8
+ export type ProjectConfigExtension = '.yaml' | '.yml' | '.json';
9
+
10
+ export interface ResolvedProjectConfigFile<
11
+ Name extends ConfigName = ConfigName,
12
+ > {
13
+ name: Name;
14
+ baseName: string;
15
+ fileName: string;
16
+ filePath: string;
17
+ extension: ProjectConfigExtension;
18
+ required: boolean;
19
+ }
20
+
21
+ export const CONFIG_FILE_EXTENSIONS = ['.yaml', '.yml', '.json'] as const;
22
+ export const DEFAULT_CONFIG_FILE_EXTENSION = '.yaml' as const;
23
+
24
+ export const SITE_CONFIG_BASE_NAMES = {
25
+ dev: 'site.dev',
26
+ prod: 'site.prod',
27
+ } as const satisfies Record<SiteEnv, SiteConfigName>;
28
+
29
+ export const PROJECT_CONFIG_BASE_NAMES = {
30
+ nav: 'nav',
31
+ authors: 'authors',
32
+ } as const satisfies Record<ProjectConfigName, ProjectConfigName>;
33
+
34
+ export const REQUIRED_PROJECT_CONFIG_NAMES = ['nav'] as const;
35
+ export const OPTIONAL_PROJECT_CONFIG_NAMES = ['authors'] as const;
36
+ export const PROJECT_CONFIG_NAMES = [
37
+ ...REQUIRED_PROJECT_CONFIG_NAMES,
38
+ ...OPTIONAL_PROJECT_CONFIG_NAMES,
39
+ ] as const;
40
+
41
+ function joinWithOr(items: string[]): string {
42
+ if (items.length <= 1) {
43
+ return items[0] ?? '';
44
+ }
45
+ if (items.length === 2) {
46
+ return `${items[0]} or ${items[1]}`;
47
+ }
48
+ return `${items.slice(0, -1).join(', ')}, or ${items[items.length - 1]}`;
49
+ }
50
+
51
+ function getResolvedConfigExtension(fileName: string): ProjectConfigExtension {
52
+ const ext = path.extname(fileName).toLowerCase();
53
+ if (ext !== '.yaml' && ext !== '.yml' && ext !== '.json') {
54
+ throw new Error(`Unsupported config file extension for ${fileName}`);
55
+ }
56
+ return ext;
57
+ }
58
+
59
+ export function getSiteConfigBaseName(env: SiteEnv): SiteConfigName {
60
+ return SITE_CONFIG_BASE_NAMES[env];
61
+ }
62
+
63
+ export function getProjectConfigBaseName(
64
+ name: ProjectConfigName,
65
+ ): ProjectConfigName {
66
+ return PROJECT_CONFIG_BASE_NAMES[name];
67
+ }
68
+
69
+ export function getSupportedConfigFileNames(baseName: string): string[] {
70
+ return CONFIG_FILE_EXTENSIONS.map(ext => `${baseName}${ext}`);
71
+ }
72
+
73
+ export function getSupportedConfigFilePaths(
74
+ projectDir: string,
75
+ baseName: string,
76
+ ): string[] {
77
+ return getSupportedConfigFileNames(baseName).map(fileName =>
78
+ path.join(projectDir, fileName),
79
+ );
80
+ }
81
+
82
+ export function getSupportedConfigFileNamesText(baseName: string): string {
83
+ return joinWithOr(getSupportedConfigFileNames(baseName));
84
+ }
85
+
86
+ export function getDefaultConfigFileName(baseName: string): string {
87
+ return `${baseName}${DEFAULT_CONFIG_FILE_EXTENSION}`;
88
+ }
89
+
90
+ export function getDefaultConfigFilePath(
91
+ projectDir: string,
92
+ baseName: string,
93
+ ): string {
94
+ return path.join(projectDir, getDefaultConfigFileName(baseName));
95
+ }
96
+
97
+ export function getDefaultSiteConfigFileName(env: SiteEnv): string {
98
+ return getDefaultConfigFileName(getSiteConfigBaseName(env));
99
+ }
100
+
101
+ export function getDefaultSiteConfigPath(
102
+ projectDir: string,
103
+ env: SiteEnv,
104
+ ): string {
105
+ return getDefaultConfigFilePath(projectDir, getSiteConfigBaseName(env));
106
+ }
107
+
108
+ export function getDefaultProjectConfigFileName(
109
+ name: ProjectConfigName,
110
+ ): string {
111
+ return getDefaultConfigFileName(getProjectConfigBaseName(name));
112
+ }
113
+
114
+ export function getDefaultProjectConfigFilePath(
115
+ projectDir: string,
116
+ name: ProjectConfigName,
117
+ ): string {
118
+ return getDefaultConfigFilePath(projectDir, getProjectConfigBaseName(name));
119
+ }
120
+
121
+ export function resolveConfigFile<Name extends ConfigName = ConfigName>(
122
+ projectDir: string,
123
+ {
124
+ name,
125
+ baseName,
126
+ required,
127
+ }: { name: Name; baseName: string; required: boolean },
128
+ ): ResolvedProjectConfigFile<Name> | undefined {
129
+ const matches = getSupportedConfigFilePaths(projectDir, baseName).filter(
130
+ filePath => fs.existsSync(filePath),
131
+ );
132
+
133
+ if (matches.length > 1) {
134
+ const fileNames = matches.map(filePath => path.basename(filePath));
135
+ throw new Error(
136
+ `Multiple config files found for ${baseName}: ${joinWithOr(fileNames)}. Keep only one.`,
137
+ );
138
+ }
139
+
140
+ if (matches.length === 0) {
141
+ if (required) {
142
+ throw new Error(
143
+ `Missing required config file for ${baseName} (tried ${getSupportedConfigFileNamesText(baseName)})`,
144
+ );
145
+ }
146
+ return undefined;
147
+ }
148
+
149
+ const filePath = matches[0];
150
+ const fileName = path.basename(filePath);
151
+ return {
152
+ name,
153
+ baseName,
154
+ fileName,
155
+ filePath,
156
+ extension: getResolvedConfigExtension(fileName),
157
+ required,
158
+ };
159
+ }
160
+
161
+ export function resolveSiteConfigFile(
162
+ projectDir: string,
163
+ env: SiteEnv,
164
+ ): ResolvedProjectConfigFile<SiteConfigName> {
165
+ const baseName = SITE_CONFIG_BASE_NAMES[env];
166
+ return resolveConfigFile(projectDir, {
167
+ name: baseName,
168
+ baseName,
169
+ required: true,
170
+ })!;
171
+ }
172
+
173
+ export function resolveProjectConfigFile(
174
+ projectDir: string,
175
+ name: ProjectConfigName,
176
+ ): ResolvedProjectConfigFile<ProjectConfigName> | undefined {
177
+ const baseName = PROJECT_CONFIG_BASE_NAMES[name];
178
+ return resolveConfigFile(projectDir, {
179
+ name,
180
+ baseName,
181
+ required: name === 'nav',
182
+ });
183
+ }