@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.
- package/README.md +4 -2
- package/bin/tada.ts +81 -57
- package/build/apply-base-path-plugin.ts +22 -6
- package/build/code.test.ts +108 -1
- package/build/colors.test.ts +66 -0
- package/build/columns-plugin.ts +116 -0
- package/build/content-watch.ts +30 -21
- package/build/copy.ts +1 -1
- package/build/external-links-plugin.ts +1 -1
- package/build/generate-content-assets.ts +48 -6
- package/build/generate-favicon.ts +1 -1
- package/build/generate-fonts.ts +1 -1
- package/build/generate-katex-assets.ts +1 -1
- package/build/log.test.ts +7 -1
- package/build/log.ts +4 -1
- package/build/markdown-plugins.test.ts +65 -3
- package/build/pagefind.ts +1 -1
- package/build/pipeline.ts +4 -20
- package/build/serve.test.ts +59 -0
- package/build/serve.ts +29 -63
- package/build/templates.ts +2 -2
- package/build/toc-plugin.test.ts +40 -25
- package/build/toc-plugin.ts +9 -2
- package/build/{types.ts → types.d.ts} +88 -1
- package/build/util.ts +1 -1
- package/build/utils/code.ts +76 -3
- package/build/utils/content-files.test.ts +56 -0
- package/build/utils/content-files.ts +15 -11
- package/build/utils/file-types.test.ts +18 -0
- package/build/utils/file-types.ts +4 -0
- package/build/utils/include.test.ts +117 -0
- package/build/utils/include.ts +53 -0
- package/build/utils/jdi-runner/TraceRunner.java +416 -0
- package/build/utils/literate-java.ts +2 -2
- package/build/utils/markdown.ts +32 -14
- package/build/utils/paths.ts +1 -1
- package/build/utils/render.ts +102 -11
- package/build/utils/shiki-highlighter.ts +1 -1
- package/build/utils/trace-layout.test.ts +570 -0
- package/build/utils/trace-layout.ts +848 -0
- package/build/utils/trace-svg.test.ts +425 -0
- package/build/utils/trace-svg.ts +905 -0
- package/build/utils/trace.test.ts +169 -0
- package/build/utils/trace.ts +382 -0
- package/build/validate-internal-links-plugin.test.ts +30 -2
- package/build/validate-internal-links-plugin.ts +3 -3
- package/build/watch-reload-client.ts +4 -20
- package/build/watch.ts +436 -448
- package/init/content/index.md +10 -5
- package/init/content/labs/00/index.md +1 -1
- package/init/content/labs/01/SearchTreeDemo.java +31 -0
- package/init/content/labs/01/TraceDemo.java +22 -0
- package/init/content/labs/01/index.md +27 -0
- package/init/content/labs/index.md +2 -0
- package/init/content/lectures/01/Rectangle.java +2 -0
- package/init/content/lectures/01/index.md +2 -1
- package/init/content/lectures/01/rectangle.py +45 -0
- package/init/content/lectures/02/_pr1.md +5 -0
- package/init/content/lectures/02/index.md +11 -0
- package/init/content/lectures/02/subdir/_foobar.html +3 -0
- package/init/content/lectures/02/subdir/_pr2.md +6 -0
- package/init/content/lectures/index.md +3 -0
- package/init/content/markdown.md +30 -0
- package/package.json +21 -4
- package/src/_base.scss +13 -2
- package/src/_layout.scss +6 -5
- package/src/anchor/index.test.ts +64 -0
- package/src/code/index.test.ts +71 -0
- package/src/code/style.scss +23 -0
- package/src/code.scss +5 -3
- package/src/header/_base.scss +48 -9
- package/src/header/index.test.ts +89 -0
- package/src/header/index.ts +1 -1
- package/src/header/style.scss +1 -5
- package/src/index.ts +18 -20
- package/src/literate/style.scss +15 -7
- package/src/navigate/eligible.test.ts +101 -0
- package/src/navigate/eligible.ts +62 -0
- package/src/navigate/index.test.ts +82 -0
- package/src/navigate/index.ts +365 -0
- package/src/navigate/lifecycle.test.ts +26 -0
- package/src/navigate/lifecycle.ts +65 -0
- package/src/navigate/style.scss +61 -0
- package/src/print/index.test.ts +71 -0
- package/src/question/index.test.ts +82 -0
- package/src/question/style.scss +12 -0
- package/src/search/index.ts +38 -14
- package/src/search/style.scss +23 -14
- package/src/style.scss +25 -12
- package/src/timezone/index.test.ts +301 -1
- package/src/timezone/index.ts +68 -56
- package/src/toc/index.test.ts +237 -0
- package/src/toc/index.ts +29 -69
- package/src/toc/style.scss +17 -0
- package/src/top/index.test.ts +46 -0
- package/src/top/index.ts +2 -2
- package/src/trace/index.test.ts +554 -0
- package/src/trace/index.ts +258 -0
- package/src/trace/style.scss +210 -0
- package/src/trace/types.d.ts +47 -0
- package/src/util.test.ts +135 -0
- package/templates/_top.html +23 -22
- 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(
|
|
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,
|
|
65
|
+
{ changedContentFiles, jsonDataChanged, partialsChanged }: WatchState = {},
|
|
57
66
|
): Set<string> {
|
|
58
67
|
if (
|
|
59
68
|
this.lastBuildFiles.size === 0 ||
|
|
60
69
|
!changedContentFiles ||
|
|
61
|
-
|
|
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({
|
|
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
|
-
|
|
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(
|
|
11
|
+
const log = makeLogger(import.meta.url);
|
|
12
12
|
|
|
13
13
|
const FONT_PATH = path.join(
|
|
14
14
|
getPackageDir(),
|
package/build/generate-fonts.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
24
|
+
const log = makeLogger(import.meta.url);
|
|
26
25
|
|
|
27
|
-
async function runPipeline(
|
|
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(
|
|
6
|
+
const log = makeLogger(import.meta.url);
|
|
8
7
|
|
|
9
|
-
function
|
|
10
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
43
|
+
export interface StartServerOptions {
|
|
44
|
+
port?: number;
|
|
45
|
+
distDir: string;
|
|
46
|
+
onReady?: (port: number) => void;
|
|
47
|
+
}
|
|
63
48
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
'Last-Modified': result.mtime.toUTCString(),
|
|
67
|
-
};
|
|
49
|
+
export function startServer(options: StartServerOptions): void {
|
|
50
|
+
const { port = 8080, distDir, onReady } = options;
|
|
68
51
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
75
|
-
|
|
59
|
+
const headers = {
|
|
60
|
+
'Cache-Control': 'no-cache',
|
|
61
|
+
'Last-Modified': result.mtime.toUTCString(),
|
|
62
|
+
};
|
|
76
63
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
});
|
package/build/templates.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
123
|
+
vars: siteVariables.vars || {},
|
|
124
124
|
site: siteVariables,
|
|
125
125
|
base: siteVariables.base,
|
|
126
126
|
basePath: siteVariables.basePath,
|
package/build/toc-plugin.test.ts
CHANGED
|
@@ -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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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');
|