@abreen/tada 1.0.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.
Files changed (125) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +290 -0
  3. package/bin/tada.js +361 -0
  4. package/config/authors.json +1 -0
  5. package/config/nav.json +28 -0
  6. package/content/index.md +19 -0
  7. package/content/lectures/01/Pair.java.md +296 -0
  8. package/content/lectures/01/Rectangle.java +80 -0
  9. package/content/lectures/01/demo.py +9 -0
  10. package/content/lectures/01/index.md +39 -0
  11. package/content/lectures/01/lecture1.pdf +0 -0
  12. package/content/lectures/index.md +25 -0
  13. package/content/markdown.md +379 -0
  14. package/content/problem_sets/index.md +6 -0
  15. package/fonts/google-sans-code/GoogleSansCodeVariable-Italic.ttf +0 -0
  16. package/fonts/google-sans-code/GoogleSansCodeVariable.ttf +0 -0
  17. package/fonts/google-sans-code/LICENSE.txt +93 -0
  18. package/fonts/inter/InterVariable-Italic.ttf +0 -0
  19. package/fonts/inter/InterVariable.ttf +0 -0
  20. package/fonts/inter/LICENSE.txt +92 -0
  21. package/package.json +70 -0
  22. package/public/avatars/alex.jpg +0 -0
  23. package/public/test.txt +1 -0
  24. package/src/_mixins.scss +4 -0
  25. package/src/anchor/README.md +6 -0
  26. package/src/anchor/index.ts +34 -0
  27. package/src/anchor/style.scss +48 -0
  28. package/src/code/README.md +5 -0
  29. package/src/code/index.ts +113 -0
  30. package/src/code/style.scss +101 -0
  31. package/src/code.scss +54 -0
  32. package/src/header/README.md +8 -0
  33. package/src/header/index.ts +43 -0
  34. package/src/header/style.scss +228 -0
  35. package/src/index.ts +73 -0
  36. package/src/layout.scss +144 -0
  37. package/src/literate/style.scss +60 -0
  38. package/src/print/README.md +4 -0
  39. package/src/print/index.ts +32 -0
  40. package/src/print/style.scss +82 -0
  41. package/src/question/README.md +3 -0
  42. package/src/question/index.ts +25 -0
  43. package/src/question/style.scss +116 -0
  44. package/src/search/README.md +6 -0
  45. package/src/search/index.ts +574 -0
  46. package/src/search/style.scss +217 -0
  47. package/src/style.scss +815 -0
  48. package/src/timezone/index.test.ts +100 -0
  49. package/src/timezone/index.ts +298 -0
  50. package/src/timezone/style.scss +16 -0
  51. package/src/timezone/timezones.json +58 -0
  52. package/src/toc/README.md +3 -0
  53. package/src/toc/index.ts +322 -0
  54. package/src/toc/style.scss +203 -0
  55. package/src/top/README.md +4 -0
  56. package/src/top/index.ts +75 -0
  57. package/src/util.ts +122 -0
  58. package/templates/_author.html +27 -0
  59. package/templates/_bottom.html +3 -0
  60. package/templates/_download.html +1 -0
  61. package/templates/_heading.html +19 -0
  62. package/templates/_nav.html +18 -0
  63. package/templates/_theme.scss +97 -0
  64. package/templates/_top.html +87 -0
  65. package/templates/authors.schema.json +13 -0
  66. package/templates/code.html +31 -0
  67. package/templates/default.html +13 -0
  68. package/templates/literate.html +16 -0
  69. package/templates/nav.schema.json +27 -0
  70. package/tsconfig.json +15 -0
  71. package/types/dev.ts +3 -0
  72. package/types/sass.d.ts +1 -0
  73. package/types/site-variables.d.ts +16 -0
  74. package/webpack/apply-base-path-plugin.js +78 -0
  75. package/webpack/build-state.js +97 -0
  76. package/webpack/code.test.js +162 -0
  77. package/webpack/colors.js +15 -0
  78. package/webpack/config.base.js +147 -0
  79. package/webpack/config.dev.js +23 -0
  80. package/webpack/config.prod.js +32 -0
  81. package/webpack/content-watch-plugin.js +153 -0
  82. package/webpack/deflist-id-plugin.js +62 -0
  83. package/webpack/external-links-plugin.js +37 -0
  84. package/webpack/features.js +5 -0
  85. package/webpack/flair.json +1 -0
  86. package/webpack/generate-content-assets-plugin.js +308 -0
  87. package/webpack/generate-favicon-plugin.js +198 -0
  88. package/webpack/generate-fonts-plugin.js +69 -0
  89. package/webpack/generate-manifest-plugin.js +116 -0
  90. package/webpack/globals.js +74 -0
  91. package/webpack/heading-subtitle-plugin.js +80 -0
  92. package/webpack/json-schema.js +19 -0
  93. package/webpack/log.js +143 -0
  94. package/webpack/markdown-plugins.test.js +203 -0
  95. package/webpack/pagefind-plugin.js +379 -0
  96. package/webpack/pagefind-plugin.test.js +131 -0
  97. package/webpack/pdf-text.js +163 -0
  98. package/webpack/print-flair-plugin.js +22 -0
  99. package/webpack/reachability.js +273 -0
  100. package/webpack/reachability.test.js +80 -0
  101. package/webpack/serve.js +104 -0
  102. package/webpack/site-variables.js +53 -0
  103. package/webpack/site.schema.json +67 -0
  104. package/webpack/templates.js +128 -0
  105. package/webpack/text-to-id.js +8 -0
  106. package/webpack/toc-plugin.js +167 -0
  107. package/webpack/util.js +49 -0
  108. package/webpack/utils/code.js +439 -0
  109. package/webpack/utils/content-files.js +147 -0
  110. package/webpack/utils/define-plugin.js +20 -0
  111. package/webpack/utils/file-types.js +26 -0
  112. package/webpack/utils/front-matter.js +57 -0
  113. package/webpack/utils/jdi-runner/LiterateRunner.class +0 -0
  114. package/webpack/utils/jdi-runner/LiterateRunner.java +241 -0
  115. package/webpack/utils/literate-java.js +153 -0
  116. package/webpack/utils/markdown.js +244 -0
  117. package/webpack/utils/parse-hsl.js +8 -0
  118. package/webpack/utils/paths.js +58 -0
  119. package/webpack/utils/render.js +466 -0
  120. package/webpack/utils/shiki-highlighter.js +26 -0
  121. package/webpack/validate-internal-links-plugin.js +155 -0
  122. package/webpack/watch-reachability-state.js +273 -0
  123. package/webpack/watch-reachability-state.test.js +198 -0
  124. package/webpack/watch-reload-client.js +54 -0
  125. package/webpack/watch.js +166 -0
@@ -0,0 +1,116 @@
1
+ const { createApplyBasePath } = require('./util');
2
+
3
+ class GenerateManifestPlugin {
4
+ constructor(siteVariables) {
5
+ this.siteVariables = siteVariables || {};
6
+ }
7
+
8
+ apply(compiler) {
9
+ compiler.hooks.thisCompilation.tap(
10
+ 'GenerateManifestPlugin',
11
+ compilation => {
12
+ const wp = compilation.compiler.webpack || {};
13
+ const { RawSource } =
14
+ (wp.sources && wp.sources) || require('webpack-sources');
15
+
16
+ compilation.hooks.processAssets.tapPromise(
17
+ {
18
+ name: 'GenerateManifestPlugin',
19
+ stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
20
+ },
21
+ async () => {
22
+ try {
23
+ const manifest = createManifest(this.siteVariables);
24
+ const output = JSON.stringify(manifest);
25
+ compilation.emitAsset('manifest.json', new RawSource(output));
26
+ } catch (err) {
27
+ compilation.errors.push(
28
+ new Error(`Error: ${err && err.message ? err.message : err}`),
29
+ );
30
+ }
31
+ },
32
+ );
33
+ },
34
+ );
35
+ }
36
+ }
37
+
38
+ module.exports = GenerateManifestPlugin;
39
+
40
+ function createManifest(siteVariables) {
41
+ const applyBasePath = createApplyBasePath(siteVariables);
42
+
43
+ return {
44
+ name: siteVariables.title,
45
+ start_url: applyBasePath('/index.html'),
46
+ display: 'minimal-ui',
47
+ icons: [
48
+ {
49
+ src: 'favicon.svg',
50
+ sizes: 'any',
51
+ type: 'image/svg+xml',
52
+ purpose: 'any maskable',
53
+ },
54
+ {
55
+ src: 'favicon-1024.png',
56
+ sizes: '1024x1024',
57
+ type: 'image/png',
58
+ purpose: 'any maskable',
59
+ },
60
+ {
61
+ src: 'favicon-512.png',
62
+ sizes: '512x512',
63
+ type: 'image/png',
64
+ purpose: 'any maskable',
65
+ },
66
+ {
67
+ src: 'favicon-256.png',
68
+ sizes: '256x256',
69
+ type: 'image/png',
70
+ purpose: 'any maskable',
71
+ },
72
+ {
73
+ src: 'favicon-192.png',
74
+ sizes: '192x192',
75
+ type: 'image/png',
76
+ purpose: 'any maskable',
77
+ },
78
+ {
79
+ src: 'favicon-128.png',
80
+ sizes: '128x128',
81
+ type: 'image/png',
82
+ purpose: 'any maskable',
83
+ },
84
+ {
85
+ src: 'favicon-64.png',
86
+ sizes: '64x64',
87
+ type: 'image/png',
88
+ purpose: 'any',
89
+ },
90
+ {
91
+ src: 'favicon-48.png',
92
+ sizes: '48x48',
93
+ type: 'image/png',
94
+ purpose: 'any',
95
+ },
96
+ {
97
+ src: 'favicon-32.png',
98
+ sizes: '32x32',
99
+ type: 'image/png',
100
+ purpose: 'any',
101
+ },
102
+ {
103
+ src: 'favicon-16.png',
104
+ sizes: '16x16',
105
+ type: 'image/png',
106
+ purpose: 'any',
107
+ },
108
+ {
109
+ src: 'favicon.ico',
110
+ sizes: '16x16 32x32 48x48 64x64 128x128 192x192 256x256',
111
+ type: 'image/x-icon',
112
+ purpose: 'any maskable',
113
+ },
114
+ ],
115
+ };
116
+ }
@@ -0,0 +1,74 @@
1
+ const timezones = require('../src/timezone/timezones.json');
2
+
3
+ module.exports = function createGlobals(site, page, subPath) {
4
+ const siteVars = page; // second arg receives siteVariables per render.js call convention
5
+ const defaultTz = timezones.find(t => t.value === siteVars.defaultTimeZone);
6
+ const timezoneChooser = defaultTz
7
+ ? `<select class="time-zone" hidden></select><noscript>Times shown in ${defaultTz.abbreviation}.</noscript>`
8
+ : '<select class="time-zone" hidden></select>';
9
+
10
+ return {
11
+ isHomePage: subPath === 'index',
12
+ isoDate,
13
+ readableDate,
14
+ classNames,
15
+ cx: classNames,
16
+ timezoneChooser,
17
+ };
18
+ };
19
+
20
+ function isoDate(str) {
21
+ if (str == null || str == '') {
22
+ return null;
23
+ }
24
+ const date = new Date(str);
25
+ return date.toISOString().slice(0, 10);
26
+ }
27
+
28
+ function readableDate(date) {
29
+ if (date == null || date == '') {
30
+ return '';
31
+ }
32
+
33
+ if (!(date instanceof Date)) {
34
+ date = new Date(date);
35
+ }
36
+
37
+ const str = date.toISOString();
38
+ const year = str.slice(0, 4);
39
+ let month = str.slice(5, 7);
40
+ if (month[0] === '0') {
41
+ month = month[1];
42
+ }
43
+ let day = str.slice(8, 10);
44
+ if (day[0] === '0') {
45
+ day = day[1];
46
+ }
47
+
48
+ const months = {
49
+ 1: 'January',
50
+ 2: 'February',
51
+ 3: 'March',
52
+ 4: 'April',
53
+ 5: 'May',
54
+ 6: 'June',
55
+ 7: 'July',
56
+ 8: 'August',
57
+ 9: 'September',
58
+ 10: 'October',
59
+ 11: 'November',
60
+ 12: 'December',
61
+ };
62
+
63
+ return `${months[month]} ${day}, ${year}`;
64
+ }
65
+
66
+ function classNames(obj) {
67
+ const names = [];
68
+ for (const key in obj) {
69
+ if (!!obj[key]) {
70
+ names.push(key);
71
+ }
72
+ }
73
+ return names.join(' ');
74
+ }
@@ -0,0 +1,80 @@
1
+ module.exports = function specialHeadingsPlugin(md) {
2
+ md.core.ruler.push('special_headings', state => {
3
+ const tokens = state.tokens;
4
+ for (let i = 0; i < tokens.length; i++) {
5
+ if (tokens[i].type !== 'heading_open') {
6
+ continue;
7
+ }
8
+
9
+ // Expected structure: heading_open -> inline -> heading_close
10
+ const inline = tokens[i + 1];
11
+ const close = tokens[i + 2];
12
+ if (
13
+ !inline ||
14
+ inline.type !== 'inline' ||
15
+ !close ||
16
+ close.type !== 'heading_close'
17
+ ) {
18
+ continue;
19
+ }
20
+
21
+ const original = inline.content;
22
+ // Match: <main> # <subtitle>
23
+ const parts = original.split(/ # /);
24
+ if (parts.length < 2) {
25
+ continue;
26
+ }
27
+
28
+ const main = parts.shift();
29
+ const subtitle = parts.join(' # ').trim();
30
+ if (!main || !subtitle) {
31
+ continue;
32
+ }
33
+
34
+ // Find the delimiter " # " inside child text tokens to wrap the remainder
35
+ if (Array.isArray(inline.children)) {
36
+ for (let ci = 0; ci < inline.children.length; ci++) {
37
+ const child = inline.children[ci];
38
+ if (child.type !== 'text') {
39
+ continue;
40
+ }
41
+ const sepIndex = child.content.indexOf(' # ');
42
+ if (sepIndex === -1) {
43
+ continue;
44
+ }
45
+
46
+ // Split the text token around the first " # "
47
+ const before = child.content.slice(0, sepIndex).trimEnd();
48
+ const afterPart = child.content.slice(sepIndex + 3); // skip " # "
49
+ child.content = before + ' '; // ensure a single space before subtitle
50
+
51
+ // Collect subtitle tokens: (afterPart as text, plus all following siblings)
52
+ const subtitleTokens = [];
53
+ if (afterPart) {
54
+ const t = new state.Token('text', '', 0);
55
+ t.content = afterPart;
56
+ subtitleTokens.push(t);
57
+ }
58
+ for (let k = ci + 1; k < inline.children.length; k++) {
59
+ subtitleTokens.push(inline.children[k]);
60
+ }
61
+
62
+ // Truncate children after the (modified) delimiter text token
63
+ inline.children.length = ci + 1;
64
+
65
+ // Inject span wrapper with preserved formatting tokens
66
+ const openSpan = new state.Token('html_inline', '', 0);
67
+ openSpan.content = '<span class="heading-subtitle">';
68
+ const closeSpan = new state.Token('html_inline', '', 0);
69
+ closeSpan.content = '</span>';
70
+ inline.children.push(openSpan, ...subtitleTokens, closeSpan);
71
+ break;
72
+ }
73
+ }
74
+
75
+ // Update inline.content (plain text representation)
76
+ inline.content = `${main} ${subtitle}`;
77
+ tokens[i].attrJoin('class', 'has-subtitle');
78
+ }
79
+ });
80
+ };
@@ -0,0 +1,19 @@
1
+ const JsonSchemaCompiler = require('ajv');
2
+
3
+ const compiler = new JsonSchemaCompiler();
4
+
5
+ function compile(schema) {
6
+ return compiler.compile(schema);
7
+ }
8
+
9
+ function doValidation(validator, input, fileName) {
10
+ const valid = validator(input);
11
+ if (!valid) {
12
+ validator.errors.forEach(error => {
13
+ console.error(error);
14
+ });
15
+ throw new Error(`JSON file failed validation: ${fileName}`);
16
+ }
17
+ }
18
+
19
+ module.exports = { compile, doValidation };
package/webpack/log.js ADDED
@@ -0,0 +1,143 @@
1
+ const { inspect } = require('node:util');
2
+ const path = require('path');
3
+ const { G, R, P, Y, L } = require('./colors');
4
+ const FLAIR_STRINGS = require('./flair.json');
5
+
6
+ const LEVELS = ['debug', 'info', 'warn', 'error'];
7
+
8
+ const ENV_LOG_LEVEL = process.env.TADA_LOG_LEVEL;
9
+
10
+ if (ENV_LOG_LEVEL && !LEVELS.includes(ENV_LOG_LEVEL)) {
11
+ throw new Error(
12
+ `Invalid TADA_LOG_LEVEL "${ENV_LOG_LEVEL}", must be one of: ${LEVELS.join(', ')}`,
13
+ );
14
+ }
15
+
16
+ function shouldLog(loggerLevel, level) {
17
+ return LEVELS.indexOf(level) >= LEVELS.indexOf(loggerLevel);
18
+ }
19
+
20
+ function validateLevel(level) {
21
+ if (!LEVELS.includes(level)) {
22
+ throw new Error(
23
+ `Invalid log level "${level}", must be one of: ${LEVELS.join(', ')}`,
24
+ );
25
+ }
26
+ }
27
+
28
+ function print(strings, stream = 'stdout', end = '\n') {
29
+ for (const s of strings) {
30
+ process[stream].write(s);
31
+ }
32
+ process[stream].write(end);
33
+ }
34
+
35
+ function makeLogger(name, logLevel = 'info') {
36
+ validateLevel(logLevel);
37
+
38
+ if (ENV_LOG_LEVEL) {
39
+ logLevel = ENV_LOG_LEVEL;
40
+ }
41
+
42
+ if (!name) {
43
+ name = '';
44
+ } else {
45
+ // Allow for passing __filename
46
+ name = path.basename(name);
47
+ }
48
+
49
+ const logger = {
50
+ /** Don't log if the level is < minLogLevel */
51
+ setMinLogLevel(minLogLevel) {
52
+ this.minLogLevel = minLogLevel;
53
+ },
54
+ getArgs(level, strings, args, colorFn) {
55
+ const params = [];
56
+ params.push(colorFn`${level}` + '\t');
57
+ params.push(format(strings, ...args));
58
+ return params;
59
+ },
60
+ debug(strings, ...args) {
61
+ if (shouldLog(this.minLogLevel, 'debug')) {
62
+ print(this.getArgs('debug', strings, args, L), 'stderr');
63
+ }
64
+ },
65
+ info(strings, ...args) {
66
+ if (shouldLog(this.minLogLevel, 'info')) {
67
+ print(this.getArgs('info', strings, args, L));
68
+ }
69
+ },
70
+ warn(strings, ...args) {
71
+ if (shouldLog(this.minLogLevel, 'warn')) {
72
+ print(this.getArgs('warn', strings, args, Y));
73
+ }
74
+ },
75
+ error(strings, ...args) {
76
+ if (shouldLog(this.minLogLevel, 'error')) {
77
+ print(this.getArgs('error', strings, args, R));
78
+ }
79
+ },
80
+ event(strings, ...args) {
81
+ print(this.getArgs('event', strings, args, G));
82
+ },
83
+ followup(strings) {
84
+ print(strings);
85
+ },
86
+ };
87
+
88
+ logger.setMinLogLevel(logLevel);
89
+ return logger;
90
+ }
91
+
92
+ function format(strings, ...args) {
93
+ // Called as template tag: first arg is an array-like with .raw
94
+ if (strings && typeof strings === 'object' && 'raw' in strings) {
95
+ try {
96
+ return String.raw(strings, ...args.map(toString));
97
+ } catch (e) {
98
+ // fallback to safe join
99
+ }
100
+ } else {
101
+ if (Array.isArray(strings)) {
102
+ args.unshift(...strings);
103
+ } else {
104
+ args.unshift(strings);
105
+ }
106
+
107
+ return args.map(toString).join(' ');
108
+ }
109
+ }
110
+
111
+ function toString(item) {
112
+ if (item === undefined) {
113
+ return 'undefined';
114
+ }
115
+ if (item === null) {
116
+ return 'null';
117
+ }
118
+ if (typeof item === 'string') {
119
+ return item;
120
+ }
121
+
122
+ try {
123
+ if (typeof item === 'object') {
124
+ return inspect(item, {
125
+ compact: true,
126
+ depth: 2,
127
+ breakLength: 80,
128
+ maxStringLength: 250,
129
+ colors: true,
130
+ });
131
+ }
132
+ throw new Error('not an object');
133
+ } catch (e) {
134
+ return String(item);
135
+ }
136
+ }
137
+
138
+ function getFlair() {
139
+ const i = Math.floor(Math.random() * FLAIR_STRINGS.length);
140
+ return P`${FLAIR_STRINGS[i]}!` + ' 🎉';
141
+ }
142
+
143
+ module.exports = { makeLogger, getFlair };
@@ -0,0 +1,203 @@
1
+ const { describe, expect, test } = require('bun:test');
2
+ const MarkdownIt = require('markdown-it');
3
+ const deflist = require('markdown-it-deflist');
4
+ const applyBasePathPlugin = require('./apply-base-path-plugin');
5
+ const deflistIdPlugin = require('./deflist-id-plugin');
6
+ const externalLinksPlugin = require('./external-links-plugin');
7
+ const headingSubtitlePlugin = require('./heading-subtitle-plugin');
8
+ const { createMarkdown } = require('./utils/markdown');
9
+
10
+ describe('apply-base-path-plugin', () => {
11
+ test('rewrites internal links, images, and raw html image sources', () => {
12
+ const md = new MarkdownIt({ html: true });
13
+ md.use(applyBasePathPlugin, {
14
+ basePath: '/course/',
15
+ codeLanguages: { java: 'java' },
16
+ features: { code: true },
17
+ });
18
+
19
+ const html = md.render(
20
+ [
21
+ '[Code](/src/example.java?view=1#top)',
22
+ '',
23
+ '![Image](/images/pic.png)',
24
+ '',
25
+ '<img src="/images/raw.png" alt="Raw">',
26
+ ].join('\n'),
27
+ );
28
+
29
+ expect(html).toContain('href="/course/src/example.html?view=1#top"');
30
+ expect(html).toContain('src="/course/images/pic.png"');
31
+ expect(html).toContain('src="/course/images/raw.png"');
32
+ });
33
+
34
+ test('rewrites relative code file links without applying base path', () => {
35
+ const md = new MarkdownIt();
36
+ md.use(applyBasePathPlugin, {
37
+ basePath: '/course/',
38
+ codeLanguages: { java: 'java' },
39
+ features: { code: true },
40
+ });
41
+
42
+ const html = md.render('[App](./App.java)');
43
+
44
+ expect(html).toContain('href="./App.html"');
45
+ expect(html).not.toContain('href="./App.java"');
46
+ });
47
+
48
+ test('keeps code file extensions when the code feature is disabled', () => {
49
+ const md = new MarkdownIt();
50
+ md.use(applyBasePathPlugin, {
51
+ basePath: '/course',
52
+ codeLanguages: { java: 'java' },
53
+ features: { code: false },
54
+ });
55
+
56
+ const html = md.render('[Code](/src/example.java#L1)');
57
+
58
+ expect(html).toContain('href="/course/src/example.java#L1"');
59
+ expect(html).not.toContain('href="/course/src/example.html#L1"');
60
+ });
61
+ });
62
+
63
+ describe('external-links-plugin', () => {
64
+ test('marks external http links without changing internal or non-http links', () => {
65
+ const md = new MarkdownIt();
66
+ md.use(externalLinksPlugin, { internalDomains: ['example.com'] });
67
+
68
+ const html = md.render(
69
+ [
70
+ '[External](https://outside.example/docs)',
71
+ '[Internal](https://example.com/docs)',
72
+ '[Mail](mailto:test@example.com)',
73
+ ].join(' '),
74
+ );
75
+
76
+ expect(html).toContain(
77
+ '<a href="https://outside.example/docs" class="external" target="_blank">External</a>',
78
+ );
79
+ expect(html).toContain('<a href="https://example.com/docs">Internal</a>');
80
+ expect(html).toContain('<a href="mailto:test@example.com">Mail</a>');
81
+ });
82
+ });
83
+
84
+ describe('heading-subtitle-plugin', () => {
85
+ test('wraps heading subtitles while preserving inline formatting', () => {
86
+ const md = new MarkdownIt();
87
+ md.use(headingSubtitlePlugin);
88
+
89
+ const html = md.render('## Title # *Subtitle*').trim();
90
+
91
+ expect(html).toBe(
92
+ '<h2 class="has-subtitle">Title <span class="heading-subtitle"><em>Subtitle</em></span></h2>',
93
+ );
94
+ });
95
+ });
96
+
97
+ describe('deflist-id-plugin', () => {
98
+ test('injects unique ids for terms and falls back when a term slug is empty', () => {
99
+ const md = new MarkdownIt();
100
+ md.use(deflist);
101
+ md.use(deflistIdPlugin);
102
+
103
+ const html = md.render(
104
+ ['Term 1', ': One', '', 'Term 1', ': Two', '', '!!!', ': Three'].join(
105
+ '\n',
106
+ ),
107
+ );
108
+
109
+ expect(html).toContain('<a id="term-1"></a>Term 1');
110
+ expect(html).toContain('<a id="term-1-2"></a>Term 1');
111
+ expect(html).toContain('<a id="term"></a>!!!');
112
+ });
113
+ });
114
+
115
+ describe('custom markdown containers', () => {
116
+ function createProjectMarkdown() {
117
+ return createMarkdown(
118
+ { basePath: '/', internalDomains: [], codeLanguages: {}, features: {} },
119
+ { validatorOptions: { enabled: false } },
120
+ );
121
+ }
122
+
123
+ test('renders collapsible details blocks', () => {
124
+ const md = createProjectMarkdown();
125
+
126
+ const html = md.render(
127
+ ['<<< details More info', 'Hello', '<<<'].join('\n'),
128
+ );
129
+
130
+ expect(html).toContain('<details><summary>More info</summary>');
131
+ expect(html).toContain('<div class="content">');
132
+ expect(html).toContain('<p>Hello</p>');
133
+ expect(html).toContain('</div></details>');
134
+ });
135
+
136
+ test('renders collapsible details blocks with inline Markdown in summary', () => {
137
+ const md = createProjectMarkdown();
138
+
139
+ const html = md.render(
140
+ ['<<< details More **info**', 'Hello', '<<<'].join('\n'),
141
+ );
142
+
143
+ expect(html).toContain(
144
+ '<details><summary>More <strong>info</strong></summary>',
145
+ );
146
+ });
147
+
148
+ test('renders section containers as section elements', () => {
149
+ const md = createProjectMarkdown();
150
+
151
+ const html = md.render(['::: section', 'Body', ':::'].join('\n'));
152
+
153
+ expect(html).toContain('<section>');
154
+ expect(html).toContain('<p>Body</p>');
155
+ expect(html).toContain('</section>');
156
+ });
157
+
158
+ test('renders questions with question and spoiler answer', () => {
159
+ const md = createProjectMarkdown();
160
+
161
+ const html = md.render(
162
+ [
163
+ '??? question What is a data structure?',
164
+ 'An organized collection.',
165
+ '???',
166
+ ].join('\n'),
167
+ );
168
+
169
+ expect(html).toContain('<div class="question">');
170
+ expect(html).toContain(
171
+ '<p class="question-q"><span class="question-label">Q.</span><span>What is a data structure?</span></p>',
172
+ );
173
+ expect(html).toContain('<p class="question-a-label">A.</p>');
174
+ expect(html).toContain(
175
+ '<div class="question-a-body" data-pagefind-ignore>',
176
+ );
177
+ expect(html).toContain('<p>An organized collection.</p>');
178
+ });
179
+
180
+ test('renders alert containers with custom and default titles', () => {
181
+ const md = createProjectMarkdown();
182
+
183
+ const html = md.render(
184
+ [
185
+ '!!! note "Read this"',
186
+ 'Body',
187
+ '!!!',
188
+ '',
189
+ '!!! warning',
190
+ 'Careful',
191
+ '!!!',
192
+ ].join('\n'),
193
+ );
194
+
195
+ expect(html).toContain('<div class="alert note">');
196
+ expect(html).toContain('<p class="title" id="read-this">');
197
+ expect(html).toContain(
198
+ '<div class="alert warning"><p class="title">Warning</p>',
199
+ );
200
+ expect(html).toContain('<p>Body</p>');
201
+ expect(html).toContain('<p>Careful</p>');
202
+ });
203
+ });