@abreen/tada 1.4.1 → 1.5.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 (103) hide show
  1. package/README.md +4 -2
  2. package/bin/tada.ts +81 -57
  3. package/build/apply-base-path-plugin.ts +22 -6
  4. package/build/code.test.ts +108 -1
  5. package/build/colors.test.ts +66 -0
  6. package/build/columns-plugin.ts +116 -0
  7. package/build/content-watch.ts +30 -21
  8. package/build/copy.ts +1 -1
  9. package/build/external-links-plugin.ts +1 -1
  10. package/build/generate-content-assets.ts +48 -6
  11. package/build/generate-favicon.ts +1 -1
  12. package/build/generate-fonts.ts +1 -1
  13. package/build/generate-katex-assets.ts +1 -1
  14. package/build/log.test.ts +7 -1
  15. package/build/log.ts +4 -1
  16. package/build/markdown-plugins.test.ts +65 -3
  17. package/build/pagefind.ts +1 -1
  18. package/build/pipeline.ts +4 -20
  19. package/build/serve.test.ts +59 -0
  20. package/build/serve.ts +29 -63
  21. package/build/templates.ts +2 -2
  22. package/build/toc-plugin.test.ts +40 -25
  23. package/build/toc-plugin.ts +9 -2
  24. package/build/{types.ts → types.d.ts} +88 -1
  25. package/build/util.ts +1 -1
  26. package/build/utils/code.ts +76 -3
  27. package/build/utils/content-files.test.ts +56 -0
  28. package/build/utils/content-files.ts +15 -11
  29. package/build/utils/file-types.test.ts +18 -0
  30. package/build/utils/file-types.ts +4 -0
  31. package/build/utils/include.test.ts +117 -0
  32. package/build/utils/include.ts +53 -0
  33. package/build/utils/jdi-runner/TraceRunner.java +416 -0
  34. package/build/utils/literate-java.ts +2 -2
  35. package/build/utils/markdown.ts +32 -14
  36. package/build/utils/paths.ts +1 -1
  37. package/build/utils/render.ts +102 -11
  38. package/build/utils/shiki-highlighter.ts +1 -1
  39. package/build/utils/trace-layout.test.ts +570 -0
  40. package/build/utils/trace-layout.ts +848 -0
  41. package/build/utils/trace-svg.test.ts +425 -0
  42. package/build/utils/trace-svg.ts +905 -0
  43. package/build/utils/trace.test.ts +169 -0
  44. package/build/utils/trace.ts +382 -0
  45. package/build/validate-internal-links-plugin.test.ts +30 -2
  46. package/build/validate-internal-links-plugin.ts +3 -3
  47. package/build/watch-reload-client.ts +4 -20
  48. package/build/watch.ts +436 -448
  49. package/init/content/index.md +10 -5
  50. package/init/content/labs/00/index.md +1 -1
  51. package/init/content/labs/01/SearchTreeDemo.java +31 -0
  52. package/init/content/labs/01/TraceDemo.java +22 -0
  53. package/init/content/labs/01/index.md +27 -0
  54. package/init/content/labs/index.md +2 -0
  55. package/init/content/lectures/01/Rectangle.java +2 -0
  56. package/init/content/lectures/01/index.md +2 -1
  57. package/init/content/lectures/01/rectangle.py +45 -0
  58. package/init/content/lectures/02/_pr1.md +5 -0
  59. package/init/content/lectures/02/index.md +11 -0
  60. package/init/content/lectures/02/subdir/_foobar.html +3 -0
  61. package/init/content/lectures/02/subdir/_pr2.md +6 -0
  62. package/init/content/lectures/index.md +3 -0
  63. package/init/content/markdown.md +30 -0
  64. package/package.json +21 -4
  65. package/src/_base.scss +13 -2
  66. package/src/_layout.scss +6 -5
  67. package/src/anchor/index.test.ts +64 -0
  68. package/src/code/index.test.ts +71 -0
  69. package/src/code/style.scss +23 -0
  70. package/src/code.scss +5 -3
  71. package/src/header/_base.scss +48 -9
  72. package/src/header/index.test.ts +89 -0
  73. package/src/header/index.ts +1 -1
  74. package/src/header/style.scss +1 -5
  75. package/src/index.ts +18 -20
  76. package/src/literate/style.scss +15 -7
  77. package/src/navigate/eligible.test.ts +101 -0
  78. package/src/navigate/eligible.ts +62 -0
  79. package/src/navigate/index.test.ts +82 -0
  80. package/src/navigate/index.ts +365 -0
  81. package/src/navigate/lifecycle.test.ts +26 -0
  82. package/src/navigate/lifecycle.ts +65 -0
  83. package/src/navigate/style.scss +61 -0
  84. package/src/print/index.test.ts +71 -0
  85. package/src/question/index.test.ts +82 -0
  86. package/src/question/style.scss +12 -0
  87. package/src/search/index.ts +38 -14
  88. package/src/search/style.scss +23 -14
  89. package/src/style.scss +25 -12
  90. package/src/timezone/index.test.ts +301 -1
  91. package/src/timezone/index.ts +68 -56
  92. package/src/toc/index.test.ts +237 -0
  93. package/src/toc/index.ts +29 -69
  94. package/src/toc/style.scss +17 -0
  95. package/src/top/index.test.ts +46 -0
  96. package/src/top/index.ts +2 -2
  97. package/src/trace/index.test.ts +554 -0
  98. package/src/trace/index.ts +258 -0
  99. package/src/trace/style.scss +210 -0
  100. package/src/trace/types.d.ts +47 -0
  101. package/src/util.test.ts +135 -0
  102. package/templates/_top.html +23 -22
  103. package/types/jsdom.d.ts +9 -0
@@ -24,7 +24,7 @@ import type {
24
24
  WatchState,
25
25
  } from './types';
26
26
 
27
- const log = makeLogger(__filename);
27
+ const log = makeLogger(import.meta.url);
28
28
 
29
29
  export class ContentRenderer {
30
30
  private siteVariables: SiteVariables;
@@ -32,6 +32,15 @@ export class ContentRenderer {
32
32
  private sourceFileCache: Map<string, Asset[]>;
33
33
  private lastBuildFiles: Set<string>;
34
34
  private javacAvailable: boolean | undefined;
35
+ private traceCache: Map<
36
+ string,
37
+ {
38
+ manifestUrl: string;
39
+ highlightedSource: string;
40
+ totalSteps: number;
41
+ mtime: number;
42
+ }
43
+ > = new Map();
35
44
 
36
45
  constructor(siteVariables: SiteVariables) {
37
46
  this.siteVariables = siteVariables || {};
@@ -53,16 +62,26 @@ export class ContentRenderer {
53
62
 
54
63
  getDirtySourceFiles(
55
64
  buildContentFiles: string[],
56
- { changedContentFiles, templatesChanged }: WatchState = {},
65
+ { changedContentFiles, jsonDataChanged, partialsChanged }: WatchState = {},
57
66
  ): Set<string> {
58
67
  if (
59
68
  this.lastBuildFiles.size === 0 ||
60
69
  !changedContentFiles ||
61
- templatesChanged
70
+ jsonDataChanged ||
71
+ partialsChanged
62
72
  ) {
63
73
  return new Set(buildContentFiles);
64
74
  }
65
75
 
76
+ // If any .java file changed, the trace cache will be stale, so rebuild
77
+ // all content pages to pick up the new trace output.
78
+ const javaFileChanged = [...changedContentFiles].some(f =>
79
+ f.endsWith('.java'),
80
+ );
81
+ if (javaFileChanged) {
82
+ return new Set(buildContentFiles);
83
+ }
84
+
66
85
  const buildFileSet = new Set(buildContentFiles);
67
86
  const dirtySourceFiles = new Set<string>();
68
87
  for (const filePath of changedContentFiles) {
@@ -107,6 +126,7 @@ export class ContentRenderer {
107
126
  distDir: string,
108
127
  validInternalTargets: Set<string>,
109
128
  assetFiles: string[],
129
+ literateJavaOutputPaths: Set<string>,
110
130
  ): Asset[] {
111
131
  const ext = path.extname(filePath).toLowerCase();
112
132
  const assets: Asset[] = [];
@@ -120,6 +140,8 @@ export class ContentRenderer {
120
140
  siteVariables: this.siteVariables,
121
141
  assetFiles,
122
142
  skipExecution: !this.javacAvailable,
143
+ validInternalTargets,
144
+ literateJavaOutputPaths,
123
145
  }),
124
146
  );
125
147
  return assets;
@@ -134,6 +156,9 @@ export class ContentRenderer {
134
156
  siteVariables: this.siteVariables,
135
157
  validInternalTargets,
136
158
  assetFiles,
159
+ literateJavaOutputPaths,
160
+ traceCache: this.traceCache,
161
+ javacAvailable: this.javacAvailable,
137
162
  }),
138
163
  );
139
164
  return assets;
@@ -166,7 +191,11 @@ export class ContentRenderer {
166
191
  contentDir: string,
167
192
  ): void {
168
193
  for (const filePath of copiedSourceFiles) {
169
- for (const asset of renderCopiedContentAsset({ filePath, contentDir })) {
194
+ for (const asset of renderCopiedContentAsset({
195
+ filePath,
196
+ contentDir,
197
+ siteVariables: this.siteVariables,
198
+ })) {
170
199
  const outPath = path.join(distDir, asset.assetPath);
171
200
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
172
201
  fs.writeFileSync(outPath, asset.content);
@@ -256,7 +285,20 @@ export class ContentRenderer {
256
285
  contentDir,
257
286
  buildContentFiles,
258
287
  Object.keys(this.siteVariables.codeLanguages || {}),
288
+ isFeatureEnabled(this.siteVariables, 'code'),
259
289
  );
290
+ const literateJavaOutputPaths = new Set<string>();
291
+ for (const filePath of buildContentFiles) {
292
+ if (isLiterateJava(filePath)) {
293
+ const parsed = path.parse(path.relative(contentDir, filePath));
294
+ const javaPath = path
295
+ .join(parsed.dir, parsed.name)
296
+ .split(path.sep)
297
+ .join(path.posix.sep);
298
+ literateJavaOutputPaths.add(`/${javaPath}`);
299
+ }
300
+ }
301
+
260
302
  const errors: Error[] = [];
261
303
 
262
304
  const configLinkErrors = validateConfigLinks(
@@ -275,8 +317,7 @@ export class ContentRenderer {
275
317
  log.info`Processing ${dirtySourceFiles.size} content ${noun}`;
276
318
  }
277
319
 
278
- const hasLiterateJava = [...dirtySourceFiles].some(isLiterateJava);
279
- if (hasLiterateJava && this.javacAvailable === undefined) {
320
+ if (this.javacAvailable === undefined && dirtySourceFiles.size > 0) {
280
321
  this.javacAvailable = checkJavac();
281
322
  if (!this.javacAvailable) {
282
323
  log.warn`javac was not found; literate Java pages will not include execution output`;
@@ -292,6 +333,7 @@ export class ContentRenderer {
292
333
  distDir,
293
334
  validInternalTargets,
294
335
  assetFiles,
336
+ literateJavaOutputPaths,
295
337
  );
296
338
  } catch (err) {
297
339
  errors.push(err as Error);
@@ -8,7 +8,7 @@ import { getPackageDir } from './utils/paths';
8
8
  import { deriveTheme } from './utils/derive-theme';
9
9
  import type { SiteVariables } from './types';
10
10
 
11
- const log = makeLogger(__filename);
11
+ const log = makeLogger(import.meta.url);
12
12
 
13
13
  const FONT_PATH = path.join(
14
14
  getPackageDir(),
@@ -3,7 +3,7 @@ import path from 'path';
3
3
  import { getPackageDir } from './utils/paths';
4
4
  import { makeLogger } from './log';
5
5
 
6
- const log = makeLogger(__filename);
6
+ const log = makeLogger(import.meta.url);
7
7
  const FONTS_DIR = path.join(getPackageDir(), 'fonts');
8
8
 
9
9
  export function copyFonts(distDir: string): void {
@@ -3,7 +3,7 @@ import path from 'path';
3
3
  import * as sass from 'sass';
4
4
  import { makeLogger } from './log';
5
5
 
6
- const log = makeLogger(__filename);
6
+ const log = makeLogger(import.meta.url);
7
7
 
8
8
  export function copyKatexAssets(distDir: string): void {
9
9
  log.info`Copying KaTeX assets`;
package/build/log.test.ts CHANGED
@@ -88,12 +88,18 @@ describe('makeLogger', () => {
88
88
  expect(log.minLogLevel).toBe('info');
89
89
  });
90
90
 
91
- test('handles __filename-style name', () => {
91
+ test('handles path-style name', () => {
92
92
  const log = makeLogger('/path/to/module.ts');
93
93
  const { stdout } = captureOutput(() => log.info`test`);
94
94
  expect(stdout).toContain('test');
95
95
  });
96
96
 
97
+ test('handles file:// URL name', () => {
98
+ const log = makeLogger('file:///path/to/module.ts');
99
+ const { stdout } = captureOutput(() => log.info`test`);
100
+ expect(stdout).toContain('test');
101
+ });
102
+
97
103
  test('interpolates objects in template', () => {
98
104
  const log = makeLogger('test');
99
105
  const obj = { key: 'value' };
package/build/log.ts CHANGED
@@ -51,7 +51,10 @@ export function makeLogger(name: string, logLevel: string = 'info'): Logger {
51
51
  if (!name) {
52
52
  name = '';
53
53
  } else {
54
- // Allow for passing __filename
54
+ // Allow for passing import.meta.url (file:// URL) or a plain path
55
+ if (name.startsWith('file://')) {
56
+ name = new URL(name).pathname;
57
+ }
55
58
  name = path.basename(name, path.extname(name));
56
59
  }
57
60
 
@@ -5,6 +5,7 @@ import applyBasePathPlugin from './apply-base-path-plugin';
5
5
  import deflistIdPlugin from './deflist-id-plugin';
6
6
  import externalLinksPlugin from './external-links-plugin';
7
7
  import headingSubtitlePlugin from './heading-subtitle-plugin';
8
+ import columnsPlugin from './columns-plugin';
8
9
  import { createMarkdown } from './utils/markdown';
9
10
  import { stripHtmlComments, injectKatexStylesheet } from './utils/render';
10
11
  import type { SiteVariables } from './types';
@@ -30,7 +31,7 @@ describe('apply-base-path-plugin', () => {
30
31
  ].join('\n'),
31
32
  );
32
33
 
33
- expect(html).toContain('href="/course/src/example.html?view=1#top"');
34
+ expect(html).toContain('href="/course/src/example.java.html?view=1#top"');
34
35
  expect(html).toContain('src="/course/images/pic.png"');
35
36
  expect(html).toContain('src="/course/images/raw.png"');
36
37
  expect(html).toContain('href="/course/about/"');
@@ -66,7 +67,7 @@ describe('apply-base-path-plugin', () => {
66
67
 
67
68
  const html = md.render('[App](./App.java)');
68
69
 
69
- expect(html).toContain('href="./App.html"');
70
+ expect(html).toContain('href="./App.java.html"');
70
71
  expect(html).not.toContain('href="./App.java"');
71
72
  });
72
73
 
@@ -230,11 +231,40 @@ describe('custom markdown containers', () => {
230
231
  expect(html).toContain('<div class="alert note">');
231
232
  expect(html).toContain('<p class="title" id="read-this">');
232
233
  expect(html).toContain(
233
- '<div class="alert warning"><p class="title">Warning</p>',
234
+ '<div class="alert warning"><p class="title" id="warning">Warning</p>',
234
235
  );
235
236
  expect(html).toContain('<p>Body</p>');
236
237
  expect(html).toContain('<p>Careful</p>');
237
238
  });
239
+
240
+ test('deduplicates alert IDs', () => {
241
+ const md = createProjectMarkdown();
242
+
243
+ const html = md.render(
244
+ [
245
+ '!!! note',
246
+ 'First',
247
+ '!!!',
248
+ '',
249
+ '!!! note',
250
+ 'Second',
251
+ '!!!',
252
+ '',
253
+ '!!! note "Custom"',
254
+ 'Third',
255
+ '!!!',
256
+ '',
257
+ '!!! note "Custom"',
258
+ 'Fourth',
259
+ '!!!',
260
+ ].join('\n'),
261
+ );
262
+
263
+ expect(html).toContain('<p class="title" id="note">Note</p>');
264
+ expect(html).toContain('<p class="title" id="note-2">Note</p>');
265
+ expect(html).toContain('id="custom"');
266
+ expect(html).toContain('id="custom-2"');
267
+ });
238
268
  });
239
269
 
240
270
  describe('hidden_fence rule', () => {
@@ -375,6 +405,38 @@ describe('katex plugin', () => {
375
405
  });
376
406
  });
377
407
 
408
+ describe('columns plugin', () => {
409
+ const md = new MarkdownIt().use(columnsPlugin);
410
+
411
+ test('basic two-column layout', () => {
412
+ const html = md.render('+++\nCol 1\n+++\nCol 2\n+++\n');
413
+ expect(html).toContain('<div class="columns">');
414
+ expect(html).toContain('<p>Col 1</p>');
415
+ expect(html).toContain('<p>Col 2</p>');
416
+ // Two column divs inside the wrapper
417
+ expect(html).toMatch(
418
+ /<div class="columns">\n<div>\n.*<\/div>\n<div>\n.*<\/div>\n<\/div>/s,
419
+ );
420
+ });
421
+
422
+ test('rich markdown inside columns', () => {
423
+ const html = md.render('+++\n## Heading\n- item\n+++\nA paragraph.\n+++\n');
424
+ expect(html).toContain('<h2>Heading</h2>');
425
+ expect(html).toContain('<li>');
426
+ expect(html).toContain('<p>A paragraph.</p>');
427
+ });
428
+
429
+ test('incomplete block with only two fences is not matched', () => {
430
+ const html = md.render('+++\nContent\n+++\n');
431
+ expect(html).not.toContain('<div class="columns">');
432
+ });
433
+
434
+ test('empty columns produce no content errors', () => {
435
+ const html = md.render('+++\n+++\n+++\n');
436
+ expect(html).toContain('<div class="columns">');
437
+ });
438
+ });
439
+
378
440
  describe('injectKatexStylesheet', () => {
379
441
  test('injects deferred KaTeX stylesheet link into <head>', () => {
380
442
  const html =
package/build/pagefind.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  import { assertMutoolAvailable, extractPdfPages } from './pdf-text';
10
10
  import type { SiteVariables } from './types';
11
11
 
12
- const log = makeLogger(__filename);
12
+ const log = makeLogger(import.meta.url);
13
13
  const PAGEFIND_VERBOSE = process.env.TADA_LOG_LEVEL === 'debug';
14
14
  const PAGEFIND_OUTPUT_SUBDIR = 'pagefind';
15
15
 
package/build/pipeline.ts CHANGED
@@ -1,4 +1,3 @@
1
- #!/usr/bin/env bun
2
1
  import fs from 'fs';
3
2
  import { getDevSiteVariables, getProdSiteVariables } from './site-variables';
4
3
  import { compileTemplates } from './templates';
@@ -22,9 +21,11 @@ import { makeLogger, printFlair } from './log';
22
21
  import { generateBuildManifest, getNextVersion } from './build-manifest';
23
22
  import path from 'path';
24
23
 
25
- const log = makeLogger(__filename);
24
+ const log = makeLogger(import.meta.url);
26
25
 
27
- async function runPipeline(mode: 'development' | 'production'): Promise<void> {
26
+ export async function runPipeline(
27
+ mode: 'development' | 'production',
28
+ ): Promise<void> {
28
29
  const isDev = mode === 'development';
29
30
  const siteVariables = isDev ? getDevSiteVariables() : getProdSiteVariables();
30
31
  let distDir: string;
@@ -100,20 +101,3 @@ async function runPipeline(mode: 'development' | 'production'): Promise<void> {
100
101
 
101
102
  printFlair();
102
103
  }
103
-
104
- // CLI entry point
105
- const arg = process.argv[2];
106
- if (arg === 'dev') {
107
- runPipeline('development').catch(err => {
108
- log.error`Build failed: ${err.message}`;
109
- process.exit(1);
110
- });
111
- } else if (arg === 'prod') {
112
- runPipeline('production').catch(err => {
113
- log.error`Build failed: ${err.message}`;
114
- process.exit(1);
115
- });
116
- } else {
117
- console.error('Usage: pipeline.js <dev|prod>');
118
- process.exit(1);
119
- }
@@ -0,0 +1,59 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { resolvePathname } from './serve';
5
+
6
+ let tmpDir: string;
7
+
8
+ beforeAll(() => {
9
+ tmpDir = fs.mkdtempSync('/tmp/serve-test-');
10
+ fs.writeFileSync(path.join(tmpDir, 'index.html'), '<h1>hello</h1>');
11
+ fs.mkdirSync(path.join(tmpDir, 'sub'));
12
+ fs.writeFileSync(path.join(tmpDir, 'sub', 'page.html'), '<p>page</p>');
13
+ });
14
+
15
+ afterAll(() => {
16
+ fs.rmSync(tmpDir, { recursive: true });
17
+ });
18
+
19
+ describe('resolvePathname', () => {
20
+ test('resolves a file at the root', () => {
21
+ const result = resolvePathname(tmpDir, '/index.html');
22
+ expect(result).not.toBeNull();
23
+ expect(result!.filePath).toBe(path.join(tmpDir, 'index.html'));
24
+ expect(result!.mtime).toBeInstanceOf(Date);
25
+ });
26
+
27
+ test('resolves a file in a subdirectory', () => {
28
+ const result = resolvePathname(tmpDir, '/sub/page.html');
29
+ expect(result).not.toBeNull();
30
+ expect(result!.filePath).toBe(path.join(tmpDir, 'sub', 'page.html'));
31
+ });
32
+
33
+ test('returns null for nonexistent file', () => {
34
+ expect(resolvePathname(tmpDir, '/missing.html')).toBeNull();
35
+ });
36
+
37
+ test('returns null for directory', () => {
38
+ expect(resolvePathname(tmpDir, '/sub')).toBeNull();
39
+ });
40
+
41
+ test('returns null for path traversal', () => {
42
+ expect(resolvePathname(tmpDir, '/../etc/passwd')).toBeNull();
43
+ });
44
+
45
+ test('returns null for encoded path traversal', () => {
46
+ expect(resolvePathname(tmpDir, '/%2e%2e/etc/passwd')).toBeNull();
47
+ });
48
+
49
+ test('returns null for invalid URL encoding', () => {
50
+ expect(resolvePathname(tmpDir, '/%ZZ')).toBeNull();
51
+ });
52
+
53
+ test('decodes percent-encoded path', () => {
54
+ fs.writeFileSync(path.join(tmpDir, 'spaced file.html'), 'ok');
55
+ const result = resolvePathname(tmpDir, '/spaced%20file.html');
56
+ expect(result).not.toBeNull();
57
+ expect(result!.filePath).toBe(path.join(tmpDir, 'spaced file.html'));
58
+ });
59
+ });
package/build/serve.ts CHANGED
@@ -1,26 +1,12 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { getDistDir } from './util';
4
3
  import { makeLogger } from './log';
5
4
  import { B } from './colors';
6
5
 
7
- const log = makeLogger(__filename);
6
+ const log = makeLogger(import.meta.url);
8
7
 
9
- function messageReady(port: number): void {
10
- if (process.send) {
11
- process.send({ ready: true, port });
12
- }
13
- }
14
-
15
- let distDir: string;
16
- try {
17
- distDir = getDistDir();
18
- } catch (err) {
19
- log.error`Failed to start server: ${err}`;
20
- process.exit(1);
21
- }
22
-
23
- function resolvePathname(
8
+ export function resolvePathname(
9
+ distDir: string,
24
10
  pathname: string,
25
11
  ): { filePath: string; mtime: Date } | null {
26
12
  let decodedPath: string;
@@ -54,45 +40,35 @@ function resolvePathname(
54
40
  return { filePath: resolvedPath, mtime: stat.mtime };
55
41
  }
56
42
 
57
- function createResponse(req: Request): Response {
58
- const url = new URL(req.url);
59
- const result = resolvePathname(url.pathname);
60
- if (!result) {
61
- return new Response('Not Found', { status: 404 });
62
- }
43
+ export interface StartServerOptions {
44
+ port?: number;
45
+ distDir: string;
46
+ onReady?: (port: number) => void;
47
+ }
63
48
 
64
- const headers = {
65
- 'Cache-Control': 'no-cache',
66
- 'Last-Modified': result.mtime.toUTCString(),
67
- };
49
+ export function startServer(options: StartServerOptions): void {
50
+ const { port = 8080, distDir, onReady } = options;
68
51
 
69
- const ims = req.headers.get('If-Modified-Since');
70
- if (ims && new Date(ims) >= new Date(headers['Last-Modified'])) {
71
- return new Response(null, { status: 304, headers });
72
- }
52
+ function createResponse(req: Request): Response {
53
+ const url = new URL(req.url);
54
+ const result = resolvePathname(distDir, url.pathname);
55
+ if (!result) {
56
+ return new Response('Not Found', { status: 404 });
57
+ }
73
58
 
74
- return new Response(Bun.file(result.filePath), { headers });
75
- }
59
+ const headers = {
60
+ 'Cache-Control': 'no-cache',
61
+ 'Last-Modified': result.mtime.toUTCString(),
62
+ };
76
63
 
77
- function getPortArg(): number {
78
- const idx = process.argv.indexOf('--port');
79
- if (idx === -1) {
80
- return 8080;
81
- }
82
- const raw = process.argv[idx + 1];
83
- if (!raw) {
84
- log.error`--port requires a value`;
85
- process.exit(1);
86
- }
87
- const port = parseInt(raw, 10);
88
- if (isNaN(port) || port <= 0 || port >= 65536) {
89
- log.error`Invalid port value: ${raw}`;
90
- process.exit(1);
64
+ const ims = req.headers.get('If-Modified-Since');
65
+ if (ims && new Date(ims) >= new Date(headers['Last-Modified'])) {
66
+ return new Response(null, { status: 304, headers });
67
+ }
68
+
69
+ return new Response(Bun.file(result.filePath), { headers });
91
70
  }
92
- return port;
93
- }
94
71
 
95
- function listen(port: number): void {
96
72
  try {
97
73
  const server = Bun.serve({
98
74
  port,
@@ -104,21 +80,11 @@ function listen(port: number): void {
104
80
  });
105
81
 
106
82
  log.info`Dev server: ${B`http://localhost:${server.port}/index.html`}`;
107
- messageReady(server.port as number);
83
+ if (onReady) {
84
+ onReady(server.port as number);
85
+ }
108
86
  } catch (err) {
109
87
  log.error`Failed to start server on port ${port}: ${err}`;
110
88
  process.exit(1);
111
89
  }
112
90
  }
113
-
114
- listen(getPortArg());
115
-
116
- process.on('uncaughtException', err => {
117
- log.error`Uncaught exception: ${err}`;
118
- process.exit(1);
119
- });
120
-
121
- process.on('unhandledRejection', reason => {
122
- log.error`Unhandled rejection: ${reason}`;
123
- process.exit(1);
124
- });
@@ -7,7 +7,7 @@ import { getPackageDir, getConfigDir } from './utils/paths';
7
7
  import type { ValidateFunction } from 'ajv';
8
8
  import type { Logger, SiteVariables } from './types';
9
9
 
10
- const log: Logger = makeLogger(__filename);
10
+ const log: Logger = makeLogger(import.meta.url);
11
11
 
12
12
  // Store all templates in memory (don't read template files during build)
13
13
  const templates: Record<string, string> = {};
@@ -120,7 +120,7 @@ export function compileTemplates(
120
120
  log.debug`Reading ${fileName}`;
121
121
  jsonData[fileName] = JSON.parse(
122
122
  _.template(fs.readFileSync(filePath, 'utf-8'))({
123
- ...(siteVariables.vars || {}),
123
+ vars: siteVariables.vars || {},
124
124
  site: siteVariables,
125
125
  base: siteVariables.base,
126
126
  basePath: siteVariables.basePath,
@@ -4,20 +4,14 @@ import type { JavaTocEntry } from './types';
4
4
 
5
5
  describe('generateTocHtml', () => {
6
6
  test('returns empty string for empty array', () => {
7
- expect(generateTocHtml([])).toBe('');
8
- });
9
-
10
- test('returns empty string for null/undefined', () => {
11
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
12
- expect(generateTocHtml(null as any)).toBe('');
13
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
- expect(generateTocHtml(undefined as any)).toBe('');
7
+ expect(generateTocHtml([], [])).toBe('');
15
8
  });
16
9
 
17
10
  test('generates heading items with correct level and link', () => {
18
- const html = generateTocHtml([
19
- { kind: 'heading', level: '2', id: 'intro', innerHtml: 'Introduction' },
20
- ]);
11
+ const html = generateTocHtml(
12
+ [{ kind: 'heading', level: '2', id: 'intro', innerHtml: 'Introduction' }],
13
+ [],
14
+ );
21
15
  expect(html).toContain('<ol>');
22
16
  expect(html).toContain('class="heading-item level2"');
23
17
  expect(html).toContain('href="#intro"');
@@ -26,33 +20,54 @@ describe('generateTocHtml', () => {
26
20
  });
27
21
 
28
22
  test('generates dinkus items', () => {
29
- const html = generateTocHtml([{ kind: 'dinkus' }]);
23
+ const html = generateTocHtml([{ kind: 'dinkus' }], []);
30
24
  expect(html).toContain('class="dinkus-item"');
31
25
  });
32
26
 
33
27
  test('generates alert items at one level deeper than last heading', () => {
34
- const html = generateTocHtml([
35
- { kind: 'heading', level: '2', id: 'sec', innerHtml: 'Section' },
36
- { kind: 'alert', type: 'warning', title: 'Caution' },
37
- ]);
28
+ const html = generateTocHtml(
29
+ [
30
+ { kind: 'heading', level: '2', id: 'sec', innerHtml: 'Section' },
31
+ { kind: 'alert', type: 'warning', title: 'Caution' },
32
+ ],
33
+ ['caution'],
34
+ );
38
35
  expect(html).toContain('class="alert-item level3 warning"');
36
+ expect(html).toContain('href="#caution"');
39
37
  expect(html).toContain('Caution');
40
38
  });
41
39
 
42
40
  test('alert before any heading uses level 2 (lastHeadingLevel defaults to 1)', () => {
43
- const html = generateTocHtml([
44
- { kind: 'alert', type: 'note', title: 'Note' },
45
- ]);
41
+ const html = generateTocHtml(
42
+ [{ kind: 'alert', type: 'note', title: 'Note' }],
43
+ ['note'],
44
+ );
46
45
  expect(html).toContain('class="alert-item level2 note"');
46
+ expect(html).toContain('href="#note"');
47
+ });
48
+
49
+ test('alert links use deduplicated IDs', () => {
50
+ const html = generateTocHtml(
51
+ [
52
+ { kind: 'alert', type: 'note', title: 'Note' },
53
+ { kind: 'alert', type: 'note', title: 'Note' },
54
+ ],
55
+ ['note', 'note-2'],
56
+ );
57
+ expect(html).toContain('href="#note"');
58
+ expect(html).toContain('href="#note-2"');
47
59
  });
48
60
 
49
61
  test('handles mixed items in order', () => {
50
- const html = generateTocHtml([
51
- { kind: 'heading', level: '2', id: 'a', innerHtml: 'A' },
52
- { kind: 'dinkus' },
53
- { kind: 'heading', level: '3', id: 'b', innerHtml: 'B' },
54
- { kind: 'alert', type: 'note', title: 'FYI' },
55
- ]);
62
+ const html = generateTocHtml(
63
+ [
64
+ { kind: 'heading', level: '2', id: 'a', innerHtml: 'A' },
65
+ { kind: 'dinkus' },
66
+ { kind: 'heading', level: '3', id: 'b', innerHtml: 'B' },
67
+ { kind: 'alert', type: 'note', title: 'FYI' },
68
+ ],
69
+ ['fyi'],
70
+ );
56
71
  expect(html).toContain('level2');
57
72
  expect(html).toContain('dinkus-item');
58
73
  expect(html).toContain('level3');