@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/build/config-loader.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
|
-
import
|
|
2
|
+
import { compileTemplate } from './lodash-template';
|
|
3
3
|
import {
|
|
4
4
|
resolveProjectConfigFile,
|
|
5
5
|
resolveSiteConfigFile,
|
|
@@ -44,7 +44,7 @@ function renderConfigTemplateString(
|
|
|
44
44
|
fileName: string,
|
|
45
45
|
): string {
|
|
46
46
|
try {
|
|
47
|
-
return
|
|
47
|
+
return compileTemplate(value)(context);
|
|
48
48
|
} catch (error) {
|
|
49
49
|
throw new Error(
|
|
50
50
|
`${fileName}: Lodash template error in config: ${getTemplateErrorMessage(error)}`,
|
|
@@ -61,7 +61,7 @@ function evaluateConfigTemplateExpression(
|
|
|
61
61
|
let captured: unknown;
|
|
62
62
|
|
|
63
63
|
try {
|
|
64
|
-
|
|
64
|
+
compileTemplate(`<% __capture(( ${expression} )); %>`)({
|
|
65
65
|
...context,
|
|
66
66
|
__capture(value: unknown) {
|
|
67
67
|
captured = value;
|
package/build/custom-fonts.ts
CHANGED
|
@@ -41,14 +41,17 @@ interface CustomFontValidationOptions {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
export function isValidPublicWoff2Path(filePath: string): boolean {
|
|
44
|
+
return isValidPublicAssetPath(filePath) && filePath.endsWith('.woff2');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isValidPublicAssetPath(filePath: string): boolean {
|
|
44
48
|
if (
|
|
45
49
|
filePath.length === 0 ||
|
|
46
50
|
filePath.startsWith('/') ||
|
|
47
51
|
/^[A-Za-z]:/.test(filePath) ||
|
|
48
52
|
filePath.includes('\\') ||
|
|
49
53
|
filePath.includes('?') ||
|
|
50
|
-
filePath.includes('#')
|
|
51
|
-
!filePath.endsWith('.woff2')
|
|
54
|
+
filePath.includes('#')
|
|
52
55
|
) {
|
|
53
56
|
return false;
|
|
54
57
|
}
|
|
@@ -160,7 +163,7 @@ export function renderCustomFontTuningScss(
|
|
|
160
163
|
serifTuning?.lineHeight !== undefined
|
|
161
164
|
) {
|
|
162
165
|
rules.push(
|
|
163
|
-
` ${rootSelector} main.body > :is(
|
|
166
|
+
` ${rootSelector} main.body .page-bottom, ${rootSelector} main.body > .page-content > :is(.slides-header, .file-header) {\n font-size: var(--font-size);\n line-height: var(--line-height);\n }`,
|
|
164
167
|
);
|
|
165
168
|
}
|
|
166
169
|
|
|
@@ -206,7 +209,7 @@ export function renderCustomFontTuningScss(
|
|
|
206
209
|
export function getSerifFontStack(
|
|
207
210
|
fontOverrides: FontOverrides | undefined,
|
|
208
211
|
): string {
|
|
209
|
-
const custom = fontOverrides?.serif
|
|
212
|
+
const custom = fontOverrides?.serif?.regular
|
|
210
213
|
? `'${CUSTOM_FONT_FAMILY_DEFINITIONS.serif.cssFamily}', `
|
|
211
214
|
: '';
|
|
212
215
|
return `${custom}'Source Serif 4', 'Times New Roman', 'Times', serif`;
|
|
@@ -215,7 +218,7 @@ export function getSerifFontStack(
|
|
|
215
218
|
export function getSerifMonoFontStack(
|
|
216
219
|
fontOverrides: FontOverrides | undefined,
|
|
217
220
|
): string {
|
|
218
|
-
const custom = fontOverrides?.serifMono
|
|
221
|
+
const custom = fontOverrides?.serifMono?.regular
|
|
219
222
|
? `'${CUSTOM_FONT_FAMILY_DEFINITIONS.serifMono.cssFamily}', `
|
|
220
223
|
: '';
|
|
221
224
|
return `${custom}'Courier Prime', 'Courier New', 'Courier', monospace`;
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
1
|
import path from 'path';
|
|
3
2
|
import * as fontkit from 'fontkit';
|
|
4
3
|
import sharp from 'sharp';
|
|
@@ -108,10 +107,11 @@ function createFaviconSvg(
|
|
|
108
107
|
</svg>`;
|
|
109
108
|
}
|
|
110
109
|
|
|
110
|
+
/** Generated favicon files, keyed by output path */
|
|
111
111
|
export async function generateFavicons(
|
|
112
112
|
siteVariables: SiteVariables,
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
): Promise<Map<string, string | Uint8Array>> {
|
|
114
|
+
const outputs = new Map<string, string | Uint8Array>();
|
|
115
115
|
const color = siteVariables.faviconColor!;
|
|
116
116
|
const symbol = siteVariables.faviconSymbol!;
|
|
117
117
|
const fontWeight = siteVariables.faviconFontWeight || 700;
|
|
@@ -132,7 +132,7 @@ export async function generateFavicons(
|
|
|
132
132
|
font,
|
|
133
133
|
fontWeight,
|
|
134
134
|
);
|
|
135
|
-
|
|
135
|
+
outputs.set(`${filenameBase}.svg`, svgMarkup);
|
|
136
136
|
|
|
137
137
|
const pngBuffers = await Promise.all(
|
|
138
138
|
FAVICON_SIZES.map(async size => {
|
|
@@ -145,7 +145,7 @@ export async function generateFavicons(
|
|
|
145
145
|
fontWeight,
|
|
146
146
|
);
|
|
147
147
|
const buf = await sharp(Buffer.from(svgForSize)).png().toBuffer();
|
|
148
|
-
|
|
148
|
+
outputs.set(`${filenameBase}-${size}.png`, buf);
|
|
149
149
|
return { size, buf };
|
|
150
150
|
}),
|
|
151
151
|
);
|
|
@@ -156,5 +156,6 @@ export async function generateFavicons(
|
|
|
156
156
|
.sort((a, b) => a.size - b.size)
|
|
157
157
|
.map(x => x.buf),
|
|
158
158
|
);
|
|
159
|
-
|
|
159
|
+
outputs.set(`${filenameBase}.ico`, icoBuffer);
|
|
160
|
+
return outputs;
|
|
160
161
|
}
|
package/build/generate-fonts.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { getPackageDir } from './utils/paths';
|
|
4
|
-
import {
|
|
5
|
-
import type { SiteVariables } from './types';
|
|
4
|
+
import type { CopiedFile, SiteVariables } from './types';
|
|
6
5
|
|
|
7
|
-
const log = makeLogger(import.meta.url);
|
|
8
6
|
const FONTS_DIR = path.join(getPackageDir(), 'fonts');
|
|
9
7
|
|
|
10
8
|
export const DEFAULT_FONT_PRELOAD_FILES = {
|
|
@@ -49,26 +47,21 @@ export function getDefaultFontPreloadFiles(
|
|
|
49
47
|
];
|
|
50
48
|
}
|
|
51
49
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
/** Bundled browser fonts, as copy references keyed by output path */
|
|
51
|
+
export function getFontOutputs(): Map<string, CopiedFile> {
|
|
52
|
+
const outputs = new Map<string, CopiedFile>();
|
|
55
53
|
for (const family of fs.readdirSync(FONTS_DIR)) {
|
|
56
54
|
const woff2Dir = path.join(FONTS_DIR, family, 'woff2');
|
|
57
55
|
if (!fs.existsSync(woff2Dir) || !fs.statSync(woff2Dir).isDirectory()) {
|
|
58
56
|
continue;
|
|
59
57
|
}
|
|
60
|
-
|
|
61
|
-
const outFamilyDir = path.join(distDir, family);
|
|
62
|
-
fs.mkdirSync(outFamilyDir, { recursive: true });
|
|
63
|
-
|
|
64
58
|
for (const file of fs.readdirSync(woff2Dir)) {
|
|
65
59
|
if (file.endsWith('.woff2')) {
|
|
66
|
-
|
|
67
|
-
path.join(woff2Dir, file),
|
|
68
|
-
|
|
69
|
-
);
|
|
70
|
-
log.debug`Copied ${family}/${file}`;
|
|
60
|
+
outputs.set(`${family}/${file}`, {
|
|
61
|
+
copyFrom: path.join(woff2Dir, file),
|
|
62
|
+
});
|
|
71
63
|
}
|
|
72
64
|
}
|
|
73
65
|
}
|
|
66
|
+
return outputs;
|
|
74
67
|
}
|
|
@@ -1,35 +1,37 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import * as sass from 'sass';
|
|
4
|
-
import {
|
|
4
|
+
import type { OutputContent } from './types';
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export function copyKatexAssets(distDir: string): void {
|
|
9
|
-
log.info`Copying KaTeX assets`;
|
|
10
|
-
|
|
11
|
-
const outDir = path.join(distDir, 'katex');
|
|
12
|
-
const outFontsDir = path.join(outDir, 'fonts');
|
|
13
|
-
fs.mkdirSync(outFontsDir, { recursive: true });
|
|
6
|
+
let katexCss: string | undefined;
|
|
14
7
|
|
|
8
|
+
function compileKatexCss(): string {
|
|
15
9
|
// Compile KaTeX SCSS with woff2-only font references
|
|
16
10
|
const katexScssDir = path.dirname(
|
|
17
11
|
require.resolve('katex/src/styles/katex.scss'),
|
|
18
12
|
);
|
|
19
|
-
|
|
13
|
+
return sass.compileString(
|
|
20
14
|
`@use 'katex' with ($use-woff2: true, $use-woff: false, $use-ttf: false, $font-folder: 'fonts');`,
|
|
21
15
|
{ loadPaths: [katexScssDir], style: 'compressed' },
|
|
22
|
-
);
|
|
23
|
-
|
|
16
|
+
).css;
|
|
17
|
+
}
|
|
24
18
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
19
|
+
/** The KaTeX stylesheet and its woff2 fonts, keyed by output path */
|
|
20
|
+
export function getKatexOutputs(): Map<string, OutputContent> {
|
|
21
|
+
katexCss ??= compileKatexCss();
|
|
22
|
+
const outputs = new Map<string, OutputContent>([
|
|
23
|
+
['katex/katex.min.css', katexCss],
|
|
24
|
+
]);
|
|
25
|
+
const fontsDir = path.join(
|
|
26
|
+
path.dirname(require.resolve('katex/dist/katex.min.css')),
|
|
27
|
+
'fonts',
|
|
28
28
|
);
|
|
29
|
-
const fontsDir = path.join(katexDistDir, 'fonts');
|
|
30
29
|
for (const file of fs.readdirSync(fontsDir)) {
|
|
31
30
|
if (file.endsWith('.woff2')) {
|
|
32
|
-
|
|
31
|
+
outputs.set(`katex/fonts/${file}`, {
|
|
32
|
+
copyFrom: path.join(fontsDir, file),
|
|
33
|
+
});
|
|
33
34
|
}
|
|
34
35
|
}
|
|
36
|
+
return outputs;
|
|
35
37
|
}
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
1
|
import { createApplyBasePath } from './util';
|
|
4
2
|
import { FAVICON_SIZES } from './generate-favicon';
|
|
5
3
|
import type { SiteVariables } from './types';
|
|
@@ -57,13 +55,7 @@ function createManifest(siteVariables: SiteVariables): WebAppManifest {
|
|
|
57
55
|
};
|
|
58
56
|
}
|
|
59
57
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
): void {
|
|
64
|
-
const manifest = createManifest(siteVariables);
|
|
65
|
-
fs.writeFileSync(
|
|
66
|
-
path.join(distDir, 'manifest.json'),
|
|
67
|
-
JSON.stringify(manifest),
|
|
68
|
-
);
|
|
58
|
+
/** The web app manifest (`manifest.json`) for generated favicons */
|
|
59
|
+
export function createWebAppManifest(siteVariables: SiteVariables): string {
|
|
60
|
+
return JSON.stringify(createManifest(siteVariables));
|
|
69
61
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import _ from 'lodash';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Compiles a Lodash template that only recognizes `<% %>`, `<%= %>`, and
|
|
5
|
+
* `<%- %>` delimiters.
|
|
6
|
+
*
|
|
7
|
+
* Lodash also treats ES template literal syntax (`${expr}`) as interpolation,
|
|
8
|
+
* but only while `interpolate` is its built-in regex object. Passing a separate
|
|
9
|
+
* regex literal with the same pattern disables that, so `${...}` in Markdown,
|
|
10
|
+
* code samples, and source files stays literal text.
|
|
11
|
+
*/
|
|
12
|
+
export function compileTemplate(source: string): _.TemplateExecutor {
|
|
13
|
+
return _.template(source, { interpolate: /<%=([\s\S]+?)%>/g });
|
|
14
|
+
}
|
|
@@ -48,10 +48,13 @@ export const MATERIAL_SYMBOLS = Object.freeze({
|
|
|
48
48
|
symbol: 'warning',
|
|
49
49
|
opticalSize: 20,
|
|
50
50
|
}),
|
|
51
|
-
|
|
52
|
-
cssVariable: '--icon-
|
|
53
|
-
symbol: '
|
|
54
|
-
opticalSize:
|
|
51
|
+
breadcrumbSeparator: Object.freeze({
|
|
52
|
+
cssVariable: '--icon-breadcrumb-separator',
|
|
53
|
+
symbol: 'chevron_right',
|
|
54
|
+
opticalSize: 24,
|
|
55
|
+
// The authored selection differs from the shared weight/grade defaults.
|
|
56
|
+
weight: 400,
|
|
57
|
+
grade: 0,
|
|
55
58
|
}),
|
|
56
59
|
search: Object.freeze({
|
|
57
60
|
cssVariable: '--icon-search',
|
|
@@ -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`;
|