@abreen/tada 1.0.2 → 1.2.0
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 +96 -48
- package/bin/tada.ts +621 -0
- package/bin/validators.test.ts +205 -0
- package/bin/validators.ts +85 -0
- package/{webpack/apply-base-path-plugin.js → build/apply-base-path-plugin.ts} +37 -17
- package/build/build-manifest.test.ts +349 -0
- package/build/build-manifest.ts +165 -0
- package/build/bundle.ts +116 -0
- package/{webpack/code.test.js → build/code.test.ts} +10 -7
- package/build/colors.ts +25 -0
- package/build/content-watch.ts +107 -0
- package/build/copy.ts +118 -0
- package/{webpack/deflist-id-plugin.js → build/deflist-id-plugin.ts} +7 -6
- package/{webpack/external-links-plugin.js → build/external-links-plugin.ts} +14 -5
- package/build/features.ts +11 -0
- package/build/generate-content-assets.ts +320 -0
- package/build/generate-favicon.ts +172 -0
- package/build/generate-fonts.ts +31 -0
- package/build/generate-katex-assets.ts +35 -0
- package/{webpack/generate-manifest-plugin.js → build/generate-manifest.ts} +29 -36
- package/build/globals.test.ts +99 -0
- package/build/globals.ts +88 -0
- package/{webpack/heading-subtitle-plugin.js → build/heading-subtitle-plugin.ts} +4 -2
- package/build/json-schema.test.ts +57 -0
- package/build/json-schema.ts +33 -0
- package/build/log.test.ts +111 -0
- package/build/log.ts +167 -0
- package/build/markdown-plugins.test.ts +396 -0
- package/{webpack/pagefind-plugin.test.js → build/pagefind.test.ts} +74 -13
- package/build/pagefind.ts +339 -0
- package/build/pdf-text.test.ts +52 -0
- package/{webpack/pdf-text.js → build/pdf-text.ts} +47 -27
- package/build/pipeline.ts +120 -0
- package/{webpack/reachability.test.js → build/reachability.test.ts} +3 -3
- package/{webpack/reachability.js → build/reachability.ts} +77 -34
- package/build/serve.ts +112 -0
- package/build/site-variables.ts +68 -0
- package/{webpack/templates.js → build/templates.ts} +38 -35
- package/{webpack/text-to-id.js → build/text-to-id.ts} +2 -2
- package/build/toc-plugin.test.ts +105 -0
- package/{webpack/toc-plugin.js → build/toc-plugin.ts} +32 -13
- package/build/types.ts +181 -0
- package/build/util.ts +26 -0
- package/{webpack/utils/code.js → build/utils/code.ts} +119 -60
- package/{webpack/utils/content-files.js → build/utils/content-files.ts} +40 -35
- package/build/utils/derive-theme.test.ts +111 -0
- package/build/utils/derive-theme.ts +85 -0
- package/build/utils/file-types.test.ts +61 -0
- package/build/utils/file-types.ts +13 -0
- package/build/utils/front-matter.test.ts +77 -0
- package/{webpack/utils/front-matter.js → build/utils/front-matter.ts} +22 -9
- package/{webpack → build}/utils/jdi-runner/LiterateRunner.java +1 -1
- package/{webpack/utils/literate-java.js → build/utils/literate-java.ts} +68 -38
- package/{webpack/utils/markdown.js → build/utils/markdown.ts} +126 -49
- package/build/utils/paths.test.ts +91 -0
- package/{webpack/utils/paths.js → build/utils/paths.ts} +18 -22
- package/{webpack/utils/render.js → build/utils/render.ts} +208 -130
- package/build/utils/shiki-highlighter.ts +29 -0
- package/build/validate-internal-links-plugin.test.ts +106 -0
- package/{webpack/validate-internal-links-plugin.js → build/validate-internal-links-plugin.ts} +47 -20
- package/{webpack/watch-reachability-state.test.js → build/watch-reachability-state.test.ts} +8 -8
- package/{webpack/watch-reachability-state.js → build/watch-reachability-state.ts} +63 -24
- package/{webpack/watch-reload-client.js → build/watch-reload-client.ts} +3 -1
- package/build/watch.ts +549 -0
- package/fonts/google-sans-code/woff2/GoogleSansCodeVariable-Italic.woff2 +0 -0
- package/fonts/google-sans-code/woff2/GoogleSansCodeVariable.woff2 +0 -0
- package/fonts/inter/woff2/InterVariable-Italic.woff2 +0 -0
- package/fonts/inter/woff2/InterVariable.woff2 +0 -0
- package/{content → init/content}/index.md +9 -3
- package/{content → init/content}/lectures/index.md +1 -1
- package/{content → init/content}/markdown.md +28 -8
- package/init/content/problem_sets/index.html +14 -0
- package/package.json +33 -22
- package/{templates → schema}/nav.schema.json +1 -2
- package/{webpack → schema}/site.schema.json +4 -12
- package/src/_alerts.scss +96 -0
- package/src/_base.scss +145 -0
- package/src/{layout.scss → _layout.scss} +36 -1
- package/src/anchor/index.ts +0 -1
- package/src/anchor/style.scss +1 -9
- package/src/code/index.ts +3 -3
- package/src/code.scss +1 -1
- package/src/critical.scss +5 -0
- package/src/header/_base.scss +129 -0
- package/src/header/style.scss +3 -131
- package/src/index.ts +4 -5
- package/src/question/style.scss +1 -1
- package/src/search/index.test.ts +203 -0
- package/src/search/index.ts +38 -93
- package/src/search/pdf-utils.ts +79 -0
- package/src/search/style.scss +8 -28
- package/src/style.scss +15 -326
- package/src/timezone/index.ts +5 -6
- package/src/toc/style.scss +5 -39
- package/src/util.ts +9 -6
- package/templates/_theme.scss +38 -14
- package/templates/_top.html +1 -1
- package/tsconfig.json +10 -6
- package/types/dev.d.ts +1 -0
- package/types/file-system-access.d.ts +5 -0
- package/types/markdown-it-plugins.d.ts +11 -0
- package/types/site-variables.d.ts +6 -10
- package/types/untyped-modules.d.ts +47 -0
- package/bin/tada.js +0 -361
- package/config/authors.json +0 -1
- package/config/nav.json +0 -28
- package/content/problem_sets/index.md +0 -6
- package/types/dev.ts +0 -3
- package/webpack/build-state.js +0 -97
- package/webpack/colors.js +0 -15
- package/webpack/config.base.js +0 -151
- package/webpack/config.dev.js +0 -23
- package/webpack/config.prod.js +0 -32
- package/webpack/content-watch-plugin.js +0 -153
- package/webpack/features.js +0 -5
- package/webpack/generate-content-assets-plugin.js +0 -308
- package/webpack/generate-favicon-plugin.js +0 -198
- package/webpack/generate-fonts-plugin.js +0 -69
- package/webpack/globals.js +0 -74
- package/webpack/json-schema.js +0 -19
- package/webpack/log.js +0 -143
- package/webpack/markdown-plugins.test.js +0 -203
- package/webpack/pagefind-plugin.js +0 -379
- package/webpack/print-flair-plugin.js +0 -22
- package/webpack/serve.js +0 -104
- package/webpack/site-variables.js +0 -53
- package/webpack/util.js +0 -49
- package/webpack/utils/define-plugin.js +0 -20
- package/webpack/utils/file-types.js +0 -26
- package/webpack/utils/parse-hsl.js +0 -8
- package/webpack/utils/shiki-highlighter.js +0 -26
- package/webpack/watch.js +0 -166
- /package/{webpack → build}/flair.json +0 -0
- /package/{webpack → build}/utils/jdi-runner/LiterateRunner.class +0 -0
- /package/fonts/google-sans-code/{GoogleSansCodeVariable-Italic.ttf → ttf/GoogleSansCodeVariable-Italic.ttf} +0 -0
- /package/fonts/google-sans-code/{GoogleSansCodeVariable.ttf → ttf/GoogleSansCodeVariable.ttf} +0 -0
- /package/fonts/inter/{InterVariable-Italic.ttf → ttf/InterVariable-Italic.ttf} +0 -0
- /package/fonts/inter/{InterVariable.ttf → ttf/InterVariable.ttf} +0 -0
- /package/{content → init/content}/lectures/01/Pair.java.md +0 -0
- /package/{content → init/content}/lectures/01/Rectangle.java +0 -0
- /package/{content → init/content}/lectures/01/demo.py +0 -0
- /package/{content → init/content}/lectures/01/index.md +0 -0
- /package/{content → init/content}/lectures/01/lecture1.pdf +0 -0
- /package/{public → init/public}/avatars/alex.jpg +0 -0
- /package/{public → init/public}/test.txt +0 -0
- /package/{templates → schema}/authors.schema.json +0 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { makeLogger, getFlair } from './log';
|
|
3
|
+
|
|
4
|
+
function captureOutput(fn: () => void): { stdout: string; stderr: string } {
|
|
5
|
+
let stdout = '';
|
|
6
|
+
let stderr = '';
|
|
7
|
+
const origStdout = process.stdout.write;
|
|
8
|
+
const origStderr = process.stderr.write;
|
|
9
|
+
process.stdout.write = ((chunk: string) => {
|
|
10
|
+
stdout += chunk;
|
|
11
|
+
return true;
|
|
12
|
+
}) as typeof process.stdout.write;
|
|
13
|
+
process.stderr.write = ((chunk: string) => {
|
|
14
|
+
stderr += chunk;
|
|
15
|
+
return true;
|
|
16
|
+
}) as typeof process.stderr.write;
|
|
17
|
+
try {
|
|
18
|
+
fn();
|
|
19
|
+
} finally {
|
|
20
|
+
process.stdout.write = origStdout;
|
|
21
|
+
process.stderr.write = origStderr;
|
|
22
|
+
}
|
|
23
|
+
return { stdout, stderr };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('makeLogger', () => {
|
|
27
|
+
test('creates a logger with default info level', () => {
|
|
28
|
+
const log = makeLogger('test');
|
|
29
|
+
expect(log.minLogLevel).toBe('info');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('throws on invalid log level', () => {
|
|
33
|
+
expect(() => makeLogger('test', 'verbose')).toThrow('Invalid log level');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('info writes to stdout', () => {
|
|
37
|
+
const log = makeLogger('test', 'info');
|
|
38
|
+
const { stdout } = captureOutput(() => log.info`hello world`);
|
|
39
|
+
expect(stdout).toContain('hello world');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('debug is suppressed at info level', () => {
|
|
43
|
+
const log = makeLogger('test', 'info');
|
|
44
|
+
const { stderr } = captureOutput(() => log.debug`should not appear`);
|
|
45
|
+
expect(stderr).toBe('');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('debug appears at debug level', () => {
|
|
49
|
+
const log = makeLogger('test', 'debug');
|
|
50
|
+
const { stderr } = captureOutput(() => log.debug`visible`);
|
|
51
|
+
expect(stderr).toContain('visible');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('warn writes to stdout', () => {
|
|
55
|
+
const log = makeLogger('test', 'warn');
|
|
56
|
+
const { stdout } = captureOutput(() => log.warn`caution`);
|
|
57
|
+
expect(stdout).toContain('caution');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('error writes to stdout', () => {
|
|
61
|
+
const log = makeLogger('test', 'error');
|
|
62
|
+
const { stdout } = captureOutput(() => log.error`failure`);
|
|
63
|
+
expect(stdout).toContain('failure');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('event always writes', () => {
|
|
67
|
+
const log = makeLogger('test', 'error');
|
|
68
|
+
const { stdout } = captureOutput(() => log.event`done`);
|
|
69
|
+
expect(stdout).toContain('done');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('followup writes strings', () => {
|
|
73
|
+
const log = makeLogger('test');
|
|
74
|
+
const { stdout } = captureOutput(() => log.followup(['line1', 'line2']));
|
|
75
|
+
expect(stdout).toContain('line1');
|
|
76
|
+
expect(stdout).toContain('line2');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('setMinLogLevel changes filtering', () => {
|
|
80
|
+
const log = makeLogger('test', 'info');
|
|
81
|
+
log.setMinLogLevel('error');
|
|
82
|
+
const { stdout } = captureOutput(() => log.info`suppressed`);
|
|
83
|
+
expect(stdout).toBe('');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('handles empty name', () => {
|
|
87
|
+
const log = makeLogger('');
|
|
88
|
+
expect(log.minLogLevel).toBe('info');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('handles __filename-style name', () => {
|
|
92
|
+
const log = makeLogger('/path/to/module.ts');
|
|
93
|
+
const { stdout } = captureOutput(() => log.info`test`);
|
|
94
|
+
expect(stdout).toContain('test');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('interpolates objects in template', () => {
|
|
98
|
+
const log = makeLogger('test');
|
|
99
|
+
const obj = { key: 'value' };
|
|
100
|
+
const { stdout } = captureOutput(() => log.info`data: ${obj}`);
|
|
101
|
+
expect(stdout).toContain('key');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('getFlair', () => {
|
|
106
|
+
test('returns a non-empty string with emoji', () => {
|
|
107
|
+
const flair = getFlair();
|
|
108
|
+
expect(flair.length).toBeGreaterThan(0);
|
|
109
|
+
expect(flair).toContain('🎉');
|
|
110
|
+
});
|
|
111
|
+
});
|
package/build/log.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { inspect } from 'node:util';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { Gi, L, Ri, P, Yi, Li } from './colors';
|
|
4
|
+
import type { Logger } from './types';
|
|
5
|
+
import FLAIR_STRINGS from './flair.json' with { type: 'json' };
|
|
6
|
+
|
|
7
|
+
const LEVELS = ['debug', 'info', 'warn', 'error'] as const;
|
|
8
|
+
|
|
9
|
+
type LogLevel = (typeof LEVELS)[number];
|
|
10
|
+
|
|
11
|
+
const ENV_LOG_LEVEL = process.env.TADA_LOG_LEVEL;
|
|
12
|
+
|
|
13
|
+
if (ENV_LOG_LEVEL && !LEVELS.includes(ENV_LOG_LEVEL as LogLevel)) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
`Invalid TADA_LOG_LEVEL "${ENV_LOG_LEVEL}", must be one of: ${LEVELS.join(', ')}`,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function shouldLog(loggerLevel: string, level: string): boolean {
|
|
20
|
+
return (
|
|
21
|
+
LEVELS.indexOf(level as LogLevel) >= LEVELS.indexOf(loggerLevel as LogLevel)
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function validateLevel(level: string): void {
|
|
26
|
+
if (!LEVELS.includes(level as LogLevel)) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`Invalid log level "${level}", must be one of: ${LEVELS.join(', ')}`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function print(
|
|
34
|
+
strings: string[],
|
|
35
|
+
stream: 'stdout' | 'stderr' = 'stdout',
|
|
36
|
+
end: string = '\n',
|
|
37
|
+
): void {
|
|
38
|
+
for (const s of strings) {
|
|
39
|
+
process[stream].write(s);
|
|
40
|
+
}
|
|
41
|
+
process[stream].write(end);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function makeLogger(name: string, logLevel: string = 'info'): Logger {
|
|
45
|
+
validateLevel(logLevel);
|
|
46
|
+
|
|
47
|
+
if (ENV_LOG_LEVEL) {
|
|
48
|
+
logLevel = ENV_LOG_LEVEL;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!name) {
|
|
52
|
+
name = '';
|
|
53
|
+
} else {
|
|
54
|
+
// Allow for passing __filename
|
|
55
|
+
name = path.basename(name, path.extname(name));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const logger: Logger = {
|
|
59
|
+
minLogLevel: logLevel,
|
|
60
|
+
/** Don't log if the level is < minLogLevel */
|
|
61
|
+
setMinLogLevel(minLogLevel: string) {
|
|
62
|
+
this.minLogLevel = minLogLevel;
|
|
63
|
+
},
|
|
64
|
+
getArgs(
|
|
65
|
+
level: string,
|
|
66
|
+
strings: TemplateStringsArray | string | string[],
|
|
67
|
+
args: unknown[],
|
|
68
|
+
labelFn: (strings: TemplateStringsArray, ...args: unknown[]) => string,
|
|
69
|
+
): string[] {
|
|
70
|
+
const params: string[] = [];
|
|
71
|
+
params.push(
|
|
72
|
+
labelFn` ${level} ` + ' ' + (level === 'debug' ? name + ' ' : ''),
|
|
73
|
+
);
|
|
74
|
+
params.push(format(strings, ...args));
|
|
75
|
+
return params;
|
|
76
|
+
},
|
|
77
|
+
debug(strings: TemplateStringsArray, ...args: unknown[]) {
|
|
78
|
+
if (shouldLog(this.minLogLevel, 'debug')) {
|
|
79
|
+
print(this.getArgs('debug', strings, args, L), 'stderr');
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
info(strings: TemplateStringsArray, ...args: unknown[]) {
|
|
83
|
+
if (shouldLog(this.minLogLevel, 'info')) {
|
|
84
|
+
print(this.getArgs('info', strings, args, Li));
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
warn(strings: TemplateStringsArray, ...args: unknown[]) {
|
|
88
|
+
if (shouldLog(this.minLogLevel, 'warn')) {
|
|
89
|
+
print(this.getArgs('warn', strings, args, Yi));
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
error(strings: TemplateStringsArray, ...args: unknown[]) {
|
|
93
|
+
if (shouldLog(this.minLogLevel, 'error')) {
|
|
94
|
+
print(this.getArgs('error', strings, args, Ri));
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
event(strings: TemplateStringsArray, ...args: unknown[]) {
|
|
98
|
+
print(this.getArgs('event', strings, args, Gi));
|
|
99
|
+
},
|
|
100
|
+
followup(strings: string[]) {
|
|
101
|
+
print(strings);
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
logger.setMinLogLevel(logLevel);
|
|
106
|
+
return logger;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function format(
|
|
110
|
+
strings: TemplateStringsArray | string | string[],
|
|
111
|
+
...args: unknown[]
|
|
112
|
+
): string {
|
|
113
|
+
// Called as template tag: first arg is an array-like with .raw
|
|
114
|
+
if (strings && typeof strings === 'object' && 'raw' in strings) {
|
|
115
|
+
try {
|
|
116
|
+
return String.raw(strings as TemplateStringsArray, ...args.map(toString));
|
|
117
|
+
} catch {
|
|
118
|
+
// fallback to safe join
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
if (Array.isArray(strings)) {
|
|
122
|
+
args.unshift(...strings);
|
|
123
|
+
} else {
|
|
124
|
+
args.unshift(strings);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return args.map(toString).join(' ');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return '';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function toString(item: unknown): string {
|
|
134
|
+
if (item === undefined) {
|
|
135
|
+
return 'undefined';
|
|
136
|
+
}
|
|
137
|
+
if (item === null) {
|
|
138
|
+
return 'null';
|
|
139
|
+
}
|
|
140
|
+
if (typeof item === 'string') {
|
|
141
|
+
return item;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
if (typeof item === 'object') {
|
|
146
|
+
return inspect(item, {
|
|
147
|
+
compact: true,
|
|
148
|
+
depth: 2,
|
|
149
|
+
breakLength: 80,
|
|
150
|
+
maxStringLength: 250,
|
|
151
|
+
colors: true,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
throw new Error('not an object');
|
|
155
|
+
} catch {
|
|
156
|
+
return String(item);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function getFlair(): string {
|
|
161
|
+
const i = Math.floor(Math.random() * FLAIR_STRINGS.length);
|
|
162
|
+
return P`${FLAIR_STRINGS[i]}!` + ' 🎉';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function printFlair(): void {
|
|
166
|
+
console.log(getFlair());
|
|
167
|
+
}
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import MarkdownIt from 'markdown-it';
|
|
3
|
+
import deflist from 'markdown-it-deflist';
|
|
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
|
+
|
|
12
|
+
describe('apply-base-path-plugin', () => {
|
|
13
|
+
test('rewrites internal links, images, and raw html sources', () => {
|
|
14
|
+
const md = new MarkdownIt({ html: true });
|
|
15
|
+
md.use(applyBasePathPlugin, {
|
|
16
|
+
basePath: '/course/',
|
|
17
|
+
codeLanguages: { java: 'java' },
|
|
18
|
+
features: { code: true },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const html = md.render(
|
|
22
|
+
[
|
|
23
|
+
'[Code](/src/example.java?view=1#top)',
|
|
24
|
+
'',
|
|
25
|
+
'',
|
|
26
|
+
'',
|
|
27
|
+
'<img src="/images/raw.png" alt="Raw">',
|
|
28
|
+
'',
|
|
29
|
+
'<a href="/about/">About</a>',
|
|
30
|
+
].join('\n'),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
expect(html).toContain('href="/course/src/example.html?view=1#top"');
|
|
34
|
+
expect(html).toContain('src="/course/images/pic.png"');
|
|
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"');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('rewrites relative code file links without applying base path', () => {
|
|
60
|
+
const md = new MarkdownIt();
|
|
61
|
+
md.use(applyBasePathPlugin, {
|
|
62
|
+
basePath: '/course/',
|
|
63
|
+
codeLanguages: { java: 'java' },
|
|
64
|
+
features: { code: true },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const html = md.render('[App](./App.java)');
|
|
68
|
+
|
|
69
|
+
expect(html).toContain('href="./App.html"');
|
|
70
|
+
expect(html).not.toContain('href="./App.java"');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('keeps code file extensions when the code feature is disabled', () => {
|
|
74
|
+
const md = new MarkdownIt();
|
|
75
|
+
md.use(applyBasePathPlugin, {
|
|
76
|
+
basePath: '/course',
|
|
77
|
+
codeLanguages: { java: 'java' },
|
|
78
|
+
features: { code: false },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const html = md.render('[Code](/src/example.java#L1)');
|
|
82
|
+
|
|
83
|
+
expect(html).toContain('href="/course/src/example.java#L1"');
|
|
84
|
+
expect(html).not.toContain('href="/course/src/example.html#L1"');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('external-links-plugin', () => {
|
|
89
|
+
test('marks external http links without changing internal or non-http links', () => {
|
|
90
|
+
const md = new MarkdownIt();
|
|
91
|
+
md.use(externalLinksPlugin, { internalDomains: ['example.com'] });
|
|
92
|
+
|
|
93
|
+
const html = md.render(
|
|
94
|
+
[
|
|
95
|
+
'[External](https://outside.example/docs)',
|
|
96
|
+
'[Internal](https://example.com/docs)',
|
|
97
|
+
'[Mail](mailto:test@example.com)',
|
|
98
|
+
].join(' '),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
expect(html).toContain(
|
|
102
|
+
'<a href="https://outside.example/docs" class="external" target="_blank">External</a>',
|
|
103
|
+
);
|
|
104
|
+
expect(html).toContain('<a href="https://example.com/docs">Internal</a>');
|
|
105
|
+
expect(html).toContain('<a href="mailto:test@example.com">Mail</a>');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('heading-subtitle-plugin', () => {
|
|
110
|
+
test('wraps heading subtitles while preserving inline formatting', () => {
|
|
111
|
+
const md = new MarkdownIt();
|
|
112
|
+
md.use(headingSubtitlePlugin);
|
|
113
|
+
|
|
114
|
+
const html = md.render('## Title # *Subtitle*').trim();
|
|
115
|
+
|
|
116
|
+
expect(html).toBe(
|
|
117
|
+
'<h2 class="has-subtitle">Title <span class="heading-subtitle"><em>Subtitle</em></span></h2>',
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('deflist-id-plugin', () => {
|
|
123
|
+
test('injects unique ids for terms and falls back when a term slug is empty', () => {
|
|
124
|
+
const md = new MarkdownIt();
|
|
125
|
+
md.use(deflist);
|
|
126
|
+
md.use(deflistIdPlugin);
|
|
127
|
+
|
|
128
|
+
const html = md.render(
|
|
129
|
+
['Term 1', ': One', '', 'Term 1', ': Two', '', '!!!', ': Three'].join(
|
|
130
|
+
'\n',
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect(html).toContain('<a id="term-1"></a>Term 1');
|
|
135
|
+
expect(html).toContain('<a id="term-1-2"></a>Term 1');
|
|
136
|
+
expect(html).toContain('<a id="term"></a>!!!');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('custom markdown containers', () => {
|
|
141
|
+
function createProjectMarkdown() {
|
|
142
|
+
return createMarkdown(
|
|
143
|
+
{
|
|
144
|
+
base: '',
|
|
145
|
+
basePath: '/',
|
|
146
|
+
internalDomains: [],
|
|
147
|
+
codeLanguages: {},
|
|
148
|
+
features: { search: true, code: true, favicon: false },
|
|
149
|
+
title: 'Test',
|
|
150
|
+
titlePostfix: ' - Test',
|
|
151
|
+
themeColor: 'steelblue',
|
|
152
|
+
defaultTimeZone: 'America/New_York',
|
|
153
|
+
} as SiteVariables,
|
|
154
|
+
{ validatorOptions: { enabled: false } },
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
test('renders collapsible details blocks', () => {
|
|
159
|
+
const md = createProjectMarkdown();
|
|
160
|
+
|
|
161
|
+
const html = md.render(
|
|
162
|
+
['<<< details More info', 'Hello', '<<<'].join('\n'),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
expect(html).toContain('<details><summary>More info</summary>');
|
|
166
|
+
expect(html).toContain('<div class="content">');
|
|
167
|
+
expect(html).toContain('<p>Hello</p>');
|
|
168
|
+
expect(html).toContain('</div></details>');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('renders collapsible details blocks with inline Markdown in summary', () => {
|
|
172
|
+
const md = createProjectMarkdown();
|
|
173
|
+
|
|
174
|
+
const html = md.render(
|
|
175
|
+
['<<< details More **info**', 'Hello', '<<<'].join('\n'),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
expect(html).toContain(
|
|
179
|
+
'<details><summary>More <strong>info</strong></summary>',
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('renders section containers as section elements', () => {
|
|
184
|
+
const md = createProjectMarkdown();
|
|
185
|
+
|
|
186
|
+
const html = md.render(['::: section', 'Body', ':::'].join('\n'));
|
|
187
|
+
|
|
188
|
+
expect(html).toContain('<section>');
|
|
189
|
+
expect(html).toContain('<p>Body</p>');
|
|
190
|
+
expect(html).toContain('</section>');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('renders questions with question and spoiler answer', () => {
|
|
194
|
+
const md = createProjectMarkdown();
|
|
195
|
+
|
|
196
|
+
const html = md.render(
|
|
197
|
+
[
|
|
198
|
+
'??? question What is a data structure?',
|
|
199
|
+
'An organized collection.',
|
|
200
|
+
'???',
|
|
201
|
+
].join('\n'),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
expect(html).toContain('<div class="question">');
|
|
205
|
+
expect(html).toContain(
|
|
206
|
+
'<p class="question-q"><span class="question-label">Q.</span><span>What is a data structure?</span></p>',
|
|
207
|
+
);
|
|
208
|
+
expect(html).toContain('<p class="question-a-label">A.</p>');
|
|
209
|
+
expect(html).toContain(
|
|
210
|
+
'<div class="question-a-body" data-pagefind-ignore>',
|
|
211
|
+
);
|
|
212
|
+
expect(html).toContain('<p>An organized collection.</p>');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('renders alert containers with custom and default titles', () => {
|
|
216
|
+
const md = createProjectMarkdown();
|
|
217
|
+
|
|
218
|
+
const html = md.render(
|
|
219
|
+
[
|
|
220
|
+
'!!! note "Read this"',
|
|
221
|
+
'Body',
|
|
222
|
+
'!!!',
|
|
223
|
+
'',
|
|
224
|
+
'!!! warning',
|
|
225
|
+
'Careful',
|
|
226
|
+
'!!!',
|
|
227
|
+
].join('\n'),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
expect(html).toContain('<div class="alert note">');
|
|
231
|
+
expect(html).toContain('<p class="title" id="read-this">');
|
|
232
|
+
expect(html).toContain(
|
|
233
|
+
'<div class="alert warning"><p class="title">Warning</p>',
|
|
234
|
+
);
|
|
235
|
+
expect(html).toContain('<p>Body</p>');
|
|
236
|
+
expect(html).toContain('<p>Careful</p>');
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('hidden_fence rule', () => {
|
|
241
|
+
function createProjectMarkdown() {
|
|
242
|
+
return createMarkdown(
|
|
243
|
+
{
|
|
244
|
+
base: '',
|
|
245
|
+
basePath: '/',
|
|
246
|
+
internalDomains: [],
|
|
247
|
+
codeLanguages: {},
|
|
248
|
+
features: { search: true, code: true, favicon: false },
|
|
249
|
+
title: 'Test',
|
|
250
|
+
titlePostfix: ' - Test',
|
|
251
|
+
themeColor: 'steelblue',
|
|
252
|
+
defaultTimeZone: 'America/New_York',
|
|
253
|
+
} as SiteVariables,
|
|
254
|
+
{ validatorOptions: { enabled: false } },
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
test('removes triple-hyphen comments containing a code fence', () => {
|
|
259
|
+
const md = createProjectMarkdown();
|
|
260
|
+
|
|
261
|
+
const html = md.render(
|
|
262
|
+
['<!---', '```', 'import java.util.*;', '```', '-->'].join('\n'),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
expect(html).not.toContain('<!---');
|
|
266
|
+
expect(html).not.toContain('import java.util');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('does not remove triple-hyphen comments without a code fence', () => {
|
|
270
|
+
const md = createProjectMarkdown();
|
|
271
|
+
|
|
272
|
+
const html = md.render(
|
|
273
|
+
['<!---', 'This is a plain comment.', '-->'].join('\n'),
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
// markdown-it passes through html_block tokens unchanged;
|
|
277
|
+
// the hidden_fence rule only converts comments that contain fences
|
|
278
|
+
expect(html).toContain('<!---');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('preserves double-hyphen HTML comments', () => {
|
|
282
|
+
const md = createProjectMarkdown();
|
|
283
|
+
|
|
284
|
+
const html = md.render(['<!-- standard HTML comment -->'].join('\n'));
|
|
285
|
+
|
|
286
|
+
expect(html).toContain('<!-- standard HTML comment -->');
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe('stripHtmlComments', () => {
|
|
291
|
+
test('removes a single triple-hyphen comment', () => {
|
|
292
|
+
const result = stripHtmlComments(
|
|
293
|
+
'<p>Before</p>\n<!--- hidden comment -->\n<p>After</p>',
|
|
294
|
+
);
|
|
295
|
+
expect(result).toBe('<p>Before</p>\n\n<p>After</p>');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('removes multiple triple-hyphen comments', () => {
|
|
299
|
+
const result = stripHtmlComments('<!--- first -->\ntext\n<!--- second -->');
|
|
300
|
+
expect(result).toBe('\ntext\n');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('removes triple-hyphen comments spanning multiple lines', () => {
|
|
304
|
+
const result = stripHtmlComments(
|
|
305
|
+
'<p>Keep</p>\n<!---\nLine 1\nLine 2\n-->\n<p>Also keep</p>',
|
|
306
|
+
);
|
|
307
|
+
expect(result).toBe('<p>Keep</p>\n\n<p>Also keep</p>');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('does not remove double-hyphen HTML comments', () => {
|
|
311
|
+
const input = '<p>Before</p>\n<!-- keep this -->\n<p>After</p>';
|
|
312
|
+
expect(stripHtmlComments(input)).toBe(input);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('returns input unchanged when there are no comments', () => {
|
|
316
|
+
const input = '<p>Hello world</p>';
|
|
317
|
+
expect(stripHtmlComments(input)).toBe(input);
|
|
318
|
+
});
|
|
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('media="print"');
|
|
386
|
+
expect(result).toContain('onload="this.media=\'all\'"');
|
|
387
|
+
expect(result).toContain('<noscript>');
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test('applies basePath to the stylesheet URL', () => {
|
|
391
|
+
const html = '<html><head></head><body></body></html>';
|
|
392
|
+
const result = injectKatexStylesheet(html, p => '/course' + p);
|
|
393
|
+
|
|
394
|
+
expect(result).toContain('href="/course/katex/katex.min.css"');
|
|
395
|
+
});
|
|
396
|
+
});
|