@abreen/tada 1.1.0 → 1.3.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 (94) hide show
  1. package/README.md +69 -17
  2. package/bin/tada.ts +317 -25
  3. package/bin/validators.test.ts +4 -3
  4. package/bin/validators.ts +4 -2
  5. package/build/apply-base-path-plugin.ts +24 -13
  6. package/build/build-manifest.test.ts +349 -0
  7. package/build/build-manifest.ts +165 -0
  8. package/build/bundle.ts +19 -20
  9. package/build/code.test.ts +7 -3
  10. package/build/content-watch.ts +6 -6
  11. package/build/copy.ts +2 -2
  12. package/build/deflist-id-plugin.ts +1 -1
  13. package/build/external-links-plugin.ts +2 -2
  14. package/build/features.ts +1 -1
  15. package/build/generate-content-assets.ts +12 -7
  16. package/build/generate-favicon.ts +13 -6
  17. package/build/generate-fonts.ts +2 -2
  18. package/build/generate-katex-assets.ts +35 -0
  19. package/build/generate-manifest.ts +2 -2
  20. package/build/globals.test.ts +10 -11
  21. package/build/globals.ts +14 -7
  22. package/build/json-schema.test.ts +1 -1
  23. package/build/log.test.ts +1 -1
  24. package/build/log.ts +2 -2
  25. package/build/markdown-plugins.test.ts +116 -10
  26. package/build/pagefind.test.ts +2 -2
  27. package/build/pagefind.ts +5 -5
  28. package/build/pdf-text.test.ts +52 -0
  29. package/build/pipeline.ts +42 -15
  30. package/build/reachability.test.ts +1 -1
  31. package/build/serve.ts +20 -8
  32. package/build/site-variables.ts +13 -5
  33. package/build/templates.ts +16 -7
  34. package/build/toc-plugin.test.ts +2 -2
  35. package/build/toc-plugin.ts +1 -1
  36. package/build/types.ts +15 -6
  37. package/build/util.ts +6 -6
  38. package/build/utils/code.ts +5 -5
  39. package/build/utils/content-files.ts +3 -3
  40. package/build/utils/derive-theme.test.ts +1 -1
  41. package/build/utils/derive-theme.ts +1 -1
  42. package/build/utils/file-types.test.ts +1 -1
  43. package/build/utils/front-matter.test.ts +1 -4
  44. package/build/utils/front-matter.ts +2 -2
  45. package/build/utils/jdi-runner/LiterateRunner.java +13 -0
  46. package/build/utils/literate-java.ts +24 -12
  47. package/build/utils/markdown.ts +41 -9
  48. package/build/utils/paths.test.ts +2 -2
  49. package/build/utils/paths.ts +5 -1
  50. package/build/utils/render.ts +57 -36
  51. package/build/utils/shiki-highlighter.ts +1 -1
  52. package/build/validate-internal-links-plugin.test.ts +1 -1
  53. package/build/validate-internal-links-plugin.ts +1 -1
  54. package/build/watch-reachability-state.test.ts +2 -2
  55. package/build/watch-reachability-state.ts +1 -1
  56. package/build/watch.ts +91 -110
  57. package/init/content/labs/00/VowelCounter.java.md +129 -0
  58. package/init/content/labs/00/index.md +29 -0
  59. package/init/content/labs/index.md +9 -0
  60. package/{content → init/content}/lectures/01/Pair.java.md +1 -0
  61. package/{content → init/content}/lectures/index.md +1 -1
  62. package/{content → init/content}/markdown.md +26 -7
  63. package/package.json +19 -7
  64. package/{templates → schema}/nav.schema.json +1 -2
  65. package/{build → schema}/site.schema.json +3 -5
  66. package/src/_alerts.scss +4 -0
  67. package/src/_base.scss +90 -6
  68. package/src/_layout.scss +39 -2
  69. package/src/anchor/index.ts +0 -1
  70. package/src/index.ts +3 -3
  71. package/src/question/style.scss +8 -3
  72. package/src/search/index.test.ts +203 -0
  73. package/src/search/index.ts +3 -79
  74. package/src/search/pdf-utils.ts +79 -0
  75. package/src/search/style.scss +2 -20
  76. package/src/style.scss +20 -101
  77. package/src/timezone/index.ts +9 -25
  78. package/src/util.ts +1 -1
  79. package/templates/_top.html +2 -1
  80. package/types/dev.d.ts +1 -3
  81. package/types/site-variables.d.ts +6 -10
  82. package/types/untyped-modules.d.ts +7 -0
  83. package/build/utils/jdi-runner/LiterateRunner.class +0 -0
  84. package/config/authors.json +0 -1
  85. package/config/nav.json +0 -28
  86. /package/{content → init/content}/index.md +0 -0
  87. /package/{content → init/content}/lectures/01/Rectangle.java +0 -0
  88. /package/{content → init/content}/lectures/01/demo.py +0 -0
  89. /package/{content → init/content}/lectures/01/index.md +0 -0
  90. /package/{content → init/content}/lectures/01/lecture1.pdf +0 -0
  91. /package/{content → init/content}/problem_sets/index.html +0 -0
  92. /package/{public → init/public}/avatars/alex.jpg +0 -0
  93. /package/{public → init/public}/test.txt +0 -0
  94. /package/{templates → schema}/authors.schema.json +0 -0
@@ -1,8 +1,8 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { makeLogger } from './log.js';
4
- import { isFeatureEnabled } from './features.js';
5
- import { initHighlighter } from './utils/shiki-highlighter.js';
3
+ import { makeLogger } from './log';
4
+ import { isFeatureEnabled } from './features';
5
+ import { initHighlighter } from './utils/shiki-highlighter';
6
6
  import {
7
7
  getBuildContentFiles,
8
8
  getContentDir,
@@ -11,16 +11,16 @@ import {
11
11
  renderCopiedContentAsset,
12
12
  renderLiterateJavaPageAsset,
13
13
  renderPlainTextPageAsset,
14
- } from './util.js';
15
- import { isLiterateJava } from './utils/file-types.js';
16
- import { checkJavac } from './utils/literate-java.js';
14
+ } from './util';
15
+ import { isLiterateJava } from './utils/file-types';
16
+ import { checkJavac } from './utils/literate-java';
17
17
  import type {
18
18
  SiteVariables,
19
19
  Asset,
20
20
  ContentRenderOptions,
21
21
  ContentRenderResult,
22
22
  WatchState,
23
- } from './types.js';
23
+ } from './types';
24
24
 
25
25
  const log = makeLogger(__filename);
26
26
 
@@ -102,6 +102,7 @@ export class ContentRenderer {
102
102
  renderSourceAssets(
103
103
  filePath: string,
104
104
  contentDir: string,
105
+ distDir: string,
105
106
  validInternalTargets: Set<string>,
106
107
  assetFiles: string[],
107
108
  ): Asset[] {
@@ -113,6 +114,7 @@ export class ContentRenderer {
113
114
  ...renderLiterateJavaPageAsset({
114
115
  filePath,
115
116
  contentDir,
117
+ distDir,
116
118
  siteVariables: this.siteVariables,
117
119
  assetFiles,
118
120
  skipExecution: !this.javacAvailable,
@@ -126,6 +128,7 @@ export class ContentRenderer {
126
128
  ...renderPlainTextPageAsset({
127
129
  filePath,
128
130
  contentDir,
131
+ distDir,
129
132
  siteVariables: this.siteVariables,
130
133
  validInternalTargets,
131
134
  assetFiles,
@@ -141,6 +144,7 @@ export class ContentRenderer {
141
144
  ...renderCodePageAsset({
142
145
  filePath,
143
146
  contentDir,
147
+ distDir,
144
148
  siteVariables: this.siteVariables,
145
149
  assetFiles,
146
150
  }),
@@ -274,6 +278,7 @@ export class ContentRenderer {
274
278
  assets = this.renderSourceAssets(
275
279
  filePath,
276
280
  contentDir,
281
+ distDir,
277
282
  validInternalTargets,
278
283
  assetFiles,
279
284
  );
@@ -3,10 +3,10 @@ import path from 'path';
3
3
  import * as fontkit from 'fontkit';
4
4
  import sharp from 'sharp';
5
5
  import pngToIco from 'png-to-ico';
6
- import { makeLogger } from './log.js';
7
- import { getPackageDir } from './utils/paths.js';
8
- import { deriveTheme } from './utils/derive-theme.js';
9
- import type { SiteVariables } from './types.js';
6
+ import { makeLogger } from './log';
7
+ import { getPackageDir } from './utils/paths';
8
+ import { deriveTheme } from './utils/derive-theme';
9
+ import type { SiteVariables } from './types';
10
10
 
11
11
  const log = makeLogger(__filename);
12
12
 
@@ -117,8 +117,15 @@ export async function generateFavicons(
117
117
  const svgSize = 512;
118
118
  const filenameBase = 'favicon';
119
119
 
120
- if (!color || !symbol) {
121
- throw new Error('Missing required "color" and/or "symbol" options');
120
+ if (!symbol) {
121
+ throw new Error(
122
+ 'Favicon generation requires "symbol" in site config (or "faviconSymbol")',
123
+ );
124
+ }
125
+ if (!color) {
126
+ throw new Error(
127
+ 'Favicon generation requires "themeColor" in site config (or "faviconColor")',
128
+ );
122
129
  }
123
130
 
124
131
  const { themeColorLight, textOnThemeLight } = deriveTheme(color);
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { getPackageDir } from './utils/paths.js';
4
- import { makeLogger } from './log.js';
3
+ import { getPackageDir } from './utils/paths';
4
+ import { makeLogger } from './log';
5
5
 
6
6
  const log = makeLogger(__filename);
7
7
  const FONTS_DIR = path.join(getPackageDir(), 'fonts');
@@ -0,0 +1,35 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import * as sass from 'sass';
4
+ import { makeLogger } from './log';
5
+
6
+ const log = makeLogger(__filename);
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 });
14
+
15
+ // Compile KaTeX SCSS with woff2-only font references
16
+ const katexScssDir = path.dirname(
17
+ require.resolve('katex/src/styles/katex.scss'),
18
+ );
19
+ const result = sass.compileString(
20
+ `@use 'katex' with ($use-woff2: true, $use-woff: false, $use-ttf: false, $font-folder: 'fonts');`,
21
+ { loadPaths: [katexScssDir], style: 'compressed' },
22
+ );
23
+ fs.writeFileSync(path.join(outDir, 'katex.min.css'), result.css);
24
+
25
+ // Copy woff2 fonts
26
+ const katexDistDir = path.dirname(
27
+ require.resolve('katex/dist/katex.min.css'),
28
+ );
29
+ const fontsDir = path.join(katexDistDir, 'fonts');
30
+ for (const file of fs.readdirSync(fontsDir)) {
31
+ if (file.endsWith('.woff2')) {
32
+ fs.copyFileSync(path.join(fontsDir, file), path.join(outFontsDir, file));
33
+ }
34
+ }
35
+ }
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { createApplyBasePath } from './util.js';
4
- import type { SiteVariables } from './types.js';
3
+ import { createApplyBasePath } from './util';
4
+ import type { SiteVariables } from './types';
5
5
 
6
6
  interface ManifestIcon {
7
7
  src: string;
@@ -1,10 +1,14 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import createGlobals from './globals.js';
3
- import type { SiteVariables } from './types.js';
2
+ import createGlobals from './globals';
3
+ import type { SiteVariables } from './types';
4
4
 
5
5
  const baseSite: SiteVariables = {
6
6
  base: 'http://localhost:8080',
7
7
  basePath: '/',
8
+ title: 'Test',
9
+ titlePostfix: ' - Test',
10
+ themeColor: 'hsl(195 70% 40%)',
11
+ defaultTimeZone: 'America/New_York',
8
12
  };
9
13
 
10
14
  function makeGlobals(subPath = 'index', site: Partial<SiteVariables> = {}) {
@@ -26,16 +30,11 @@ describe('createGlobals', () => {
26
30
  expect(g.cx).toBe(g.classNames);
27
31
  });
28
32
 
29
- test('timezoneChooser includes noscript when defaultTimeZone is set', () => {
33
+ test('renderTimeZoneChooser includes hidden disabled select, inline script, and noscript', () => {
30
34
  const g = makeGlobals('index', { defaultTimeZone: 'America/New_York' });
31
- expect(g.timezoneChooser).toContain('noscript');
32
- expect(g.timezoneChooser).toContain('select');
33
- });
34
-
35
- test('timezoneChooser has no noscript when defaultTimeZone is not set', () => {
36
- const g = makeGlobals('index');
37
- expect(g.timezoneChooser).toContain('select');
38
- expect(g.timezoneChooser).not.toContain('noscript');
35
+ const html = g.renderTimeZoneChooser();
36
+ expect(html).toContain('<select class="time-zone" hidden disabled>');
37
+ expect(html).toContain('<noscript>Times shown in ET.</noscript>');
39
38
  });
40
39
  });
41
40
 
package/build/globals.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import timezones from '../src/timezone/timezones.json' with { type: 'json' };
2
- import type { SiteVariables } from './types.js';
2
+ import type { SiteVariables } from './types';
3
3
 
4
4
  interface Globals {
5
5
  isHomePage: boolean;
@@ -7,7 +7,7 @@ interface Globals {
7
7
  readableDate: (date: string | Date | null | undefined) => string;
8
8
  classNames: (obj: Record<string, unknown>) => string;
9
9
  cx: (obj: Record<string, unknown>) => string;
10
- timezoneChooser: string;
10
+ renderTimeZoneChooser: () => string;
11
11
  }
12
12
 
13
13
  export default function createGlobals(
@@ -17,10 +17,17 @@ export default function createGlobals(
17
17
  ): Globals {
18
18
  const defaultTz = timezones.find(
19
19
  t => t.value === siteVariables.defaultTimeZone,
20
- );
21
- const timezoneChooser = defaultTz
22
- ? `<select class="time-zone" hidden></select><noscript>Times shown in ${defaultTz.abbreviation}.</noscript>`
23
- : '<select class="time-zone" hidden></select>';
20
+ )!;
21
+ const options = timezones
22
+ .map(tz => {
23
+ const label =
24
+ tz.value === 'UTC' ? tz.label : `${tz.label} (${tz.abbreviation})`;
25
+ const selected = tz.value === defaultTz.value ? ' selected' : '';
26
+ return `<option value="${tz.value}"${selected}>${label}</option>`;
27
+ })
28
+ .join('');
29
+ const renderTimeZoneChooser = () =>
30
+ `<span class="time-zone-label" hidden>Times shown in </span><select class="time-zone" hidden disabled>${options}</select><noscript>Times shown in ${defaultTz.abbreviation}.</noscript>`;
24
31
 
25
32
  return {
26
33
  isHomePage: subPath === 'index',
@@ -28,7 +35,7 @@ export default function createGlobals(
28
35
  readableDate,
29
36
  classNames,
30
37
  cx: classNames,
31
- timezoneChooser,
38
+ renderTimeZoneChooser,
32
39
  };
33
40
  }
34
41
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { compile, doValidation } from './json-schema.js';
2
+ import { compile, doValidation } from './json-schema';
3
3
 
4
4
  const schema = {
5
5
  type: 'object',
package/build/log.test.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { makeLogger, getFlair } from './log.js';
2
+ import { makeLogger, getFlair } from './log';
3
3
 
4
4
  function captureOutput(fn: () => void): { stdout: string; stderr: string } {
5
5
  let stdout = '';
package/build/log.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { inspect } from 'node:util';
2
2
  import path from 'path';
3
- import { Gi, L, Ri, P, Yi, Li } from './colors.js';
4
- import type { Logger } from './types.js';
3
+ import { Gi, L, Ri, P, Yi, Li } from './colors';
4
+ import type { Logger } from './types';
5
5
  import FLAIR_STRINGS from './flair.json' with { type: 'json' };
6
6
 
7
7
  const LEVELS = ['debug', 'info', 'warn', 'error'] as const;
@@ -1,16 +1,16 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
  import MarkdownIt from 'markdown-it';
3
3
  import deflist from 'markdown-it-deflist';
4
- import applyBasePathPlugin from './apply-base-path-plugin.js';
5
- import deflistIdPlugin from './deflist-id-plugin.js';
6
- import externalLinksPlugin from './external-links-plugin.js';
7
- import headingSubtitlePlugin from './heading-subtitle-plugin.js';
8
- import { createMarkdown } from './utils/markdown.js';
9
- import { stripHtmlComments } from './utils/render.js';
10
- import type { SiteVariables } from './types.js';
4
+ import applyBasePathPlugin from './apply-base-path-plugin';
5
+ import deflistIdPlugin from './deflist-id-plugin';
6
+ import externalLinksPlugin from './external-links-plugin';
7
+ import headingSubtitlePlugin from './heading-subtitle-plugin';
8
+ import { createMarkdown } from './utils/markdown';
9
+ import { stripHtmlComments, injectKatexStylesheet } from './utils/render';
10
+ import type { SiteVariables } from './types';
11
11
 
12
12
  describe('apply-base-path-plugin', () => {
13
- test('rewrites internal links, images, and raw html image sources', () => {
13
+ test('rewrites internal links, images, and raw html sources', () => {
14
14
  const md = new MarkdownIt({ html: true });
15
15
  md.use(applyBasePathPlugin, {
16
16
  basePath: '/course/',
@@ -25,12 +25,35 @@ describe('apply-base-path-plugin', () => {
25
25
  '![Image](/images/pic.png)',
26
26
  '',
27
27
  '<img src="/images/raw.png" alt="Raw">',
28
+ '',
29
+ '<a href="/about/">About</a>',
28
30
  ].join('\n'),
29
31
  );
30
32
 
31
33
  expect(html).toContain('href="/course/src/example.html?view=1#top"');
32
34
  expect(html).toContain('src="/course/images/pic.png"');
33
35
  expect(html).toContain('src="/course/images/raw.png"');
36
+ expect(html).toContain('href="/course/about/"');
37
+ });
38
+
39
+ test('does not rewrite external or relative raw html links', () => {
40
+ const md = new MarkdownIt({ html: true });
41
+ md.use(applyBasePathPlugin, {
42
+ basePath: '/course/',
43
+ codeLanguages: {},
44
+ features: { code: true },
45
+ });
46
+
47
+ const html = md.render(
48
+ [
49
+ '<a href="https://example.com/">External</a>',
50
+ '',
51
+ '<a href="relative.html">Relative</a>',
52
+ ].join('\n'),
53
+ );
54
+
55
+ expect(html).toContain('href="https://example.com/"');
56
+ expect(html).toContain('href="relative.html"');
34
57
  });
35
58
 
36
59
  test('rewrites relative code file links without applying base path', () => {
@@ -122,7 +145,11 @@ describe('custom markdown containers', () => {
122
145
  basePath: '/',
123
146
  internalDomains: [],
124
147
  codeLanguages: {},
125
- features: {},
148
+ features: { search: true, code: true, favicon: false },
149
+ title: 'Test',
150
+ titlePostfix: ' - Test',
151
+ themeColor: 'steelblue',
152
+ defaultTimeZone: 'America/New_York',
126
153
  } as SiteVariables,
127
154
  { validatorOptions: { enabled: false } },
128
155
  );
@@ -218,7 +245,11 @@ describe('hidden_fence rule', () => {
218
245
  basePath: '/',
219
246
  internalDomains: [],
220
247
  codeLanguages: {},
221
- features: {},
248
+ features: { search: true, code: true, favicon: false },
249
+ title: 'Test',
250
+ titlePostfix: ' - Test',
251
+ themeColor: 'steelblue',
252
+ defaultTimeZone: 'America/New_York',
222
253
  } as SiteVariables,
223
254
  { validatorOptions: { enabled: false } },
224
255
  );
@@ -286,3 +317,78 @@ describe('stripHtmlComments', () => {
286
317
  expect(stripHtmlComments(input)).toBe(input);
287
318
  });
288
319
  });
320
+
321
+ describe('katex plugin', () => {
322
+ function createProjectMarkdown() {
323
+ return createMarkdown(
324
+ {
325
+ base: '',
326
+ basePath: '/',
327
+ internalDomains: [],
328
+ codeLanguages: {},
329
+ features: { search: true, code: true, favicon: false },
330
+ title: 'Test',
331
+ titlePostfix: ' - Test',
332
+ themeColor: 'steelblue',
333
+ defaultTimeZone: 'America/New_York',
334
+ } as SiteVariables,
335
+ { validatorOptions: { enabled: false } },
336
+ );
337
+ }
338
+
339
+ test('renders inline math with $ delimiters', () => {
340
+ const md = createProjectMarkdown();
341
+ const html = md.render('The equation $E = mc^2$ is famous.');
342
+ expect(html).toContain('class="katex"');
343
+ expect(html).toContain('mc');
344
+ });
345
+
346
+ test('renders display math with $$ delimiters', () => {
347
+ const md = createProjectMarkdown();
348
+ const html = md.render('$$\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}$$');
349
+ expect(html).toContain('class="katex-display"');
350
+ });
351
+
352
+ test('includes aria-label for inline math', () => {
353
+ const md = createProjectMarkdown();
354
+ const html = md.render('$E = mc^2$');
355
+ expect(html).toContain('aria-label="E, equals, m, c, squared"');
356
+ });
357
+
358
+ test('includes aria-label for display math', () => {
359
+ const md = createProjectMarkdown();
360
+ const html = md.render('$$\\frac{1}{2}$$');
361
+ expect(html).toContain(
362
+ 'aria-label="start fraction, 1, divided by, 2, end fraction"',
363
+ );
364
+ });
365
+
366
+ test('throws on invalid LaTeX syntax', () => {
367
+ const md = createProjectMarkdown();
368
+ expect(() => md.render('$\\invalidcommand{$')).toThrow();
369
+ });
370
+
371
+ test('does not produce katex output when no math delimiters are used', () => {
372
+ const md = createProjectMarkdown();
373
+ const html = md.render('Just a normal paragraph with a $5 price tag.');
374
+ expect(html).not.toContain('class="katex"');
375
+ });
376
+ });
377
+
378
+ describe('injectKatexStylesheet', () => {
379
+ test('injects deferred KaTeX stylesheet link into <head>', () => {
380
+ const html =
381
+ '<html><head><meta charset="UTF-8"></head><body></body></html>';
382
+ const result = injectKatexStylesheet(html, p => p);
383
+
384
+ expect(result).toContain('href="/katex/katex.min.css"');
385
+ expect(result).toContain('rel="stylesheet"');
386
+ });
387
+
388
+ test('applies basePath to the stylesheet URL', () => {
389
+ const html = '<html><head></head><body></body></html>';
390
+ const result = injectKatexStylesheet(html, p => '/course' + p);
391
+
392
+ expect(result).toContain('href="/course/katex/katex.min.css"');
393
+ });
394
+ });
@@ -1,7 +1,7 @@
1
1
  import path from 'path';
2
2
  import { describe, expect, test } from 'bun:test';
3
- import { buildIndex, collectIndexTargets } from './pagefind.js';
4
- import type { SiteVariables } from './types.js';
3
+ import { buildIndex, collectIndexTargets } from './pagefind';
4
+ import type { SiteVariables } from './types';
5
5
 
6
6
  interface FakePagefindCalls {
7
7
  htmlFiles?: { sourcePath: string; content: string }[];
package/build/pagefind.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  import path from 'path';
2
- import { makeLogger } from './log.js';
3
- import { collectReachableSiteAssets } from './reachability.js';
2
+ import { makeLogger } from './log';
3
+ import { collectReachableSiteAssets } from './reachability';
4
4
  import {
5
5
  getContentDir,
6
6
  getFilesByExtensions,
7
7
  normalizeOutputPath,
8
- } from './util.js';
9
- import { assertMutoolAvailable, extractPdfPages } from './pdf-text.js';
10
- import type { SiteVariables } from './types.js';
8
+ } from './util';
9
+ import { assertMutoolAvailable, extractPdfPages } from './pdf-text';
10
+ import type { SiteVariables } from './types';
11
11
 
12
12
  const log = makeLogger(__filename);
13
13
  const PAGEFIND_VERBOSE = process.env.TADA_LOG_LEVEL === 'debug';
@@ -0,0 +1,52 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { buildPdfPageRecords } from './pdf-text';
3
+
4
+ describe('buildPdfPageRecords', () => {
5
+ test('creates page records with 1-based page numbers', () => {
6
+ const result = buildPdfPageRecords(['Hello world', 'Second page']);
7
+ expect(result).toEqual({
8
+ pages: [
9
+ { pageNumber: 1, content: 'Hello world' },
10
+ { pageNumber: 2, content: 'Second page' },
11
+ ],
12
+ hasExtractedText: true,
13
+ });
14
+ });
15
+
16
+ test('normalizes whitespace in extracted text', () => {
17
+ const result = buildPdfPageRecords([' lots of\n\tspaces ']);
18
+ expect(result).toEqual({
19
+ pages: [{ pageNumber: 1, content: 'lots of spaces' }],
20
+ hasExtractedText: true,
21
+ });
22
+ });
23
+
24
+ test('skips blank pages', () => {
25
+ const result = buildPdfPageRecords(['Page one', '', ' ', 'Page four']);
26
+ expect(result).toEqual({
27
+ pages: [
28
+ { pageNumber: 1, content: 'Page one' },
29
+ { pageNumber: 4, content: 'Page four' },
30
+ ],
31
+ hasExtractedText: true,
32
+ });
33
+ });
34
+
35
+ test('returns hasExtractedText false when all pages are blank', () => {
36
+ const result = buildPdfPageRecords(['', ' ', '\n\t']);
37
+ expect(result).toEqual({ pages: [], hasExtractedText: false });
38
+ });
39
+
40
+ test('returns hasExtractedText false for empty input', () => {
41
+ const result = buildPdfPageRecords([]);
42
+ expect(result).toEqual({ pages: [], hasExtractedText: false });
43
+ });
44
+
45
+ test('handles a single page', () => {
46
+ const result = buildPdfPageRecords(['Only page']);
47
+ expect(result).toEqual({
48
+ pages: [{ pageNumber: 1, content: 'Only page' }],
49
+ hasExtractedText: true,
50
+ });
51
+ });
52
+ });
package/build/pipeline.ts CHANGED
@@ -1,25 +1,43 @@
1
1
  #!/usr/bin/env bun
2
2
  import fs from 'fs';
3
- import { getDevSiteVariables, getProdSiteVariables } from './site-variables.js';
4
- import { compileTemplates } from './templates.js';
5
- import { getDistDir, getContentDir, getPublicDir } from './utils/paths.js';
6
- import { isFeatureEnabled } from './features.js';
7
- import { bundle } from './bundle.js';
8
- import { copyFonts } from './generate-fonts.js';
9
- import { generateFavicons } from './generate-favicon.js';
10
- import { generateManifest } from './generate-manifest.js';
11
- import { copyPublicFiles, copyContentAssets } from './copy.js';
12
- import { getProcessedExtensions } from './utils/file-types.js';
13
- import { ContentRenderer } from './generate-content-assets.js';
14
- import { runPagefind } from './pagefind.js';
15
- import { makeLogger, printFlair } from './log.js';
3
+ import { getDevSiteVariables, getProdSiteVariables } from './site-variables';
4
+ import { compileTemplates } from './templates';
5
+ import {
6
+ getDistDir,
7
+ getProdDistDir,
8
+ getContentDir,
9
+ getPublicDir,
10
+ } from './utils/paths';
11
+ import { isFeatureEnabled } from './features';
12
+ import { bundle } from './bundle';
13
+ import { copyFonts } from './generate-fonts';
14
+ import { copyKatexAssets } from './generate-katex-assets';
15
+ import { generateFavicons } from './generate-favicon';
16
+ import { generateManifest } from './generate-manifest';
17
+ import { copyPublicFiles, copyContentAssets } from './copy';
18
+ import { getProcessedExtensions } from './utils/file-types';
19
+ import { ContentRenderer } from './generate-content-assets';
20
+ import { runPagefind } from './pagefind';
21
+ import { makeLogger, printFlair } from './log';
22
+ import { generateBuildManifest, getNextVersion } from './build-manifest';
23
+ import path from 'path';
16
24
 
17
25
  const log = makeLogger(__filename);
18
26
 
19
27
  async function runPipeline(mode: 'development' | 'production'): Promise<void> {
20
28
  const isDev = mode === 'development';
21
29
  const siteVariables = isDev ? getDevSiteVariables() : getProdSiteVariables();
22
- const distDir = getDistDir();
30
+ let distDir: string;
31
+ let prodVersion: number;
32
+
33
+ if (isDev) {
34
+ distDir = getDistDir();
35
+ } else {
36
+ const prodBase = getProdDistDir();
37
+ prodVersion = getNextVersion(prodBase);
38
+ distDir = path.join(prodBase, `v${prodVersion}`);
39
+ }
40
+
23
41
  const contentDir = getContentDir();
24
42
  const publicDir = getPublicDir();
25
43
 
@@ -33,8 +51,9 @@ async function runPipeline(mode: 'development' | 'production'): Promise<void> {
33
51
 
34
52
  // Phase 2: Bundle + assets (parallel)
35
53
  const parallelTasks: (Promise<unknown> | void)[] = [
36
- bundle(siteVariables, { mode }),
54
+ bundle(siteVariables, { mode, distDir }),
37
55
  copyFonts(distDir),
56
+ copyKatexAssets(distDir),
38
57
  ];
39
58
 
40
59
  if (isFeatureEnabled(siteVariables, 'favicon')) {
@@ -72,6 +91,14 @@ async function runPipeline(mode: 'development' | 'production'): Promise<void> {
72
91
  await runPagefind({ siteVariables, distPath: distDir, htmlAssetsByPath });
73
92
  }
74
93
 
94
+ // Phase 5: Build manifest (production only)
95
+ if (!isDev) {
96
+ const prodBase = getProdDistDir();
97
+ const manifestPath = path.join(prodBase, `v${prodVersion!}.manifest.json`);
98
+ await generateBuildManifest(distDir, manifestPath);
99
+ log.info`Built dist-prod/v${prodVersion!}/`;
100
+ }
101
+
75
102
  printFlair();
76
103
  }
77
104
 
@@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test';
2
2
  import {
3
3
  collectDirectSiteAssetLinks,
4
4
  collectReachableSiteAssets,
5
- } from './reachability.js';
5
+ } from './reachability';
6
6
 
7
7
  describe('reachability', () => {
8
8
  test('collectDirectSiteAssetLinks resolves relative links and ignores excluded links', () => {