@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
@@ -0,0 +1,238 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { globals } from './globals';
4
+ import { toPosix } from './utils/paths';
5
+ import type { OutputContent } from './types';
6
+
7
+ type FileMutation =
8
+ | { kind: 'write'; path: string; content: OutputContent }
9
+ | { kind: 'delete'; path: string };
10
+
11
+ /** An output file and the source that produced it */
12
+ export interface OutputFile {
13
+ sourcePath: string;
14
+ content: OutputContent;
15
+ }
16
+
17
+ // EBUSY means a file is briefly held open. On Windows, EPERM and EACCES also
18
+ // mean that (an indexer, antivirus, or the dev server holding the file); on
19
+ // other platforms they are permanent permission errors and are not retried.
20
+ const BUSY_ERROR_CODES = new Set(
21
+ process.platform === 'win32' ? ['EBUSY', 'EPERM', 'EACCES'] : ['EBUSY'],
22
+ );
23
+ const BUSY_RETRY_COUNT = 4;
24
+ const BUSY_RETRY_DELAY_MS = 50;
25
+
26
+ function retryWhileBusy(action: () => void): void {
27
+ for (let attempt = 0; ; attempt++) {
28
+ try {
29
+ action();
30
+ return;
31
+ } catch (error) {
32
+ const code = (error as NodeJS.ErrnoException).code;
33
+ if (attempt >= BUSY_RETRY_COUNT || !code || !BUSY_ERROR_CODES.has(code)) {
34
+ throw error;
35
+ }
36
+ globals.sleepSync(BUSY_RETRY_DELAY_MS);
37
+ }
38
+ }
39
+ }
40
+
41
+ /** Writes rendered text or bytes, or copies a referenced source file. */
42
+ export function writeOutputFile(
43
+ filePath: string,
44
+ content: OutputContent,
45
+ ): void {
46
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
47
+ // Older builds may have copied a source's read-only permissions.
48
+ if (fs.lstatSync(filePath, { throwIfNoEntry: false })?.isFile()) {
49
+ fs.chmodSync(filePath, 0o644);
50
+ }
51
+ if (typeof content === 'string' || content instanceof Uint8Array) {
52
+ fs.writeFileSync(filePath, content);
53
+ } else {
54
+ fs.copyFileSync(content.copyFrom, filePath);
55
+ // Distribution output is writable regardless of the source permissions.
56
+ fs.chmodSync(filePath, 0o644);
57
+ }
58
+ }
59
+
60
+ export function sameOutputContent(
61
+ left: OutputContent,
62
+ right: OutputContent,
63
+ ): boolean {
64
+ if (typeof left === 'string' || typeof right === 'string') {
65
+ return left === right;
66
+ }
67
+ if (left instanceof Uint8Array || right instanceof Uint8Array) {
68
+ return (
69
+ left instanceof Uint8Array &&
70
+ right instanceof Uint8Array &&
71
+ Buffer.compare(left, right) === 0
72
+ );
73
+ }
74
+ // Copied files compare by source path; a source whose contents changed is
75
+ // always rebuilt, which forces a write.
76
+ return left.copyFrom === right.copyFrom;
77
+ }
78
+
79
+ /**
80
+ * Mutations from one build's outputs to the next. Outputs from a source in
81
+ * `forceSourcePaths` are rewritten even when they look unchanged.
82
+ */
83
+ export function computeMutations(
84
+ previous: ReadonlyMap<string, OutputFile>,
85
+ next: ReadonlyMap<string, OutputFile>,
86
+ forceSourcePaths: ReadonlySet<string> = new Set(),
87
+ ): FileMutation[] {
88
+ const mutations: FileMutation[] = [];
89
+ for (const outputPath of new Set([...previous.keys(), ...next.keys()])) {
90
+ const before = previous.get(outputPath);
91
+ const after = next.get(outputPath);
92
+ if (!after) {
93
+ mutations.push({ kind: 'delete', path: outputPath });
94
+ } else if (
95
+ !before ||
96
+ !sameOutputContent(before.content, after.content) ||
97
+ forceSourcePaths.has(after.sourcePath)
98
+ ) {
99
+ mutations.push({
100
+ kind: 'write',
101
+ path: outputPath,
102
+ content: after.content,
103
+ });
104
+ }
105
+ }
106
+ return mutations;
107
+ }
108
+
109
+ /**
110
+ * Mutations that make `rootDir` hold exactly `outputs`: every output is
111
+ * written, and every other file and empty directory is deleted except those
112
+ * under `keep`.
113
+ */
114
+ export function planFullWrite(
115
+ rootDir: string,
116
+ outputs: ReadonlyMap<string, OutputFile>,
117
+ keep: readonly string[] = [],
118
+ ): FileMutation[] {
119
+ const files: string[] = [];
120
+ const dirs: string[] = [];
121
+ // Walk one level at a time: a recursive listing would follow symbolic links
122
+ // to directories. A link is listed as a file, so deleting it removes only
123
+ // the link, never what it points to.
124
+ const walk = (dir: string): void => {
125
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
126
+ const fullPath = path.join(dir, entry.name);
127
+ const relPath = toPosix(path.relative(rootDir, fullPath));
128
+ if (entry.isDirectory()) {
129
+ dirs.push(relPath);
130
+ walk(fullPath);
131
+ } else {
132
+ files.push(relPath);
133
+ }
134
+ }
135
+ };
136
+ if (fs.existsSync(rootDir)) {
137
+ walk(rootDir);
138
+ }
139
+ const isKept = (relPath: string) =>
140
+ keep.some(prefix => `${relPath}/`.startsWith(prefix));
141
+ const dirsWithFiles = new Set<string>();
142
+ for (const file of files) {
143
+ const segments = file.split('/');
144
+ for (let i = 1; i < segments.length; i++) {
145
+ dirsWithFiles.add(segments.slice(0, i).join('/'));
146
+ }
147
+ }
148
+
149
+ const deletes: FileMutation[] = [
150
+ ...files.filter(file => !isKept(file) && !outputs.has(file)),
151
+ ...dirs.filter(dir => !isKept(dir) && !dirsWithFiles.has(dir)),
152
+ ].map(relPath => ({ kind: 'delete', path: relPath }));
153
+ const writes: FileMutation[] = [...outputs].map(([outputPath, output]) => ({
154
+ kind: 'write',
155
+ path: outputPath,
156
+ content: output.content,
157
+ }));
158
+ return [...deletes, ...writes];
159
+ }
160
+
161
+ function pruneEmptyDirs(rootDir: string, filePath: string): void {
162
+ const root = path.resolve(rootDir);
163
+ for (
164
+ let dir = path.dirname(filePath);
165
+ dir !== root;
166
+ dir = path.dirname(dir)
167
+ ) {
168
+ try {
169
+ fs.rmdirSync(dir);
170
+ } catch {
171
+ return;
172
+ }
173
+ }
174
+ }
175
+
176
+ /** Throws if a path would be reached through a symbolic link below `rootDir`. */
177
+ function createSymlinkGuard(rootDir: string): (target: string) => void {
178
+ const root = path.resolve(rootDir);
179
+ const safeDirs = new Set<string>([root]);
180
+ return target => {
181
+ const visited: string[] = [];
182
+ for (
183
+ let dir = path.dirname(target);
184
+ !safeDirs.has(dir) && dir.startsWith(root + path.sep);
185
+ dir = path.dirname(dir)
186
+ ) {
187
+ if (fs.lstatSync(dir, { throwIfNoEntry: false })?.isSymbolicLink()) {
188
+ throw new Error(`Refusing to write through the symbolic link ${dir}`);
189
+ }
190
+ visited.push(dir);
191
+ }
192
+ for (const dir of visited) {
193
+ safeDirs.add(dir);
194
+ }
195
+ };
196
+ }
197
+
198
+ /**
199
+ * Applies mutations to the files in `rootDir` in place. Deletes run first,
200
+ * deepest paths first, and remove directories they leave empty, so an output
201
+ * can change between a file and a directory. Nothing is renamed, staged, or
202
+ * rolled back: if a write fails, the error is thrown and earlier writes stay.
203
+ * Nothing is written or deleted through a symbolic link.
204
+ */
205
+ export function applyMutations(
206
+ rootDir: string,
207
+ mutations: readonly FileMutation[],
208
+ ): void {
209
+ const assertNotThroughSymlink = createSymlinkGuard(rootDir);
210
+ const deletes = mutations
211
+ .filter(mutation => mutation.kind === 'delete')
212
+ .sort((a, b) => b.path.length - a.path.length);
213
+ for (const mutation of deletes) {
214
+ const target = path.resolve(rootDir, mutation.path);
215
+ assertNotThroughSymlink(target);
216
+ retryWhileBusy(() => {
217
+ if (fs.lstatSync(target, { throwIfNoEntry: false })?.isDirectory()) {
218
+ fs.rmdirSync(target);
219
+ } else {
220
+ fs.rmSync(target, { force: true });
221
+ }
222
+ });
223
+ pruneEmptyDirs(rootDir, target);
224
+ }
225
+ for (const mutation of mutations) {
226
+ if (mutation.kind === 'write') {
227
+ const target = path.resolve(rootDir, mutation.path);
228
+ assertNotThroughSymlink(target);
229
+ retryWhileBusy(() => {
230
+ // Replace a link at the target itself; writing would follow it
231
+ if (fs.lstatSync(target, { throwIfNoEntry: false })?.isSymbolicLink()) {
232
+ fs.rmSync(target, { force: true });
233
+ }
234
+ writeOutputFile(target, mutation.content);
235
+ });
236
+ }
237
+ }
238
+ }
package/build/pagefind.ts CHANGED
@@ -1,17 +1,26 @@
1
+ import fs from 'fs';
1
2
  import path from 'path';
2
3
  import { makeLogger } from './log';
3
4
  import { collectReachableSiteAssets } from './reachability';
4
- import {
5
- getContentDir,
6
- getFilesByExtensions,
7
- normalizeOutputPath,
8
- } from './util';
5
+ import { normalizeOutputPath, SEARCH_INDEX_DIR } from './utils/paths';
6
+ import { isFeatureEnabled } from './features';
7
+ import type { SiteVariables } from './types';
8
+ import type { TadaProjectScan } from './source-model';
9
9
  import { assertMutoolAvailable, extractPdfPages } from './pdf-text';
10
10
  import type { HtmlOutputAnalysis } from './types';
11
11
 
12
12
  const log = makeLogger(import.meta.url);
13
13
  const PAGEFIND_VERBOSE = process.env.TADA_LOG_LEVEL === 'debug';
14
- const PAGEFIND_OUTPUT_SUBDIR = 'pagefind';
14
+
15
+ /**
16
+ * Output path prefixes a full write leaves alone: the search index, which is
17
+ * written after the build.
18
+ */
19
+ export function getKeptOutputPrefixes(siteVariables: SiteVariables): string[] {
20
+ return isFeatureEnabled(siteVariables, 'search')
21
+ ? [`${SEARCH_INDEX_DIR}/`]
22
+ : [];
23
+ }
15
24
 
16
25
  type PagefindModule = typeof import('pagefind');
17
26
  type PagefindIndex = Awaited<
@@ -71,16 +80,21 @@ async function addPdfRecord(
71
80
  }
72
81
  }
73
82
 
74
- function getPdfSourceByOutputPath(): Map<string, string> {
75
- const contentDir = getContentDir();
76
- const pdfFiles: string[] = getFilesByExtensions(contentDir, ['pdf']);
77
-
78
- return new Map(
79
- pdfFiles.map((filePath: string) => {
80
- const relPath = path.relative(contentDir, filePath);
81
- return [normalizeOutputPath(`/${relPath}`), filePath] as const;
82
- }),
83
- );
83
+ /** PDFs copied from `content/`, keyed by their root-relative output path */
84
+ export function getPdfSources(scan: TadaProjectScan): Map<string, string> {
85
+ const pdfSources = new Map<string, string>();
86
+ for (const [filePath, entry] of scan.sources) {
87
+ if (
88
+ entry.renderKind !== 'content-copy' ||
89
+ path.extname(filePath).toLowerCase() !== '.pdf'
90
+ ) {
91
+ continue;
92
+ }
93
+ for (const outputPath of entry.outputs) {
94
+ pdfSources.set(normalizeOutputPath(`/${outputPath}`), filePath);
95
+ }
96
+ }
97
+ return pdfSources;
84
98
  }
85
99
 
86
100
  interface IndexTargets {
@@ -120,6 +134,13 @@ interface BuildIndexOptions {
120
134
  loadPagefind?: () => Promise<PagefindModule>;
121
135
  checkMutool?: () => Promise<void>;
122
136
  extractPages?: typeof extractPdfPages;
137
+ prepareOutputDir?: (dir: string) => void;
138
+ }
139
+
140
+ function prepareSearchIndexDir(dir: string): void {
141
+ if (fs.lstatSync(dir, { throwIfNoEntry: false })?.isFile()) {
142
+ fs.unlinkSync(dir);
143
+ }
123
144
  }
124
145
 
125
146
  async function buildIndex({
@@ -131,6 +152,7 @@ async function buildIndex({
131
152
  loadPagefind = getPagefind,
132
153
  checkMutool = assertMutoolAvailable,
133
154
  extractPages = extractPdfPages,
155
+ prepareOutputDir = prepareSearchIndexDir,
134
156
  }: BuildIndexOptions): Promise<void> {
135
157
  const pagefind = await loadPagefind();
136
158
  const { index, errors: createErrors } = await pagefind.createIndex({
@@ -200,9 +222,10 @@ async function buildIndex({
200
222
  }
201
223
  }
202
224
 
203
- const { errors: writeErrors } = await index.writeFiles({
204
- outputPath: path.join(distPath, PAGEFIND_OUTPUT_SUBDIR),
205
- });
225
+ // Keep older hashed files available to browsers using the previous index.
226
+ const outputPath = path.join(distPath, SEARCH_INDEX_DIR);
227
+ prepareOutputDir(outputPath);
228
+ const { errors: writeErrors } = await index.writeFiles({ outputPath });
206
229
  const writeError = formatPagefindErrors('index.writeFiles()', writeErrors);
207
230
  if (writeError) {
208
231
  throw new Error(writeError);
@@ -216,14 +239,15 @@ interface RunPagefindOptions {
216
239
  distPath: string;
217
240
  htmlAssetsByPath: Map<string, string>;
218
241
  htmlAnalysisByPath: Map<string, HtmlOutputAnalysis>;
242
+ pdfSourceByOutputPath: Map<string, string>;
219
243
  }
220
244
 
221
245
  export async function runPagefind({
222
246
  distPath,
223
247
  htmlAssetsByPath,
224
248
  htmlAnalysisByPath,
249
+ pdfSourceByOutputPath,
225
250
  }: RunPagefindOptions): Promise<void> {
226
- const pdfSourceByOutputPath = getPdfSourceByOutputPath();
227
251
  const start = Date.now();
228
252
 
229
253
  log.debug`Finding reachable pages for search index`;
@@ -258,6 +282,7 @@ export class WatchPagefindRunner {
258
282
  private distPath: string | null;
259
283
  private htmlCacheByAssetPath: Map<string, string>;
260
284
  private htmlAnalysisByPath: Map<string, HtmlOutputAnalysis>;
285
+ private pdfSourceByOutputPath: Map<string, string>;
261
286
 
262
287
  constructor() {
263
288
  this.watchRunInProgress = false;
@@ -265,16 +290,19 @@ export class WatchPagefindRunner {
265
290
  this.distPath = null;
266
291
  this.htmlCacheByAssetPath = new Map();
267
292
  this.htmlAnalysisByPath = new Map();
293
+ this.pdfSourceByOutputPath = new Map();
268
294
  }
269
295
 
270
296
  update(
271
297
  distPath: string,
272
298
  htmlAssetsByPath: Map<string, string>,
273
299
  htmlAnalysisByPath: Map<string, HtmlOutputAnalysis>,
300
+ pdfSourceByOutputPath: Map<string, string>,
274
301
  ): void {
275
302
  this.distPath = distPath;
276
303
  this.htmlCacheByAssetPath = htmlAssetsByPath;
277
304
  this.htmlAnalysisByPath = htmlAnalysisByPath;
305
+ this.pdfSourceByOutputPath = pdfSourceByOutputPath;
278
306
  }
279
307
 
280
308
  run(): void {
@@ -289,7 +317,7 @@ export class WatchPagefindRunner {
289
317
  const distPath = this.distPath!;
290
318
  const htmlAssetsByPath = new Map(this.htmlCacheByAssetPath);
291
319
  const htmlAnalysisByPath = new Map(this.htmlAnalysisByPath);
292
- const pdfSourceByOutputPath = getPdfSourceByOutputPath();
320
+ const pdfSourceByOutputPath = this.pdfSourceByOutputPath;
293
321
  const start = Date.now();
294
322
 
295
323
  log.debug`Preparing search index background snapshot`;
package/build/pdf-text.ts CHANGED
@@ -137,7 +137,7 @@ export function buildPdfPageRecords(pageTexts: string[]): PdfExtractResult {
137
137
  return { pages, hasExtractedText: pages.length > 0 };
138
138
  }
139
139
 
140
- export async function extractPdfPages(
140
+ async function extractPdfPagesUncached(
141
141
  pdfPath: string,
142
142
  ): Promise<PdfExtractResult> {
143
143
  await assertMutoolAvailable();
@@ -181,3 +181,43 @@ export async function extractPdfPages(
181
181
  }
182
182
  }
183
183
  }
184
+
185
+ interface FileSignature {
186
+ size: number;
187
+ mtimeMs: number;
188
+ }
189
+
190
+ /**
191
+ * Wraps a PDF text extractor so each PDF is extracted again only after its size
192
+ * or modification time changes. Concurrent requests for one file share a
193
+ * single extraction, and failed extractions are not cached.
194
+ */
195
+ export function createCachedPdfExtractor(
196
+ extract: (pdfPath: string) => Promise<PdfExtractResult>,
197
+ stat: (pdfPath: string) => FileSignature,
198
+ ): (pdfPath: string) => Promise<PdfExtractResult> {
199
+ const cache = new Map<
200
+ string,
201
+ FileSignature & { result: Promise<PdfExtractResult> }
202
+ >();
203
+ return pdfPath => {
204
+ const { size, mtimeMs } = stat(pdfPath);
205
+ const cached = cache.get(pdfPath);
206
+ if (cached && cached.size === size && cached.mtimeMs === mtimeMs) {
207
+ return cached.result;
208
+ }
209
+ const entry = { size, mtimeMs, result: extract(pdfPath) };
210
+ cache.set(pdfPath, entry);
211
+ entry.result.catch(() => {
212
+ if (cache.get(pdfPath) === entry) {
213
+ cache.delete(pdfPath);
214
+ }
215
+ });
216
+ return entry.result;
217
+ };
218
+ }
219
+
220
+ export const extractPdfPages = createCachedPdfExtractor(
221
+ extractPdfPagesUncached,
222
+ pdfPath => fs.statSync(pdfPath),
223
+ );
package/build/pipeline.ts CHANGED
@@ -1,123 +1,117 @@
1
1
  import fs from 'fs';
2
+ import path from 'path';
2
3
  import { getDevSiteVariables, getProdSiteVariables } from './site-variables';
3
- import { compileTemplates } from './templates';
4
4
  import { getDistDir, getProdDistDir } from './utils/paths';
5
5
  import { isFeatureEnabled } from './features';
6
- import { bundle } from './bundle';
7
- import { copyFonts } from './generate-fonts';
8
- import { copyKatexAssets } from './generate-katex-assets';
9
- import { generateFavicons } from './generate-favicon';
10
- import { generateWebAppManifest } from './generate-web-app-manifest';
11
- import { copyPublicFiles, copyContentAssets } from './copy';
12
- import { ContentRenderer } from './generate-content-assets';
13
- import { runPagefind } from './pagefind';
6
+ import { getKeptOutputPrefixes, runPagefind } from './pagefind';
14
7
  import { makeLogger, printFlair } from './log';
15
- import { generateBuildManifest, getNextVersion } from './build-manifest';
16
- import { scanProject } from './source-model';
17
- import { validateConfig } from './watch/validation';
18
- import { applyCommitPlan } from './watch/fs-commit';
19
- import path from 'path';
8
+ import {
9
+ generateBuildManifest,
10
+ getNextVersion,
11
+ MANIFEST_FILE_NAME,
12
+ } from './build-manifest';
13
+ import { buildSite, createBuildMeta, type TadaSnapshot } from './site-build';
14
+ import {
15
+ BuildFailedError,
16
+ diagnosticFromError,
17
+ printDiagnostics,
18
+ } from './build-validation';
19
+ import { applyMutations, planFullWrite } from './output-publication';
20
+ import type { BuildDiagnostic } from './build-types';
21
+ import type { SiteVariables } from './types';
20
22
 
21
23
  const log = makeLogger(import.meta.url);
22
24
 
23
- function makeTempOutputDir(targetDir: string): string {
24
- const parentDir = path.dirname(targetDir);
25
- fs.mkdirSync(parentDir, { recursive: true });
26
- return fs.mkdtempSync(
27
- path.join(parentDir, `${path.basename(targetDir)}-build-`),
25
+ function fail(diagnostics: BuildDiagnostic[]): never {
26
+ printDiagnostics(diagnostics);
27
+ const noun = diagnostics.length === 1 ? 'error' : 'errors';
28
+ throw new BuildFailedError(`${diagnostics.length} ${noun}`);
29
+ }
30
+
31
+ async function indexSearch(
32
+ outputDir: string,
33
+ snapshot: TadaSnapshot,
34
+ ): Promise<void> {
35
+ if (isFeatureEnabled(snapshot.siteVariables, 'search')) {
36
+ const meta = createBuildMeta(snapshot);
37
+ await runPagefind({ distPath: outputDir, ...meta });
38
+ }
39
+ }
40
+
41
+ /** Updates `dist/` in place to hold exactly this build's outputs. */
42
+ async function publishDev(snapshot: TadaSnapshot): Promise<void> {
43
+ const distDir = getDistDir();
44
+ applyMutations(
45
+ distDir,
46
+ planFullWrite(
47
+ distDir,
48
+ snapshot.outputs,
49
+ getKeptOutputPrefixes(snapshot.siteVariables),
50
+ ),
28
51
  );
52
+ await indexSearch(distDir, snapshot);
29
53
  }
30
54
 
31
- function removeDirIfExists(dir: string): void {
32
- fs.rmSync(dir, { recursive: true, force: true });
55
+ /** Writes a new `dist-prod/v{N}/`; it counts as a version once its manifest is written. */
56
+ async function publishProd(snapshot: TadaSnapshot): Promise<void> {
57
+ const prodBase = getProdDistDir();
58
+ const version = getNextVersion(prodBase);
59
+ const versionDir = path.join(prodBase, `v${version}`);
60
+ // A directory without a manifest is left over from a failed build.
61
+ fs.rmSync(versionDir, { recursive: true, force: true });
62
+ try {
63
+ applyMutations(versionDir, planFullWrite(versionDir, snapshot.outputs));
64
+ await indexSearch(versionDir, snapshot);
65
+ await generateBuildManifest(
66
+ versionDir,
67
+ path.join(versionDir, MANIFEST_FILE_NAME),
68
+ version,
69
+ );
70
+ } catch (error) {
71
+ try {
72
+ fs.rmSync(versionDir, { recursive: true, force: true });
73
+ } catch {
74
+ // Without a manifest, the leftover directory is ignored and replaced.
75
+ }
76
+ throw error;
77
+ }
78
+ log.info`Built dist-prod/v${version}/`;
33
79
  }
34
80
 
35
81
  export async function runPipeline(
36
82
  mode: 'development' | 'production',
37
83
  ): Promise<void> {
38
84
  const isDev = mode === 'development';
39
- const siteVariables = isDev ? getDevSiteVariables() : getProdSiteVariables();
40
- const scan = scanProject(siteVariables);
41
- const publishDir = isDev
42
- ? getDistDir()
43
- : path.join(getProdDistDir(), 'v-next');
44
- const distDir = makeTempOutputDir(publishDir);
45
- let published = false;
46
-
85
+ let siteVariables: SiteVariables;
47
86
  try {
48
- const configDiagnostics = validateConfig(scan, siteVariables);
49
- if (configDiagnostics.length > 0) {
50
- throw new Error(configDiagnostics[0].message);
51
- }
52
-
53
- compileTemplates(siteVariables);
54
- const contentRenderer = new ContentRenderer(siteVariables);
55
- await contentRenderer.initHighlighter();
56
-
57
- const parallelTasks: (Promise<unknown> | void)[] = [
58
- bundle(siteVariables, { mode, distDir }),
59
- copyFonts(distDir),
60
- copyKatexAssets(distDir),
61
- ];
62
-
63
- if (isFeatureEnabled(siteVariables, 'favicon')) {
64
- parallelTasks.push(generateFavicons(siteVariables, distDir));
65
- }
66
-
67
- const results = await Promise.all(parallelTasks);
68
- const assetFiles = results[0] as string[]; // bundle output filenames
69
-
70
- if (isFeatureEnabled(siteVariables, 'favicon')) {
71
- generateWebAppManifest(siteVariables, distDir);
72
- }
73
-
74
- const publicRelPaths = copyPublicFiles(scan.publicDir, distDir);
75
- copyContentAssets(
76
- scan.contentDir,
77
- distDir,
78
- [...scan.processedExts],
79
- publicRelPaths,
80
- );
81
-
82
- const { errors, htmlAssetsByPath, htmlAnalysisByPath } =
83
- contentRenderer.processContent({ distDir, assetFiles, scan });
84
-
85
- for (const err of errors) {
86
- log.error`${err.message}`;
87
- }
88
- if (errors.length > 0) {
89
- throw new Error(errors[0].message);
90
- }
91
-
92
- if (isFeatureEnabled(siteVariables, 'search')) {
93
- await runPagefind({
94
- distPath: distDir,
95
- htmlAssetsByPath,
96
- htmlAnalysisByPath,
97
- });
98
- }
99
-
100
- let finalOutputDir = getDistDir();
101
-
102
- if (!isDev) {
103
- const prodBase = getProdDistDir();
104
- const prodVersion = getNextVersion(prodBase);
105
- finalOutputDir = path.join(prodBase, `v${prodVersion}`);
106
- const manifestPath = path.join(distDir, 'tada.manifest.json');
107
- await generateBuildManifest(distDir, manifestPath, prodVersion);
108
- log.info`Built dist-prod/v${prodVersion}/`;
109
- }
87
+ siteVariables = isDev ? getDevSiteVariables() : getProdSiteVariables();
88
+ } catch (error) {
89
+ fail([diagnosticFromError(error)]);
90
+ }
110
91
 
111
- applyCommitPlan({
112
- kind: 'replace-root',
113
- stagedPath: distDir,
114
- targetPath: finalOutputDir,
92
+ let result: Awaited<ReturnType<typeof buildSite>>;
93
+ try {
94
+ result = await buildSite({
95
+ siteVariables,
96
+ mode,
97
+ isWatchMode: false,
98
+ traceCache: new Map(),
115
99
  });
116
- published = true;
117
- printFlair();
118
- } finally {
119
- if (!published) {
120
- removeDirIfExists(distDir);
121
- }
100
+ } catch (error) {
101
+ fail([diagnosticFromError(error)]);
102
+ }
103
+ if (!result.ok) {
104
+ fail(result.diagnostics);
105
+ }
106
+
107
+ try {
108
+ await (isDev ? publishDev(result.snapshot) : publishProd(result.snapshot));
109
+ } catch (error) {
110
+ fail([
111
+ diagnosticFromError(
112
+ new Error(`Failed to write output: ${(error as Error).message}`),
113
+ ),
114
+ ]);
122
115
  }
116
+ printFlair();
123
117
  }