@abreen/tada 1.17.0 → 1.18.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.
Files changed (107) hide show
  1. package/README.md +41 -7
  2. package/bin/tada.ts +23 -9
  3. package/bin/validators.ts +34 -1
  4. package/build/branding.ts +41 -0
  5. package/build/build-manifest.ts +10 -5
  6. package/build/{watch/compiler-types.d.ts → build-types.d.ts} +8 -5
  7. package/build/build-validation.ts +176 -0
  8. package/build/bundle.ts +75 -65
  9. package/build/columns-plugin.ts +27 -1
  10. package/build/config-loader.ts +3 -3
  11. package/build/custom-fonts.ts +8 -5
  12. package/build/deflist-id-plugin.ts +2 -2
  13. package/build/generate-favicon.ts +7 -6
  14. package/build/generate-fonts.ts +8 -15
  15. package/build/generate-katex-assets.ts +19 -17
  16. package/build/generate-web-app-manifest.ts +3 -11
  17. package/build/lodash-template.ts +14 -0
  18. package/build/output-publication.ts +238 -0
  19. package/build/pagefind.ts +49 -21
  20. package/build/pdf-text.ts +41 -1
  21. package/build/pipeline.ts +95 -101
  22. package/build/site-assets.ts +46 -0
  23. package/build/site-build.ts +267 -0
  24. package/build/source-model.ts +223 -229
  25. package/build/source-records.ts +69 -81
  26. package/build/template-globals.ts +3 -0
  27. package/build/templates.ts +12 -8
  28. package/build/toc-plugin.ts +21 -21
  29. package/build/types.d.ts +38 -38
  30. package/build/util.ts +0 -4
  31. package/build/utils/code.ts +51 -117
  32. package/build/utils/final-html.ts +171 -47
  33. package/build/utils/jdi-runner/LiterateRunner.java +9 -1
  34. package/build/utils/literate-java.ts +11 -7
  35. package/build/utils/markdown-partials.ts +4 -3
  36. package/build/utils/markdown.ts +6 -17
  37. package/build/utils/paths.ts +3 -0
  38. package/build/utils/plain-text.ts +18 -0
  39. package/build/utils/render.ts +197 -154
  40. package/build/utils/shiki-highlighter.ts +11 -0
  41. package/build/utils/source-template.ts +2 -2
  42. package/build/utils/trace-core.ts +8 -83
  43. package/build/utils/trace.ts +38 -95
  44. package/build/validate-config-links.ts +39 -6
  45. package/build/watch/build-incremental.ts +46 -103
  46. package/build/watch/compiler.ts +158 -37
  47. package/build/watch/engine.ts +207 -162
  48. package/build/watch/index.ts +9 -2
  49. package/build/watch/planner.ts +88 -144
  50. package/build/watch/runtime.ts +21 -34
  51. package/build/watch/types.d.ts +32 -64
  52. package/package.json +4 -5
  53. package/schema/site.schema.json +18 -2
  54. package/src/_content.scss +447 -0
  55. package/src/_fonts.scss +90 -0
  56. package/src/_layout.scss +50 -35
  57. package/src/appearance-picker/{style.scss → _index.scss} +1 -1
  58. package/src/code/{style.scss → _index.scss} +61 -0
  59. package/src/header/{_base.scss → _index.scss} +98 -6
  60. package/src/index.ts +1 -17
  61. package/src/navigate/{style.scss → _index.scss} +20 -0
  62. package/src/navigate/eligible.ts +0 -40
  63. package/src/navigate/index.ts +50 -5
  64. package/src/navigate/lifecycle.ts +19 -2
  65. package/src/navigate/runtime.ts +20 -4
  66. package/src/page-update/index.ts +33 -20
  67. package/src/print/{style.scss → _index.scss} +3 -1
  68. package/src/search/index.ts +30 -2
  69. package/src/slides/{style.scss → _index.scss} +5 -0
  70. package/src/style.scss +24 -536
  71. package/src/timezone/index.ts +29 -3
  72. package/src/timezone/time-format.ts +4 -0
  73. package/src/toc/index.ts +6 -5
  74. package/src/trace/index.ts +11 -1
  75. package/templates/_author.html +2 -2
  76. package/templates/_download.html +1 -1
  77. package/templates/_footer.html +1 -1
  78. package/templates/_heading.html +3 -3
  79. package/templates/_nav.html +2 -2
  80. package/templates/_page-bottom.html +6 -0
  81. package/templates/_top.html +18 -14
  82. package/templates/code.html +22 -21
  83. package/templates/default.html +12 -11
  84. package/templates/literate.html +6 -5
  85. package/build/copy.ts +0 -82
  86. package/build/generate-content-assets.ts +0 -121
  87. package/build/utils/content-files.ts +0 -170
  88. package/build/watch/assets.ts +0 -103
  89. package/build/watch/build-full.ts +0 -97
  90. package/build/watch/build-helpers.ts +0 -66
  91. package/build/watch/build-result.ts +0 -19
  92. package/build/watch/fs-commit.ts +0 -153
  93. package/build/watch/mutations.ts +0 -73
  94. package/build/watch/snapshot.ts +0 -201
  95. package/build/watch/validation.ts +0 -50
  96. package/src/code.scss +0 -60
  97. package/src/critical.scss +0 -5
  98. package/src/header/style.scss +0 -91
  99. /package/src/{literate.scss → _literate.scss} +0 -0
  100. /package/src/{material-symbols.scss → _material-symbols.scss} +0 -0
  101. /package/src/anchor/{style.scss → _index.scss} +0 -0
  102. /package/src/page-update/{style.scss → _index.scss} +0 -0
  103. /package/src/question/{style.scss → _index.scss} +0 -0
  104. /package/src/search/{style.scss → _index.scss} +0 -0
  105. /package/src/timezone/{style.scss → _index.scss} +0 -0
  106. /package/src/toc/{style.scss → _index.scss} +0 -0
  107. /package/src/trace/{style.scss → _index.scss} +0 -0
package/README.md CHANGED
@@ -45,7 +45,8 @@ A static site generator. The successor to Presto.
45
45
  * `+++ ... +++ ... +++` renders a two-column layout
46
46
  * Special heading subtitles with `## Heading # A subtitle here`
47
47
  * `{{{ _partial.md }}}` syntax for including partials
48
- - Automatically generated favicon
48
+ - Custom header logo from an image in `public/`
49
+ - Automatically generated favicon or custom ICO from `public/`
49
50
  * Text, color, font and font weight taken from config file
50
51
 
51
52
  ## Prerequisites
@@ -135,6 +136,9 @@ files in the `dist/` directory.
135
136
  ### `tada watch`
136
137
 
137
138
  Start a development web server, watch for changes and rebuild automatically.
139
+ Exactly one `site.dev.yaml`, `site.dev.yml`, or `site.dev.json` must exist
140
+ before starting; otherwise, correct the configuration files and restart the
141
+ command. Once watching, build errors can be fixed without restarting.
138
142
 
139
143
  ### `tada clean`
140
144
 
@@ -264,16 +268,18 @@ vars:
264
268
  | `title` | Title for the whole site (also used to derive `titlePostfix`) |
265
269
  | `titlePostfix` | *Optional*, the string to append to each page's `title` |
266
270
  | `banner` | *Optional*, Markdown rendered in a bordered box above every page |
267
- | `symbol` | Text (1-5 chars) displayed in header (also used as the favicon symbol) |
271
+ | `symbol` | Text (1-5 chars) displayed in header when `logo` is absent; also used for generated favicons |
272
+ | `logo` | *Optional*, image path relative to `public/`, replacing the header symbol |
273
+ | `favicon` | *Optional*, `.ico` path relative to `public/`, replacing generated favicons and the web app manifest |
268
274
  | `themeColor` | Theme color for the site (e.g., `"tomato"`, `"#c04040"`, `"hsl(195 70% 40%)"`) |
269
275
  | `tintHue` | *Optional*, hue (0-360) for background and foreground tinting (default `20`) |
270
- | `tintAmount` | *Optional*, percentage (0-100) of tint to apply (default `100`) |
276
+ | `tintAmount` | *Optional*, percentage (0-100) of tint to apply to the neutral palette (default `100`); link saturation stays fixed |
271
277
  | `defaultFont` | *Optional*, initial font pairing: `sans` or `serif` (default `sans`) |
272
278
  | `defaultContrast` | *Optional*, initial neutral-palette contrast: `standard` or `high` (default `standard`) |
273
279
  | `fontOverrides` | *Optional*, custom serif and serif-monospace WOFF2 faces from `public/`; each configured family requires `regular` and may include styled faces, OpenType `features`, and font-specific `tuning` |
274
- | `faviconSymbol` | *Optional*, the text to use instead of `symbol` in the favicon |
280
+ | `faviconSymbol` | *Optional*, the text to use instead of `symbol` in generated favicons |
275
281
  | `features.search` | Enable search UI and Pagefind index generation |
276
- | `features.favicon` | Enable automatically generated favicons |
282
+ | `features.favicon` | Enable favicon links and generation (default `true`); `false` also omits custom favicon links |
277
283
  | `features.footer` | Show the Tada footer at the bottom of every page |
278
284
  | `features.pickers` | Show the font and contrast pickers at the bottom of every page (default `true`) |
279
285
  | `base` | Full base URL of the deployed site, used for metadata and URL generation |
@@ -281,10 +287,38 @@ vars:
281
287
  | `internalDomains` | Domain names treated as internal by link processing (not marked external) |
282
288
  | `extensionToShikiLanguage` | *Optional*, map from source-file extension to the Shiki language used for generated code pages and copied-source processing |
283
289
  | `shikiLanguages` | *Optional*, list of Shiki languages permitted in code blocks |
284
- | `faviconColor` | *Optional*, background color for favicon (defaults to `themeColor`) |
285
- | `faviconFontWeight` | *Optional*, font weight used for favicon text (default `700`) |
290
+ | `faviconColor` | *Optional*, background color for generated favicons (defaults to `themeColor`) |
291
+ | `faviconFontWeight` | *Optional*, font weight used for generated favicon text (default `700`) |
286
292
  | `vars` | Arbitrary key/value variables exposed to templates/content as `vars.*` (e.g., `<%= vars.staffEmail %>`) |
287
293
 
294
+ ### Custom logo and favicon
295
+
296
+ Place your assets in `public/` and configure their relative paths:
297
+
298
+ ```yaml
299
+ logo: branding/logo.svg
300
+ favicon: branding/favicon.ico
301
+ symbol: TADA
302
+ ```
303
+
304
+ Here the files are `public/branding/logo.svg` and
305
+ `public/branding/favicon.ico`. Both fields are optional and independent. The
306
+ logo replaces the header symbol and works without JavaScript. Without a custom
307
+ favicon, Tada generates favicons from `faviconSymbol` or `symbol`, never from
308
+ the logo image. A custom favicon replaces all generated icon assets, the web
309
+ app manifest, and related web app metadata; `symbol` can be omitted when both
310
+ logo and favicon are supplied.
311
+
312
+ Paths must use forward slashes and cannot contain absolute paths, empty
313
+ segments, `.` or `..` segments, query strings, or fragments. Assets must exist
314
+ and be readable. The favicon must end in `.ico`; logo extensions are unrestricted,
315
+ so choose a browser-supported image format. Asset URLs encode filenames and
316
+ respect `basePath`, and files are copied unchanged.
317
+
318
+ `features.favicon: false` omits all favicon links and generation. Configured
319
+ assets are still validated and public files still copy normally. See
320
+ [Header logo](spec/logo.md) and [Favicons](spec/favicons.md) for details.
321
+
288
322
  #### `nav.yaml`
289
323
 
290
324
  Defines the site navigation structure. The file contains an array of section
package/bin/tada.ts CHANGED
@@ -10,6 +10,8 @@ import {
10
10
  validateHue,
11
11
  validateUrl,
12
12
  validateBasePath,
13
+ validateTimeZone,
14
+ resolveDefaultTimeZone,
13
15
  createSiteConfig,
14
16
  } from './validators';
15
17
  import {
@@ -28,7 +30,9 @@ import {
28
30
 
29
31
  const { version } = packageJson;
30
32
 
31
- const SYSTEM_TIME_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
33
+ const DEFAULT_TIME_ZONE = resolveDefaultTimeZone(
34
+ Intl.DateTimeFormat().resolvedOptions().timeZone,
35
+ );
32
36
 
33
37
  const packageDir = path.resolve(import.meta.dir, '..');
34
38
 
@@ -102,8 +106,8 @@ const INIT_QUESTIONS: Record<string, InitQuestion> = {
102
106
  },
103
107
  defaultTimeZone: {
104
108
  prompt: 'Default time zone',
105
- defaultValue: SYSTEM_TIME_ZONE,
106
- validate: (v: string) => (v ? null : 'Time zone is required'),
109
+ defaultValue: DEFAULT_TIME_ZONE.timeZone,
110
+ validate: validateTimeZone,
107
111
  },
108
112
  prodBase: {
109
113
  prompt: 'Production base URL',
@@ -218,10 +222,8 @@ async function initCommand(args: string[]): Promise<void> {
218
222
  type: 'string',
219
223
  default: INIT_QUESTIONS.tintAmount.defaultValue,
220
224
  },
221
- 'default-time-zone': {
222
- type: 'string',
223
- default: INIT_QUESTIONS.defaultTimeZone.defaultValue,
224
- },
225
+ // No parseArgs default, so an omitted flag can be told apart
226
+ 'default-time-zone': { type: 'string' },
225
227
  'prod-base': {
226
228
  type: 'string',
227
229
  default: INIT_QUESTIONS.prodBase.defaultValue,
@@ -268,6 +270,13 @@ async function initCommand(args: string[]): Promise<void> {
268
270
 
269
271
  const config: Record<string, string> = {};
270
272
 
273
+ const usesDefaultTimeZone =
274
+ !noInteractive || values['default-time-zone'] === undefined;
275
+ if (usesDefaultTimeZone && DEFAULT_TIME_ZONE.note) {
276
+ console.log(DEFAULT_TIME_ZONE.note);
277
+ }
278
+ values['default-time-zone'] ??= DEFAULT_TIME_ZONE.timeZone;
279
+
271
280
  if (noInteractive) {
272
281
  for (const [flag, key] of Object.entries(FLAG_TO_KEY)) {
273
282
  const value = values[flag] as string;
@@ -536,7 +545,9 @@ function diffCommand(args: string[]): void {
536
545
 
537
546
  if (totalChanges === 0) {
538
547
  console.log('\nNo changes between builds.');
539
- return;
548
+ if (copyIdx === -1) {
549
+ return;
550
+ }
540
551
  }
541
552
 
542
553
  if (diff.added.length > 0) {
@@ -559,7 +570,9 @@ function diffCommand(args: string[]): void {
559
570
  }
560
571
 
561
572
  const noun = (n: number) => (n === 1 ? 'file' : 'files');
562
- console.log(`\nTotal: ${totalChanges} ${noun(totalChanges)} differ`);
573
+ if (totalChanges > 0) {
574
+ console.log(`\nTotal: ${totalChanges} ${noun(totalChanges)} differ`);
575
+ }
563
576
 
564
577
  if (copyIdx !== -1) {
565
578
  const outDirArg = args[copyIdx + 1];
@@ -570,6 +583,7 @@ function diffCommand(args: string[]): void {
570
583
 
571
584
  const newDistDir = path.join(prodBase, `v${newVer}`);
572
585
  const resolvedOutDir = path.resolve(projectDir, outDirArg);
586
+ fs.mkdirSync(resolvedOutDir, { recursive: true });
573
587
  copyChangedFiles(diff, newDistDir, resolvedOutDir);
574
588
 
575
589
  // Always include pagefind/ (excluded from manifest but needed for search)
package/bin/validators.ts CHANGED
@@ -1,5 +1,9 @@
1
+ import timezones from '../src/timezone/timezones.json' with { type: 'json' };
1
2
  import type { SiteConfigInput, SiteVariables } from '../build/types';
2
3
 
4
+ const SUPPORTED_TIME_ZONES: readonly string[] = timezones.map(tz => tz.value);
5
+ const FALLBACK_TIME_ZONE = 'UTC';
6
+
3
7
  export function validateSymbol(value: string): string | null {
4
8
  if (!value) {
5
9
  return 'Symbol is required';
@@ -55,6 +59,35 @@ export function validateBasePath(value: string): string | null {
55
59
  return null;
56
60
  }
57
61
 
62
+ export function validateTimeZone(value: string): string | null {
63
+ if (!value) {
64
+ return 'Time zone is required';
65
+ }
66
+ if (!SUPPORTED_TIME_ZONES.includes(value)) {
67
+ return `Must be one of the supported time zones: ${SUPPORTED_TIME_ZONES.join(', ')}`;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * Picks the default time zone for `tada init`: the system zone when builds
74
+ * support it, otherwise UTC with a note explaining the fallback.
75
+ */
76
+ export function resolveDefaultTimeZone(systemTimeZone: string | undefined): {
77
+ timeZone: string;
78
+ note: string | null;
79
+ } {
80
+ if (systemTimeZone && validateTimeZone(systemTimeZone) === null) {
81
+ return { timeZone: systemTimeZone, note: null };
82
+ }
83
+ return {
84
+ timeZone: FALLBACK_TIME_ZONE,
85
+ note: systemTimeZone
86
+ ? `System time zone ${systemTimeZone} is not supported; using ${FALLBACK_TIME_ZONE}`
87
+ : `System time zone is unknown; using ${FALLBACK_TIME_ZONE}`,
88
+ };
89
+ }
90
+
58
91
  export function createSiteConfig({
59
92
  title,
60
93
  symbol,
@@ -83,7 +116,7 @@ export function createSiteConfig({
83
116
  extensionToShikiLanguage,
84
117
  shikiLanguages,
85
118
  themeColor,
86
- tintHue: Number(tintHue),
119
+ tintHue: Number(String(tintHue).replace(/deg$/, '')),
87
120
  tintAmount: Number(tintAmount),
88
121
  vars: { foobar: 123 },
89
122
  };
@@ -0,0 +1,41 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { isValidPublicAssetPath } from './custom-fonts';
4
+ import type { SiteVariables } from './types';
5
+
6
+ export function validateBranding(
7
+ site: Pick<SiteVariables, 'logo' | 'favicon'>,
8
+ options: {
9
+ publicDir: string;
10
+ publicFiles: ReadonlySet<string>;
11
+ readFile?: (filePath: string) => Buffer;
12
+ },
13
+ ): string[] {
14
+ const diagnostics: string[] = [];
15
+ for (const field of ['logo', 'favicon'] as const) {
16
+ const value = site[field];
17
+ if (value === undefined) {
18
+ continue;
19
+ }
20
+ if (
21
+ !isValidPublicAssetPath(value) ||
22
+ (field === 'favicon' && !value.endsWith('.ico'))
23
+ ) {
24
+ diagnostics.push(
25
+ `${field} "${value}" must be a public-relative POSIX path${field === 'favicon' ? ' ending in .ico' : ''}`,
26
+ );
27
+ continue;
28
+ }
29
+ const absolutePath = path.resolve(options.publicDir, ...value.split('/'));
30
+ if (!options.publicFiles.has(absolutePath)) {
31
+ diagnostics.push(`${field} "${value}" does not exist in public/`);
32
+ continue;
33
+ }
34
+ try {
35
+ (options.readFile ?? fs.readFileSync)(absolutePath);
36
+ } catch {
37
+ diagnostics.push(`${field} "${value}" could not be read`);
38
+ }
39
+ }
40
+ return diagnostics;
41
+ }
@@ -1,10 +1,11 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { globals, type Globals } from './globals';
4
- import { toPosix } from './utils/paths';
4
+ import { SEARCH_INDEX_DIR, toPosix } from './utils/paths';
5
5
 
6
- const EXCLUDED_DIRS = new Set(['pagefind']);
7
- const EXCLUDED_FILES = new Set(['tada.manifest.json']);
6
+ const EXCLUDED_DIRS = new Set([SEARCH_INDEX_DIR]);
7
+ export const MANIFEST_FILE_NAME = 'tada.manifest.json';
8
+ const EXCLUDED_FILES = new Set([MANIFEST_FILE_NAME]);
8
9
 
9
10
  type ManifestGlobals = Pick<
10
11
  Globals,
@@ -85,7 +86,7 @@ export async function walkAndHash(
85
86
  if (EXCLUDED_DIRS.has(topLevel)) {
86
87
  continue;
87
88
  }
88
- if (EXCLUDED_FILES.has(entry.name)) {
89
+ if (EXCLUDED_FILES.has(rel)) {
89
90
  continue;
90
91
  }
91
92
 
@@ -109,7 +110,11 @@ export function getVersions(prodBaseDir: string): number[] {
109
110
  continue;
110
111
  }
111
112
  const match = entry.name.match(/^v(\d+)$/);
112
- if (match) {
113
+ // A version exists only once its manifest, written last, is in place.
114
+ if (
115
+ match &&
116
+ fs.existsSync(path.join(prodBaseDir, entry.name, MANIFEST_FILE_NAME))
117
+ ) {
113
118
  versions.push(parseInt(match[1], 10));
114
119
  }
115
120
  }
@@ -1,12 +1,14 @@
1
1
  import type {
2
2
  HtmlOutputAnalysis,
3
3
  SiteVariables,
4
- TraceToolAvailability,
5
- } from '../types';
4
+ TraceArtifactFile,
5
+ } from './types';
6
6
 
7
+ /** A finished trace, keyed in the cache by its traced source files */
7
8
  export interface TraceCacheEntry {
8
- manifestUrl: string;
9
9
  artifactId: string;
10
+ /** Manifest and chunk files, re-emitted on a cache hit */
11
+ files: TraceArtifactFile[];
10
12
  highlightedSources: { file: string; highlightedSource: string }[];
11
13
  totalSteps: number;
12
14
  sourceMtims: Record<string, number>;
@@ -17,9 +19,10 @@ export type TraceCache = Map<string, TraceCacheEntry>;
17
19
  export interface TadaBuildMeta {
18
20
  htmlAssetsByPath: Map<string, string>;
19
21
  htmlAnalysisByPath: Map<string, HtmlOutputAnalysis>;
22
+ pdfSourceByOutputPath: Map<string, string>;
20
23
  siteVariables: SiteVariables;
21
24
  }
22
25
 
23
- export interface WatchTraceOptions {
24
- toolAvailability: TraceToolAvailability;
26
+ export interface BuildDiagnostic {
27
+ message: string;
25
28
  }
@@ -0,0 +1,176 @@
1
+ import path from 'path';
2
+ import { validateBranding } from './branding';
3
+ import { makeLogger } from './log';
4
+ import { config, getConfigFileName } from './templates';
5
+ import { validateConfigLinks } from './validate-config-links';
6
+ import { validateCustomFontOverrides } from './custom-fonts';
7
+ import { getProjectDir, SEARCH_INDEX_DIR, toPosix } from './utils/paths';
8
+ import { sameOutputContent } from './output-publication';
9
+ import { isFeatureEnabled } from './features';
10
+ import type { OutputContent, SiteVariables } from './types';
11
+ import type { BuildDiagnostic } from './build-types';
12
+ import type { TadaSourceRecord } from './source-records';
13
+ import {
14
+ assertNoOutputPathConflicts,
15
+ sourcePaths,
16
+ type TadaProjectScan,
17
+ } from './source-model';
18
+
19
+ const log = makeLogger(import.meta.url);
20
+
21
+ /** Thrown after a build's errors have been printed */
22
+ export class BuildFailedError extends Error {}
23
+
24
+ function diagnosticsFromMessages(messages: string[]): BuildDiagnostic[] {
25
+ return messages.map(message => ({ message }));
26
+ }
27
+
28
+ export function diagnosticFromError(error: unknown): BuildDiagnostic {
29
+ return { message: error instanceof Error ? error.message : String(error) };
30
+ }
31
+
32
+ function escapeRegExp(text: string): string {
33
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
34
+ }
35
+
36
+ /** Shows absolute paths inside the project as project-relative POSIX paths. */
37
+ function formatDiagnostic(
38
+ message: string,
39
+ projectDir: string = getProjectDir(),
40
+ ): string {
41
+ const pattern = new RegExp(
42
+ `${escapeRegExp(projectDir + path.sep)}([^\\s:,"()]*)`,
43
+ 'g',
44
+ );
45
+ return message.replace(pattern, (_, relPath: string) => toPosix(relPath));
46
+ }
47
+
48
+ /** Prints each diagnostic once, as one line. */
49
+ export function printDiagnostics(
50
+ diagnostics: readonly BuildDiagnostic[],
51
+ ): void {
52
+ for (const diagnostic of diagnostics) {
53
+ log.error`${formatDiagnostic(diagnostic.message)}`;
54
+ }
55
+ }
56
+
57
+ function projectRelative(scan: TadaProjectScan, sourcePath: string): string {
58
+ return toPosix(path.relative(path.dirname(scan.contentDir), sourcePath));
59
+ }
60
+
61
+ export function validateConfig(
62
+ scan: TadaProjectScan,
63
+ siteVariables: SiteVariables,
64
+ ): BuildDiagnostic[] {
65
+ const publicFiles = new Set(sourcePaths(scan, 'public'));
66
+ const diagnostics = diagnosticsFromMessages([
67
+ ...validateCustomFontOverrides({
68
+ fontOverrides: siteVariables.fontOverrides,
69
+ publicDir: scan.publicDir,
70
+ publicFiles,
71
+ }),
72
+ ...validateBranding(siteVariables, {
73
+ publicDir: scan.publicDir,
74
+ publicFiles,
75
+ }),
76
+ ]);
77
+ for (const outputPath of assertNoOutputPathConflicts(scan)) {
78
+ const sources = [...scan.outputProducers.get(outputPath)!]
79
+ .map(sourcePath => projectRelative(scan, sourcePath))
80
+ .sort();
81
+ const verb = sources.length === 2 ? 'both write' : 'all write';
82
+ diagnostics.push({
83
+ message: `${sources.join(', ')}: ${verb} ${outputPath}`,
84
+ });
85
+ }
86
+ return diagnostics;
87
+ }
88
+
89
+ /**
90
+ * Output conflicts that can only be found after rendering: a source writing a
91
+ * file Tada generates, a file in the search index directory, or a path that
92
+ * would have to be both a file and a directory. Two sources writing the same
93
+ * path are found earlier by validateConfig.
94
+ */
95
+ export function findOutputConflicts(
96
+ scan: TadaProjectScan,
97
+ siteVariables: SiteVariables,
98
+ generatedOutputs: ReadonlyMap<string, OutputContent>,
99
+ records: ReadonlyMap<string, TadaSourceRecord>,
100
+ ): BuildDiagnostic[] {
101
+ const searchEnabled = isFeatureEnabled(siteVariables, 'search');
102
+ const messages = new Set<string>();
103
+ // Output path to the project-relative source that writes it (null for Tada)
104
+ // and the content it writes.
105
+ const owners = new Map<
106
+ string,
107
+ { source: string | null; content: OutputContent }
108
+ >(
109
+ [...generatedOutputs].map(([outputPath, content]) => [
110
+ outputPath,
111
+ { source: null, content },
112
+ ]),
113
+ );
114
+ for (const record of records.values()) {
115
+ const source = projectRelative(scan, record.sourcePath);
116
+ for (const [outputPath, content] of record.outputs) {
117
+ const existing = owners.get(outputPath);
118
+ if (existing?.source === null) {
119
+ messages.add(
120
+ `${source}: conflicts with ${outputPath} generated by Tada`,
121
+ );
122
+ } else if (
123
+ searchEnabled &&
124
+ (outputPath === SEARCH_INDEX_DIR ||
125
+ outputPath.startsWith(`${SEARCH_INDEX_DIR}/`))
126
+ ) {
127
+ messages.add(
128
+ `${source}: conflicts with the search index in ${SEARCH_INDEX_DIR}/`,
129
+ );
130
+ } else if (!existing) {
131
+ owners.set(outputPath, { source, content });
132
+ } else if (
133
+ existing.source !== source &&
134
+ !sameOutputContent(existing.content, content)
135
+ ) {
136
+ // Pages that trace the same files share identical trace outputs.
137
+ messages.add(
138
+ `${[existing.source, source].sort().join(', ')}: both write ${outputPath}`,
139
+ );
140
+ }
141
+ }
142
+ }
143
+ for (const [outputPath, { source: owner }] of owners) {
144
+ const segments = outputPath.split('/');
145
+ for (let i = 1; i < segments.length; i++) {
146
+ const ancestor = segments.slice(0, i).join('/');
147
+ if (!owners.has(ancestor)) {
148
+ continue;
149
+ }
150
+ const ancestorOwner = owners.get(ancestor)!.source;
151
+ if (ancestorOwner === null) {
152
+ messages.add(`${owner}: conflicts with ${ancestor} generated by Tada`);
153
+ } else if (owner === null) {
154
+ messages.add(
155
+ `${ancestorOwner}: conflicts with ${ancestor}/ generated by Tada`,
156
+ );
157
+ } else {
158
+ messages.add(
159
+ `${[ancestorOwner, owner].sort().join(', ')}: ${ancestor} is both a file and a directory`,
160
+ );
161
+ }
162
+ }
163
+ }
164
+ return diagnosticsFromMessages([...messages]);
165
+ }
166
+
167
+ export function validateProjectConfigLinks(
168
+ validTargets: ReadonlySet<string>,
169
+ ): BuildDiagnostic[] {
170
+ return diagnosticsFromMessages(
171
+ validateConfigLinks(validTargets, config('nav'), config('authors'), {
172
+ navFileName: getConfigFileName('nav'),
173
+ authorsFileName: getConfigFileName('authors'),
174
+ }),
175
+ );
176
+ }