@abreen/tada 1.17.1 → 1.18.1

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 (111) hide show
  1. package/README.md +38 -7
  2. package/assets/material-symbols/chevron_right_24px.svg +1 -0
  3. package/bin/tada.ts +16 -7
  4. package/bin/validators.ts +33 -0
  5. package/build/branding.ts +41 -0
  6. package/build/build-manifest.ts +9 -4
  7. package/build/{watch/compiler-types.d.ts → build-types.d.ts} +8 -5
  8. package/build/build-validation.ts +176 -0
  9. package/build/bundle.ts +75 -65
  10. package/build/config-loader.ts +3 -3
  11. package/build/custom-fonts.ts +8 -5
  12. package/build/generate-favicon.ts +7 -6
  13. package/build/generate-fonts.ts +8 -15
  14. package/build/generate-katex-assets.ts +19 -17
  15. package/build/generate-web-app-manifest.ts +3 -11
  16. package/build/lodash-template.ts +14 -0
  17. package/build/material-symbols.ts +7 -4
  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 -244
  25. package/build/source-records.ts +69 -81
  26. package/build/template-globals.ts +3 -0
  27. package/build/templates.ts +5 -2
  28. package/build/toc-plugin.ts +21 -21
  29. package/build/types.d.ts +44 -38
  30. package/build/util.ts +0 -4
  31. package/build/utils/code.ts +38 -101
  32. package/build/utils/final-html.ts +171 -47
  33. package/build/utils/literate-java.ts +11 -7
  34. package/build/utils/markdown-partials.ts +4 -3
  35. package/build/utils/markdown.ts +6 -17
  36. package/build/utils/paths.ts +3 -0
  37. package/build/utils/plain-text.ts +18 -0
  38. package/build/utils/render.ts +227 -165
  39. package/build/utils/shiki-highlighter.ts +11 -0
  40. package/build/utils/source-template.ts +2 -2
  41. package/build/utils/trace-core.ts +8 -83
  42. package/build/utils/trace.ts +38 -95
  43. package/build/validate-config-links.ts +60 -20
  44. package/build/watch/build-incremental.ts +46 -103
  45. package/build/watch/compiler.ts +158 -37
  46. package/build/watch/engine.ts +207 -176
  47. package/build/watch/index.ts +9 -2
  48. package/build/watch/planner.ts +88 -144
  49. package/build/watch/runtime.ts +21 -34
  50. package/build/watch/types.d.ts +32 -64
  51. package/init/content/labs/00/VowelCounter.java.md +5 -2
  52. package/init/content/labs/00/index.md +3 -2
  53. package/init/content/labs/01/index.md +3 -2
  54. package/init/content/labs/02/index.md +3 -2
  55. package/init/content/lectures/01/index.md +3 -2
  56. package/init/content/lectures/02/index.md +3 -2
  57. package/package.json +4 -4
  58. package/schema/site.schema.json +18 -2
  59. package/src/_content.scss +463 -0
  60. package/src/_fonts.scss +90 -0
  61. package/src/_layout.scss +50 -35
  62. package/src/{material-symbols.scss → _material-symbols.scss} +7 -0
  63. package/src/appearance-picker/{style.scss → _index.scss} +1 -1
  64. package/src/code/{style.scss → _index.scss} +61 -0
  65. package/src/header/{_base.scss → _index.scss} +98 -6
  66. package/src/index.ts +1 -17
  67. package/src/navigate/{style.scss → _index.scss} +29 -3
  68. package/src/navigate/breadcrumbs.ts +129 -0
  69. package/src/navigate/eligible.ts +0 -40
  70. package/src/navigate/index.ts +38 -4
  71. package/src/navigate/lifecycle.ts +19 -2
  72. package/src/navigate/runtime.ts +57 -22
  73. package/src/page-update/index.ts +33 -20
  74. package/src/print/{style.scss → _index.scss} +3 -1
  75. package/src/search/index.ts +7 -0
  76. package/src/slides/{style.scss → _index.scss} +5 -0
  77. package/src/style.scss +24 -536
  78. package/src/toc/index.ts +2 -2
  79. package/templates/_author.html +2 -2
  80. package/templates/_download.html +1 -1
  81. package/templates/_footer.html +1 -1
  82. package/templates/_heading.html +17 -4
  83. package/templates/_nav.html +2 -2
  84. package/templates/_page-bottom.html +6 -0
  85. package/templates/_top.html +18 -14
  86. package/templates/code.html +22 -21
  87. package/templates/default.html +12 -11
  88. package/templates/literate.html +6 -5
  89. package/assets/material-symbols/south_east_20px.svg +0 -1
  90. package/build/copy.ts +0 -82
  91. package/build/generate-content-assets.ts +0 -121
  92. package/build/utils/content-files.ts +0 -170
  93. package/build/watch/assets.ts +0 -103
  94. package/build/watch/build-full.ts +0 -97
  95. package/build/watch/build-helpers.ts +0 -66
  96. package/build/watch/build-result.ts +0 -19
  97. package/build/watch/fs-commit.ts +0 -218
  98. package/build/watch/mutations.ts +0 -73
  99. package/build/watch/snapshot.ts +0 -201
  100. package/build/watch/validation.ts +0 -57
  101. package/src/code.scss +0 -60
  102. package/src/critical.scss +0 -5
  103. package/src/header/style.scss +0 -91
  104. /package/src/{literate.scss → _literate.scss} +0 -0
  105. /package/src/anchor/{style.scss → _index.scss} +0 -0
  106. /package/src/page-update/{style.scss → _index.scss} +0 -0
  107. /package/src/question/{style.scss → _index.scss} +0 -0
  108. /package/src/search/{style.scss → _index.scss} +0 -0
  109. /package/src/timezone/{style.scss → _index.scss} +0 -0
  110. /package/src/toc/{style.scss → _index.scss} +0 -0
  111. /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
@@ -267,16 +268,18 @@ vars:
267
268
  | `title` | Title for the whole site (also used to derive `titlePostfix`) |
268
269
  | `titlePostfix` | *Optional*, the string to append to each page's `title` |
269
270
  | `banner` | *Optional*, Markdown rendered in a bordered box above every page |
270
- | `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 |
271
274
  | `themeColor` | Theme color for the site (e.g., `"tomato"`, `"#c04040"`, `"hsl(195 70% 40%)"`) |
272
275
  | `tintHue` | *Optional*, hue (0-360) for background and foreground tinting (default `20`) |
273
276
  | `tintAmount` | *Optional*, percentage (0-100) of tint to apply to the neutral palette (default `100`); link saturation stays fixed |
274
277
  | `defaultFont` | *Optional*, initial font pairing: `sans` or `serif` (default `sans`) |
275
278
  | `defaultContrast` | *Optional*, initial neutral-palette contrast: `standard` or `high` (default `standard`) |
276
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` |
277
- | `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 |
278
281
  | `features.search` | Enable search UI and Pagefind index generation |
279
- | `features.favicon` | Enable automatically generated favicons |
282
+ | `features.favicon` | Enable favicon links and generation (default `true`); `false` also omits custom favicon links |
280
283
  | `features.footer` | Show the Tada footer at the bottom of every page |
281
284
  | `features.pickers` | Show the font and contrast pickers at the bottom of every page (default `true`) |
282
285
  | `base` | Full base URL of the deployed site, used for metadata and URL generation |
@@ -284,10 +287,38 @@ vars:
284
287
  | `internalDomains` | Domain names treated as internal by link processing (not marked external) |
285
288
  | `extensionToShikiLanguage` | *Optional*, map from source-file extension to the Shiki language used for generated code pages and copied-source processing |
286
289
  | `shikiLanguages` | *Optional*, list of Shiki languages permitted in code blocks |
287
- | `faviconColor` | *Optional*, background color for favicon (defaults to `themeColor`) |
288
- | `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`) |
289
292
  | `vars` | Arbitrary key/value variables exposed to templates/content as `vars.*` (e.g., `<%= vars.staffEmail %>`) |
290
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
+
291
322
  #### `nav.yaml`
292
323
 
293
324
  Defines the site navigation structure. The file contains an array of section
@@ -352,7 +383,7 @@ list of variables parsed using the [`front-matter`][front-matter] library).
352
383
  | `description` | Meta description for the page |
353
384
  | `toc` | Set to `true` to show a table of contents |
354
385
  | `slides` | Set to `true` on Markdown pages to treat top-level `---` as slide separators and add presentation controls |
355
- | `parent` & `parentLabel` | URL and label for a breadcrumb link displayed above the title |
386
+ | `breadcrumbs` | Ordered ancestor links with `label` and `url`; the page title completes the trail |
356
387
  | `published` | Year, month, and day of publishing (e.g, `2025-09-09`) |
357
388
 
358
389
  You may also add arbitrary fields in a page's front matter, and access them
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M504-480 320-664l56-56 240 240-240 240-56-56 184-184Z"/></svg>
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;
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,
@@ -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,
@@ -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
+ }
package/build/bundle.ts CHANGED
@@ -1,14 +1,8 @@
1
1
  import fs from 'fs';
2
- import os from 'os';
3
2
  import path from 'path';
4
- import _ from 'lodash';
3
+ import { compileTemplate } from './lodash-template';
5
4
  import * as sass from 'sass';
6
- import {
7
- getPackageDir,
8
- getProjectDir,
9
- getDistDir,
10
- toPosix,
11
- } from './utils/paths';
5
+ import { getPackageDir, toPosix } from './utils/paths';
12
6
  import {
13
7
  deriveTheme,
14
8
  deriveLinkHue,
@@ -40,7 +34,7 @@ interface CoverageGlobal {
40
34
  __tadaCoverage?: CoverageHooks;
41
35
  }
42
36
 
43
- export function getBundleNaming(): string {
37
+ function getBundleNaming(): string {
44
38
  const version = pkg.version.replace(/[^a-zA-Z0-9.-]/g, '-');
45
39
  return `[name].bundle.tada-${version}.[ext]`;
46
40
  }
@@ -53,6 +47,7 @@ function formatCssNumber(value: number, precision = 4): string {
53
47
  return value.toFixed(precision).replace(/\.?0+$/, '');
54
48
  }
55
49
 
50
+ // Returns the rendered SCSS source of the theme module (`config/theme`)
56
51
  function renderThemeScss(siteVariables: SiteVariables): string {
57
52
  const templatePath = path.join(getPackageDir(), 'templates/_theme.scss');
58
53
  const template = fs.readFileSync(templatePath, 'utf-8');
@@ -74,7 +69,7 @@ function renderThemeScss(siteVariables: SiteVariables): string {
74
69
  siteVariables.fontOverrides,
75
70
  );
76
71
 
77
- const renderedTemplate = _.template(template)({
72
+ const renderedTemplate = compileTemplate(template)({
78
73
  ...theme,
79
74
  tintHue,
80
75
  tintAmount,
@@ -98,12 +93,7 @@ function renderThemeScss(siteVariables: SiteVariables): string {
98
93
  .replace('/* TADA_CUSTOM_FONT_TUNING */', customFontTuning)
99
94
  .replace('/* TADA_MATERIAL_SYMBOL_VARIABLES */', materialSymbolVariables);
100
95
 
101
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tada-'));
102
- const configDir = path.join(tmpDir, 'config');
103
- fs.mkdirSync(configDir);
104
- fs.writeFileSync(path.join(configDir, '_theme.scss'), rendered);
105
-
106
- return tmpDir;
96
+ return rendered;
107
97
  }
108
98
 
109
99
  function createDefine(
@@ -119,14 +109,31 @@ function createDefine(
119
109
  };
120
110
  }
121
111
 
122
- function createScssPlugin(themeDir: string) {
112
+ const THEME_MODULE_URL = 'config/theme';
113
+ const THEME_CANONICAL_URL = 'tada:config/theme';
114
+
115
+ // Serves the rendered theme from memory to `@use 'config/theme'`
116
+ function createThemeImporter(themeScss: string): sass.Importer<'sync'> {
117
+ return {
118
+ canonicalize(url) {
119
+ return url === THEME_MODULE_URL ? new URL(THEME_CANONICAL_URL) : null;
120
+ },
121
+ load(canonicalUrl) {
122
+ if (canonicalUrl.href !== THEME_CANONICAL_URL) {
123
+ return null;
124
+ }
125
+ return { contents: themeScss, syntax: 'scss' };
126
+ },
127
+ };
128
+ }
129
+
130
+ function createScssPlugin(themeScss: string) {
131
+ const importers = [createThemeImporter(themeScss)];
123
132
  return {
124
133
  name: 'scss',
125
134
  setup(build: PluginBuilder) {
126
135
  build.onLoad({ filter: /\.scss$/ }, args => {
127
- const result = sass.compile(args.path, {
128
- loadPaths: [themeDir, getProjectDir()],
129
- });
136
+ const result = sass.compile(args.path, { importers });
130
137
  return { contents: result.css, loader: 'css' as const };
131
138
  });
132
139
  },
@@ -139,55 +146,58 @@ function getCoverageBundlePlugin(): BunBuildPlugin | null {
139
146
  );
140
147
  }
141
148
 
142
- export async function bundle(
149
+ async function buildToMemory(
150
+ options: Parameters<typeof Bun.build>[0],
151
+ ): Promise<Map<string, string>> {
152
+ const result = await Bun.build(options);
153
+ if (!result.success) {
154
+ const messages = result.logs
155
+ .filter(log => log.level === 'error')
156
+ .map(log => log.message || String(log));
157
+ throw new Error(`Bundle failed:\n${messages.join('\n')}`);
158
+ }
159
+ const outputs = new Map<string, string>();
160
+ for (const output of result.outputs) {
161
+ outputs.set(
162
+ path.posix.normalize(toPosix(output.path)),
163
+ await output.text(),
164
+ );
165
+ }
166
+ return outputs;
167
+ }
168
+
169
+ /** Bundles Tada's client CSS and JavaScript, keyed by output path */
170
+ export function bundle(
143
171
  siteVariables: SiteVariables,
144
- { mode = 'development', distDir }: { mode?: string; distDir?: string } = {},
145
- ): Promise<string[]> {
146
- const packageDir = getPackageDir();
147
- const resolvedDistDir = distDir ?? getDistDir();
172
+ { mode = 'development' }: { mode?: string } = {},
173
+ ): Promise<Map<string, string>> {
148
174
  const isDev = mode === 'development';
175
+ const plugins = [createScssPlugin(renderThemeScss(siteVariables))];
176
+ const coverageBundlePlugin = getCoverageBundlePlugin();
177
+ if (coverageBundlePlugin) {
178
+ plugins.push(coverageBundlePlugin);
179
+ }
149
180
 
150
- const entrypoints = [
151
- path.resolve(packageDir, 'src/index.ts'),
152
- path.resolve(packageDir, 'src/critical.scss'),
153
- ];
154
-
155
- const themeDir = renderThemeScss(siteVariables);
156
-
157
- try {
158
- const plugins = [createScssPlugin(themeDir)];
159
- const coverageBundlePlugin = getCoverageBundlePlugin();
160
- if (coverageBundlePlugin) {
161
- plugins.push(coverageBundlePlugin);
162
- }
163
-
164
- const result = await Bun.build({
165
- entrypoints,
166
- outdir: resolvedDistDir,
167
- naming: getBundleNaming(),
168
- minify: mode === 'production',
169
- sourcemap: isDev ? 'inline' : 'none',
170
- define: createDefine(siteVariables, isDev),
171
- external: ['*.woff2'],
172
- plugins,
173
- });
174
-
175
- if (!result.success) {
176
- const messages = result.logs
177
- .filter(log => log.level === 'error')
178
- .map(log => log.message || String(log));
179
- throw new Error(`Bundle failed:\n${messages.join('\n')}`);
180
- }
181
-
182
- // Return the output filenames for asset tag injection
183
- const assetFiles = result.outputs.map(output =>
184
- toPosix(path.relative(resolvedDistDir, output.path)),
185
- );
181
+ return buildToMemory({
182
+ entrypoints: [path.resolve(getPackageDir(), 'src/index.ts')],
183
+ naming: getBundleNaming(),
184
+ minify: mode === 'production',
185
+ sourcemap: isDev ? 'inline' : 'none',
186
+ define: createDefine(siteVariables, isDev),
187
+ external: ['*.woff2'],
188
+ plugins,
189
+ });
190
+ }
186
191
 
187
- return assetFiles;
188
- } finally {
189
- fs.rmSync(themeDir, { recursive: true, force: true });
190
- }
192
+ /** Bundles the watch-mode reload client, keyed by output path */
193
+ export function bundleReloadClient(): Promise<Map<string, string>> {
194
+ return buildToMemory({
195
+ entrypoints: [
196
+ path.resolve(getPackageDir(), 'build/watch-reload-client.ts'),
197
+ ],
198
+ naming: getBundleNaming(),
199
+ sourcemap: 'inline',
200
+ });
191
201
  }
192
202
 
193
203
  export { renderThemeScss };