@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.
- package/README.md +41 -7
- package/bin/tada.ts +23 -9
- package/bin/validators.ts +34 -1
- package/build/branding.ts +41 -0
- package/build/build-manifest.ts +10 -5
- 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/columns-plugin.ts +27 -1
- package/build/config-loader.ts +3 -3
- package/build/custom-fonts.ts +8 -5
- package/build/deflist-id-plugin.ts +2 -2
- 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/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 -229
- package/build/source-records.ts +69 -81
- package/build/template-globals.ts +3 -0
- package/build/templates.ts +12 -8
- package/build/toc-plugin.ts +21 -21
- package/build/types.d.ts +38 -38
- package/build/util.ts +0 -4
- package/build/utils/code.ts +51 -117
- package/build/utils/final-html.ts +171 -47
- package/build/utils/jdi-runner/LiterateRunner.java +9 -1
- 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 +197 -154
- 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 +39 -6
- package/build/watch/build-incremental.ts +46 -103
- package/build/watch/compiler.ts +158 -37
- package/build/watch/engine.ts +207 -162
- 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/package.json +4 -5
- package/schema/site.schema.json +18 -2
- package/src/_content.scss +447 -0
- package/src/_fonts.scss +90 -0
- package/src/_layout.scss +50 -35
- 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} +20 -0
- package/src/navigate/eligible.ts +0 -40
- package/src/navigate/index.ts +50 -5
- package/src/navigate/lifecycle.ts +19 -2
- package/src/navigate/runtime.ts +20 -4
- package/src/page-update/index.ts +33 -20
- package/src/print/{style.scss → _index.scss} +3 -1
- package/src/search/index.ts +30 -2
- package/src/slides/{style.scss → _index.scss} +5 -0
- package/src/style.scss +24 -536
- package/src/timezone/index.ts +29 -3
- package/src/timezone/time-format.ts +4 -0
- package/src/toc/index.ts +6 -5
- package/src/trace/index.ts +11 -1
- package/templates/_author.html +2 -2
- package/templates/_download.html +1 -1
- package/templates/_footer.html +1 -1
- package/templates/_heading.html +3 -3
- 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/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 -153
- package/build/watch/mutations.ts +0 -73
- package/build/watch/snapshot.ts +0 -201
- package/build/watch/validation.ts +0 -50
- 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/{material-symbols.scss → _material-symbols.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
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
|
|
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
|
}
|