@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.
- package/README.md +38 -7
- package/assets/material-symbols/chevron_right_24px.svg +1 -0
- package/bin/tada.ts +16 -7
- package/bin/validators.ts +33 -0
- package/build/branding.ts +41 -0
- package/build/build-manifest.ts +9 -4
- package/build/{watch/compiler-types.d.ts → build-types.d.ts} +8 -5
- package/build/build-validation.ts +176 -0
- package/build/bundle.ts +75 -65
- package/build/config-loader.ts +3 -3
- package/build/custom-fonts.ts +8 -5
- package/build/generate-favicon.ts +7 -6
- package/build/generate-fonts.ts +8 -15
- package/build/generate-katex-assets.ts +19 -17
- package/build/generate-web-app-manifest.ts +3 -11
- package/build/lodash-template.ts +14 -0
- package/build/material-symbols.ts +7 -4
- package/build/output-publication.ts +238 -0
- package/build/pagefind.ts +49 -21
- package/build/pdf-text.ts +41 -1
- package/build/pipeline.ts +95 -101
- package/build/site-assets.ts +46 -0
- package/build/site-build.ts +267 -0
- package/build/source-model.ts +223 -244
- package/build/source-records.ts +69 -81
- package/build/template-globals.ts +3 -0
- package/build/templates.ts +5 -2
- package/build/toc-plugin.ts +21 -21
- package/build/types.d.ts +44 -38
- package/build/util.ts +0 -4
- package/build/utils/code.ts +38 -101
- package/build/utils/final-html.ts +171 -47
- package/build/utils/literate-java.ts +11 -7
- package/build/utils/markdown-partials.ts +4 -3
- package/build/utils/markdown.ts +6 -17
- package/build/utils/paths.ts +3 -0
- package/build/utils/plain-text.ts +18 -0
- package/build/utils/render.ts +227 -165
- package/build/utils/shiki-highlighter.ts +11 -0
- package/build/utils/source-template.ts +2 -2
- package/build/utils/trace-core.ts +8 -83
- package/build/utils/trace.ts +38 -95
- package/build/validate-config-links.ts +60 -20
- package/build/watch/build-incremental.ts +46 -103
- package/build/watch/compiler.ts +158 -37
- package/build/watch/engine.ts +207 -176
- package/build/watch/index.ts +9 -2
- package/build/watch/planner.ts +88 -144
- package/build/watch/runtime.ts +21 -34
- package/build/watch/types.d.ts +32 -64
- package/init/content/labs/00/VowelCounter.java.md +5 -2
- package/init/content/labs/00/index.md +3 -2
- package/init/content/labs/01/index.md +3 -2
- package/init/content/labs/02/index.md +3 -2
- package/init/content/lectures/01/index.md +3 -2
- package/init/content/lectures/02/index.md +3 -2
- package/package.json +4 -4
- package/schema/site.schema.json +18 -2
- package/src/_content.scss +463 -0
- package/src/_fonts.scss +90 -0
- package/src/_layout.scss +50 -35
- package/src/{material-symbols.scss → _material-symbols.scss} +7 -0
- package/src/appearance-picker/{style.scss → _index.scss} +1 -1
- package/src/code/{style.scss → _index.scss} +61 -0
- package/src/header/{_base.scss → _index.scss} +98 -6
- package/src/index.ts +1 -17
- package/src/navigate/{style.scss → _index.scss} +29 -3
- package/src/navigate/breadcrumbs.ts +129 -0
- package/src/navigate/eligible.ts +0 -40
- package/src/navigate/index.ts +38 -4
- package/src/navigate/lifecycle.ts +19 -2
- package/src/navigate/runtime.ts +57 -22
- package/src/page-update/index.ts +33 -20
- package/src/print/{style.scss → _index.scss} +3 -1
- package/src/search/index.ts +7 -0
- package/src/slides/{style.scss → _index.scss} +5 -0
- package/src/style.scss +24 -536
- package/src/toc/index.ts +2 -2
- package/templates/_author.html +2 -2
- package/templates/_download.html +1 -1
- package/templates/_footer.html +1 -1
- package/templates/_heading.html +17 -4
- package/templates/_nav.html +2 -2
- package/templates/_page-bottom.html +6 -0
- package/templates/_top.html +18 -14
- package/templates/code.html +22 -21
- package/templates/default.html +12 -11
- package/templates/literate.html +6 -5
- package/assets/material-symbols/south_east_20px.svg +0 -1
- package/build/copy.ts +0 -82
- package/build/generate-content-assets.ts +0 -121
- package/build/utils/content-files.ts +0 -170
- package/build/watch/assets.ts +0 -103
- package/build/watch/build-full.ts +0 -97
- package/build/watch/build-helpers.ts +0 -66
- package/build/watch/build-result.ts +0 -19
- package/build/watch/fs-commit.ts +0 -218
- package/build/watch/mutations.ts +0 -73
- package/build/watch/snapshot.ts +0 -201
- package/build/watch/validation.ts +0 -57
- package/src/code.scss +0 -60
- package/src/critical.scss +0 -5
- package/src/header/style.scss +0 -91
- /package/src/{literate.scss → _literate.scss} +0 -0
- /package/src/anchor/{style.scss → _index.scss} +0 -0
- /package/src/page-update/{style.scss → _index.scss} +0 -0
- /package/src/question/{style.scss → _index.scss} +0 -0
- /package/src/search/{style.scss → _index.scss} +0 -0
- /package/src/timezone/{style.scss → _index.scss} +0 -0
- /package/src/toc/{style.scss → _index.scss} +0 -0
- /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
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
| `
|
|
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
|
|
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:
|
|
106
|
-
validate:
|
|
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
|
-
|
|
222
|
-
|
|
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
|
+
}
|
package/build/build-manifest.ts
CHANGED
|
@@ -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([
|
|
7
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
5
|
-
} from '
|
|
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
|
|
24
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
|
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
|
-
|
|
151
|
-
path.resolve(
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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 };
|