@abreen/tada 1.17.1 → 1.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +38 -7
  2. package/assets/material-symbols/chevron_right_24px.svg +1 -0
  3. package/bin/tada.ts +16 -7
  4. package/bin/validators.ts +33 -0
  5. package/build/branding.ts +41 -0
  6. package/build/build-manifest.ts +9 -4
  7. package/build/{watch/compiler-types.d.ts → build-types.d.ts} +8 -5
  8. package/build/build-validation.ts +176 -0
  9. package/build/bundle.ts +75 -65
  10. package/build/config-loader.ts +3 -3
  11. package/build/custom-fonts.ts +8 -5
  12. package/build/generate-favicon.ts +7 -6
  13. package/build/generate-fonts.ts +8 -15
  14. package/build/generate-katex-assets.ts +19 -17
  15. package/build/generate-web-app-manifest.ts +3 -11
  16. package/build/lodash-template.ts +14 -0
  17. package/build/material-symbols.ts +7 -4
  18. package/build/output-publication.ts +238 -0
  19. package/build/pagefind.ts +49 -21
  20. package/build/pdf-text.ts +41 -1
  21. package/build/pipeline.ts +95 -101
  22. package/build/site-assets.ts +46 -0
  23. package/build/site-build.ts +267 -0
  24. package/build/source-model.ts +223 -244
  25. package/build/source-records.ts +69 -81
  26. package/build/template-globals.ts +3 -0
  27. package/build/templates.ts +5 -2
  28. package/build/toc-plugin.ts +21 -21
  29. package/build/types.d.ts +44 -38
  30. package/build/util.ts +0 -4
  31. package/build/utils/code.ts +38 -101
  32. package/build/utils/final-html.ts +171 -47
  33. package/build/utils/literate-java.ts +11 -7
  34. package/build/utils/markdown-partials.ts +4 -3
  35. package/build/utils/markdown.ts +6 -17
  36. package/build/utils/paths.ts +3 -0
  37. package/build/utils/plain-text.ts +18 -0
  38. package/build/utils/render.ts +227 -165
  39. package/build/utils/shiki-highlighter.ts +11 -0
  40. package/build/utils/source-template.ts +2 -2
  41. package/build/utils/trace-core.ts +8 -83
  42. package/build/utils/trace.ts +38 -95
  43. package/build/validate-config-links.ts +60 -20
  44. package/build/watch/build-incremental.ts +46 -103
  45. package/build/watch/compiler.ts +158 -37
  46. package/build/watch/engine.ts +207 -176
  47. package/build/watch/index.ts +9 -2
  48. package/build/watch/planner.ts +88 -144
  49. package/build/watch/runtime.ts +21 -34
  50. package/build/watch/types.d.ts +32 -64
  51. package/init/content/labs/00/VowelCounter.java.md +5 -2
  52. package/init/content/labs/00/index.md +3 -2
  53. package/init/content/labs/01/index.md +3 -2
  54. package/init/content/labs/02/index.md +3 -2
  55. package/init/content/lectures/01/index.md +3 -2
  56. package/init/content/lectures/02/index.md +3 -2
  57. package/package.json +4 -4
  58. package/schema/site.schema.json +18 -2
  59. package/src/_content.scss +463 -0
  60. package/src/_fonts.scss +90 -0
  61. package/src/_layout.scss +50 -35
  62. package/src/{material-symbols.scss → _material-symbols.scss} +7 -0
  63. package/src/appearance-picker/{style.scss → _index.scss} +1 -1
  64. package/src/code/{style.scss → _index.scss} +61 -0
  65. package/src/header/{_base.scss → _index.scss} +98 -6
  66. package/src/index.ts +1 -17
  67. package/src/navigate/{style.scss → _index.scss} +29 -3
  68. package/src/navigate/breadcrumbs.ts +129 -0
  69. package/src/navigate/eligible.ts +0 -40
  70. package/src/navigate/index.ts +38 -4
  71. package/src/navigate/lifecycle.ts +19 -2
  72. package/src/navigate/runtime.ts +57 -22
  73. package/src/page-update/index.ts +33 -20
  74. package/src/print/{style.scss → _index.scss} +3 -1
  75. package/src/search/index.ts +7 -0
  76. package/src/slides/{style.scss → _index.scss} +5 -0
  77. package/src/style.scss +24 -536
  78. package/src/toc/index.ts +2 -2
  79. package/templates/_author.html +2 -2
  80. package/templates/_download.html +1 -1
  81. package/templates/_footer.html +1 -1
  82. package/templates/_heading.html +17 -4
  83. package/templates/_nav.html +2 -2
  84. package/templates/_page-bottom.html +6 -0
  85. package/templates/_top.html +18 -14
  86. package/templates/code.html +22 -21
  87. package/templates/default.html +12 -11
  88. package/templates/literate.html +6 -5
  89. package/assets/material-symbols/south_east_20px.svg +0 -1
  90. package/build/copy.ts +0 -82
  91. package/build/generate-content-assets.ts +0 -121
  92. package/build/utils/content-files.ts +0 -170
  93. package/build/watch/assets.ts +0 -103
  94. package/build/watch/build-full.ts +0 -97
  95. package/build/watch/build-helpers.ts +0 -66
  96. package/build/watch/build-result.ts +0 -19
  97. package/build/watch/fs-commit.ts +0 -218
  98. package/build/watch/mutations.ts +0 -73
  99. package/build/watch/snapshot.ts +0 -201
  100. package/build/watch/validation.ts +0 -57
  101. package/src/code.scss +0 -60
  102. package/src/critical.scss +0 -5
  103. package/src/header/style.scss +0 -91
  104. /package/src/{literate.scss → _literate.scss} +0 -0
  105. /package/src/anchor/{style.scss → _index.scss} +0 -0
  106. /package/src/page-update/{style.scss → _index.scss} +0 -0
  107. /package/src/question/{style.scss → _index.scss} +0 -0
  108. /package/src/search/{style.scss → _index.scss} +0 -0
  109. /package/src/timezone/{style.scss → _index.scss} +0 -0
  110. /package/src/toc/{style.scss → _index.scss} +0 -0
  111. /package/src/trace/{style.scss → _index.scss} +0 -0
@@ -1,5 +1,5 @@
1
1
  import fs from 'fs';
2
- import _ from 'lodash';
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 _.template(value)(context);
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
- _.template(`<% __capture(( ${expression} )); %>`)({
64
+ compileTemplate(`<% __capture(( ${expression} )); %>`)({
65
65
  ...context,
66
66
  __capture(value: unknown) {
67
67
  captured = value;
@@ -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(footer, .appearance-pickers, .slides-header, .file-header) {\n font-size: var(--font-size);\n line-height: var(--line-height);\n }`,
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
- distDir: string,
114
- ): Promise<void> {
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
- fs.writeFileSync(path.join(distDir, `${filenameBase}.svg`), svgMarkup);
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
- fs.writeFileSync(path.join(distDir, `${filenameBase}-${size}.png`), buf);
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
- fs.writeFileSync(path.join(distDir, `${filenameBase}.ico`), icoBuffer);
159
+ outputs.set(`${filenameBase}.ico`, icoBuffer);
160
+ return outputs;
160
161
  }
@@ -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 { makeLogger } from './log';
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
- export function copyFonts(distDir: string): void {
53
- log.info`Copying fonts`;
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
- fs.copyFileSync(
67
- path.join(woff2Dir, file),
68
- path.join(outFamilyDir, file),
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 { makeLogger } from './log';
4
+ import type { OutputContent } from './types';
5
5
 
6
- const log = makeLogger(import.meta.url);
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
- const result = sass.compileString(
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
- fs.writeFileSync(path.join(outDir, 'katex.min.css'), result.css);
16
+ ).css;
17
+ }
24
18
 
25
- // Copy woff2 fonts
26
- const katexDistDir = path.dirname(
27
- require.resolve('katex/dist/katex.min.css'),
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
- fs.copyFileSync(path.join(fontsDir, file), path.join(outFontsDir, file));
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
- export function generateWebAppManifest(
61
- siteVariables: SiteVariables,
62
- distDir: string,
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
- parent: Object.freeze({
52
- cssVariable: '--icon-parent',
53
- symbol: 'south_east',
54
- opticalSize: 20,
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
- 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`;