@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,466 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const _ = require('lodash');
4
+ const { stripHtml } = require('string-strip-html');
5
+ const { makeLogger } = require('../log');
6
+ const { B } = require('../colors');
7
+ const createGlobals = require('../globals');
8
+ const { render, json } = require('../templates');
9
+ const {
10
+ extractJavaMethodToc,
11
+ renderCodeSegment,
12
+ renderCodeWithComments,
13
+ } = require('./code');
14
+ const { extensionIsMarkdown } = require('./file-types');
15
+ const { createApplyBasePath, normalizeOutputPath } = require('./paths');
16
+ const { parseFrontMatterAndContent } = require('./front-matter');
17
+ const { createMarkdown } = require('./markdown');
18
+ const { generateTocHtml, generateCodeTocHtml } = require('../toc-plugin');
19
+ const {
20
+ parseLiterateJava,
21
+ hasMainMethod,
22
+ deriveClassName,
23
+ compileJavaSource,
24
+ executeLiterateJava,
25
+ } = require('./literate-java');
26
+
27
+ const log = makeLogger(__filename);
28
+
29
+ const REQUIRED_FRONT_MATTER_FIELDS = ['title'];
30
+
31
+ function resolveAuthor(pageVariables, filePath) {
32
+ if (!pageVariables.author) {
33
+ return;
34
+ }
35
+ const authors = json('authors.json');
36
+ const authorKey = pageVariables.author;
37
+ const authorEntry = authors[authorKey];
38
+ if (!authorEntry) {
39
+ throw new Error(
40
+ `${filePath}: unknown author "${authorKey}" (not found in authors.json)`,
41
+ );
42
+ }
43
+ pageVariables.author = authorEntry;
44
+ }
45
+
46
+ function validateFrontMatter(pageVariables, filePath) {
47
+ let valid = true;
48
+ for (const field of REQUIRED_FRONT_MATTER_FIELDS) {
49
+ if (!pageVariables[field]) {
50
+ log.error`${filePath}: missing required front matter field: "${field}"`;
51
+ valid = false;
52
+ }
53
+ }
54
+ return valid;
55
+ }
56
+
57
+ function createTemplateParameters({
58
+ pageVariables,
59
+ siteVariables,
60
+ content,
61
+ applyBasePath,
62
+ subPath,
63
+ }) {
64
+ return {
65
+ ...(siteVariables.vars || {}),
66
+ ...createGlobals(pageVariables, siteVariables, subPath),
67
+ site: siteVariables,
68
+ base: siteVariables.base,
69
+ basePath: siteVariables.basePath,
70
+ page: pageVariables,
71
+ content,
72
+ applyBasePath,
73
+ };
74
+ }
75
+
76
+ function injectWebpackAssets(html, compilation, applyBasePath) {
77
+ const assets = compilation.getAssets();
78
+ const jsAssets = assets
79
+ .filter(asset => asset.name.endsWith('.js'))
80
+ .map(asset => asset.name);
81
+ const cssAssets = assets
82
+ .filter(asset => asset.name.endsWith('.css'))
83
+ .map(asset => asset.name);
84
+
85
+ const scriptTags = jsAssets
86
+ .map(asset => `<script defer src="${applyBasePath('/' + asset)}"></script>`)
87
+ .join('');
88
+ const linkTags = cssAssets
89
+ .map(
90
+ asset => `<link href="${applyBasePath('/' + asset)}" rel="stylesheet">`,
91
+ )
92
+ .join('');
93
+
94
+ return html
95
+ .replace('<head>', `<head>${linkTags}`)
96
+ .replace('</head>', `${scriptTags}</head>`);
97
+ }
98
+
99
+ function toContentAssetPath(contentDir, filePath) {
100
+ return path
101
+ .relative(contentDir, filePath)
102
+ .split(path.sep)
103
+ .join(path.posix.sep);
104
+ }
105
+
106
+ function renderPlainTextPageAsset({
107
+ filePath,
108
+ contentDir,
109
+ siteVariables,
110
+ validInternalTargets,
111
+ compilation,
112
+ }) {
113
+ const { dir, name, ext } = path.parse(filePath);
114
+ const subPath = path.relative(contentDir, path.join(dir, name));
115
+ const applyBasePath = createApplyBasePath(siteVariables);
116
+
117
+ log.info`Rendering page ${B`${subPath + ext}`}`;
118
+ const { content, pageVariables, tocItems } = renderPlainTextContent(
119
+ filePath,
120
+ subPath,
121
+ siteVariables,
122
+ applyBasePath,
123
+ validInternalTargets,
124
+ { validateInternalLinks: extensionIsMarkdown(ext.toLowerCase()) },
125
+ );
126
+
127
+ if (!validateFrontMatter(pageVariables, filePath)) {
128
+ return [];
129
+ }
130
+
131
+ if (!pageVariables.template) {
132
+ pageVariables.template = 'default';
133
+ }
134
+
135
+ if (pageVariables.toc && tocItems) {
136
+ pageVariables.tocHtml = generateTocHtml(tocItems);
137
+ }
138
+
139
+ const templateParameters = createTemplateParameters({
140
+ pageVariables,
141
+ siteVariables,
142
+ content,
143
+ applyBasePath,
144
+ subPath,
145
+ });
146
+
147
+ const html = injectWebpackAssets(
148
+ render(`${pageVariables.template}.html`, templateParameters),
149
+ compilation,
150
+ applyBasePath,
151
+ );
152
+
153
+ return [
154
+ {
155
+ assetPath: toContentAssetPath(contentDir, path.join(dir, `${name}.html`)),
156
+ content: html,
157
+ },
158
+ ];
159
+ }
160
+
161
+ function renderCodePageAsset({
162
+ filePath,
163
+ contentDir,
164
+ siteVariables,
165
+ compilation,
166
+ }) {
167
+ const { dir, name, ext } = path.parse(filePath);
168
+ const subPath = path.relative(contentDir, path.join(dir, name));
169
+ const applyBasePath = createApplyBasePath(siteVariables);
170
+ const lang = siteVariables.codeLanguages[ext.slice(1).toLowerCase()];
171
+ const sourceCode = fs.readFileSync(filePath, 'utf-8');
172
+
173
+ log.info`Rendering code page ${B`${subPath + ext}`}`;
174
+ const content = renderCodeWithComments(sourceCode, lang, siteVariables);
175
+ const codeFilePath = applyBasePath(
176
+ normalizeOutputPath(`/${toContentAssetPath(contentDir, filePath)}`),
177
+ );
178
+ const titleHtml = `<tt>${name + ext}</tt>`;
179
+ const tocItems = lang === 'java' ? extractJavaMethodToc(sourceCode) : [];
180
+ const tocHtml = generateCodeTocHtml(tocItems);
181
+ const pageVariables = {
182
+ template: 'code',
183
+ filePath,
184
+ title: `${name}${ext}`,
185
+ titleHtml,
186
+ codeFilePath,
187
+ downloadName: `${name}${ext}`,
188
+ tocItems,
189
+ tocHtml,
190
+ };
191
+
192
+ const templateParameters = createTemplateParameters({
193
+ pageVariables,
194
+ siteVariables,
195
+ content,
196
+ applyBasePath,
197
+ subPath,
198
+ });
199
+
200
+ const html = injectWebpackAssets(
201
+ render('code.html', templateParameters),
202
+ compilation,
203
+ applyBasePath,
204
+ );
205
+
206
+ return [
207
+ {
208
+ assetPath: toContentAssetPath(contentDir, path.join(dir, `${name}.html`)),
209
+ content: html,
210
+ },
211
+ ];
212
+ }
213
+
214
+ function renderCopiedContentAsset({ filePath, contentDir }) {
215
+ const ext = path.extname(filePath).toLowerCase();
216
+ const label = ext === '.pdf' ? 'Copying' : 'Copying source file';
217
+ const relPath = toContentAssetPath(contentDir, filePath);
218
+
219
+ log.info`${label} ${B`${relPath}`}`;
220
+ return [{ assetPath: relPath, content: fs.readFileSync(filePath) }];
221
+ }
222
+
223
+ /** Parses the file, renders using template, returns HTML & params used to generate page */
224
+ function renderPlainTextContent(
225
+ filePath,
226
+ subPath,
227
+ siteVariables,
228
+ applyBasePath,
229
+ validInternalTargets,
230
+ { validateInternalLinks = true } = {},
231
+ ) {
232
+ const sourceUrlPath = `/${subPath}.html`;
233
+ const md = createMarkdown(siteVariables, {
234
+ validatorOptions: {
235
+ enabled: validateInternalLinks,
236
+ filePath,
237
+ sourceUrlPath,
238
+ validTargets: validInternalTargets,
239
+ codeExtensions:
240
+ siteVariables.features?.code === false
241
+ ? []
242
+ : Object.keys(siteVariables.codeLanguages),
243
+ },
244
+ });
245
+
246
+ const ext = path.extname(filePath);
247
+ const raw = fs.readFileSync(filePath, 'utf-8');
248
+
249
+ const { pageVariables, content } = parseFrontMatterAndContent(raw, ext);
250
+
251
+ // Handle substitutions inside front matter using siteVariables
252
+ const siteOnlyParams = createTemplateParameters({
253
+ pageVariables: {},
254
+ siteVariables,
255
+ content: null,
256
+ applyBasePath,
257
+ subPath,
258
+ });
259
+ const pageVariablesProcessed = Object.entries(pageVariables)
260
+ .map(([k, v]) => {
261
+ const newValue =
262
+ typeof v === 'string' ? _.template(v)(siteOnlyParams) : v;
263
+ return [k, newValue];
264
+ })
265
+ .reduce((acc, [k, v]) => {
266
+ acc[k] = v;
267
+ return acc;
268
+ }, {});
269
+
270
+ // Render title and description as inline Markdown
271
+ if (pageVariablesProcessed.title) {
272
+ const titleHtml = md.renderInline(pageVariablesProcessed.title);
273
+ pageVariablesProcessed.titleHtml = titleHtml;
274
+ pageVariablesProcessed.title = stripHtml(titleHtml).result;
275
+ }
276
+ if (pageVariablesProcessed.description) {
277
+ const descriptionHtml = md.renderInline(pageVariablesProcessed.description);
278
+ pageVariablesProcessed.descriptionHtml = descriptionHtml;
279
+ pageVariablesProcessed.description = stripHtml(descriptionHtml).result;
280
+ }
281
+
282
+ resolveAuthor(pageVariablesProcessed, filePath);
283
+
284
+ const strippedContent = stripHtmlComments(content);
285
+
286
+ const params = createTemplateParameters({
287
+ pageVariables: pageVariablesProcessed,
288
+ siteVariables,
289
+ content: strippedContent,
290
+ applyBasePath,
291
+ subPath,
292
+ });
293
+
294
+ let html = null;
295
+ try {
296
+ html = _.template(strippedContent)(params);
297
+ } catch (err) {
298
+ throw new Error(
299
+ `${filePath}: Lodash template error in page or template: ${err.message}`,
300
+ );
301
+ }
302
+
303
+ let tocItems = null;
304
+ if (extensionIsMarkdown(ext)) {
305
+ const env = {};
306
+ html = md.render(html, env);
307
+ tocItems = env.tocItems || null;
308
+ }
309
+
310
+ return { content: html, pageVariables: params.page, tocItems };
311
+ }
312
+
313
+ function stripHtmlComments(str) {
314
+ return str.replace(/<!---[\s\S]*?-->/g, '');
315
+ }
316
+
317
+ function renderLiterateJavaPageAsset({
318
+ filePath,
319
+ contentDir,
320
+ siteVariables,
321
+ compilation,
322
+ }) {
323
+ const { dir, name } = path.parse(filePath);
324
+ const className = deriveClassName(filePath);
325
+ const subPath = path.relative(contentDir, path.join(dir, className));
326
+ const applyBasePath = createApplyBasePath(siteVariables);
327
+
328
+ log.info`Rendering literate Java page ${B`${name}`}`;
329
+
330
+ const raw = fs.readFileSync(filePath, 'utf-8');
331
+ const {
332
+ pageVariables,
333
+ content,
334
+ javaSource,
335
+ codeBlocks,
336
+ visibleBlockIndices,
337
+ } = parseLiterateJava(raw, siteVariables);
338
+
339
+ if (!validateFrontMatter(pageVariables, filePath)) {
340
+ return [];
341
+ }
342
+
343
+ // Compile the concatenated Java source
344
+ let tempDir;
345
+ let blockOutputMap = null;
346
+ try {
347
+ tempDir = compileJavaSource(javaSource, className);
348
+
349
+ // Execute if there is a main() method
350
+ if (hasMainMethod(javaSource)) {
351
+ const outputEntries = executeLiterateJava(className, tempDir, codeBlocks);
352
+ blockOutputMap = new Map(outputEntries);
353
+ }
354
+ } finally {
355
+ if (tempDir) {
356
+ fs.rmSync(tempDir, { recursive: true, force: true });
357
+ }
358
+ }
359
+
360
+ // Render full markdown with a custom fence rule that replaces fences
361
+ // with Shiki-highlighted code blocks and optional JDI output columns
362
+ const md = createMarkdown(siteVariables, {
363
+ validatorOptions: { enabled: false },
364
+ });
365
+ let fenceIndex = 0;
366
+
367
+ md.renderer.rules.fence = (tokens, idx) => {
368
+ const token = tokens[idx];
369
+ const code = token.content;
370
+ const lines = code.endsWith('\n')
371
+ ? code.slice(0, -1).split('\n')
372
+ : code.split('\n');
373
+
374
+ // Dedent: strip common leading whitespace for display
375
+ const minIndent = lines.reduce((min, line) => {
376
+ if (line.trim().length === 0) {
377
+ return min;
378
+ }
379
+ const indent = line.match(/^(\s*)/)[1].length;
380
+ return Math.min(min, indent);
381
+ }, Infinity);
382
+ const dedented =
383
+ minIndent > 0 && minIndent < Infinity
384
+ ? lines.map(l => l.slice(minIndent))
385
+ : lines;
386
+
387
+ const blockIdx = visibleBlockIndices[fenceIndex++];
388
+ const startLine = codeBlocks[blockIdx].javaStartLine;
389
+
390
+ const codeHtml = renderCodeSegment(dedented, startLine, 'java');
391
+ const output =
392
+ blockOutputMap && blockOutputMap.has(blockIdx)
393
+ ? blockOutputMap.get(blockIdx)
394
+ : null;
395
+
396
+ if (output) {
397
+ const escapedOutput = output
398
+ .replace(/&/g, '&amp;')
399
+ .replace(/</g, '&lt;')
400
+ .replace(/>/g, '&gt;');
401
+ return `<div class="literate-code-output">${codeHtml}<pre>${escapedOutput}</pre></div>`;
402
+ }
403
+
404
+ return codeHtml;
405
+ };
406
+
407
+ const env = {};
408
+ const contentHtml = md.render(content, env);
409
+
410
+ // Build page variables
411
+ const javaFileName = `${className}.java`;
412
+ const codeFilePath = applyBasePath(
413
+ normalizeOutputPath(
414
+ `/${toContentAssetPath(contentDir, path.join(dir, javaFileName))}`,
415
+ ),
416
+ );
417
+
418
+ const titleHtml = md.renderInline(pageVariables.title);
419
+ pageVariables.titleHtml = titleHtml;
420
+ pageVariables.title = stripHtml(titleHtml).result;
421
+ pageVariables.template = 'literate';
422
+ pageVariables.codeFilePath = codeFilePath;
423
+ pageVariables.downloadName = javaFileName;
424
+
425
+ if (pageVariables.toc && env.tocItems) {
426
+ pageVariables.tocHtml = generateTocHtml(env.tocItems);
427
+ }
428
+
429
+ resolveAuthor(pageVariables, filePath);
430
+
431
+ const templateParameters = createTemplateParameters({
432
+ pageVariables,
433
+ siteVariables,
434
+ content: contentHtml,
435
+ applyBasePath,
436
+ subPath,
437
+ });
438
+
439
+ const html = injectWebpackAssets(
440
+ render('literate.html', templateParameters),
441
+ compilation,
442
+ applyBasePath,
443
+ );
444
+
445
+ return [
446
+ {
447
+ assetPath: toContentAssetPath(
448
+ contentDir,
449
+ path.join(dir, `${className}.html`),
450
+ ),
451
+ content: html,
452
+ },
453
+ {
454
+ assetPath: toContentAssetPath(contentDir, path.join(dir, javaFileName)),
455
+ content: javaSource,
456
+ },
457
+ ];
458
+ }
459
+
460
+ module.exports = {
461
+ injectWebpackAssets,
462
+ renderCodePageAsset,
463
+ renderCopiedContentAsset,
464
+ renderLiterateJavaPageAsset,
465
+ renderPlainTextPageAsset,
466
+ };
@@ -0,0 +1,26 @@
1
+ const { makeLogger } = require('../log');
2
+
3
+ const log = makeLogger(__filename);
4
+
5
+ let highlighter = null;
6
+
7
+ async function initHighlighter(langs) {
8
+ if (highlighter) {
9
+ return;
10
+ }
11
+ log.info`Initializing syntax highlighter`;
12
+ const { createHighlighter } = await import('shiki');
13
+ highlighter = await createHighlighter({
14
+ themes: ['github-light', 'github-dark'],
15
+ langs,
16
+ });
17
+ }
18
+
19
+ function getHighlighter() {
20
+ if (!highlighter) {
21
+ throw new Error('Shiki highlighter not initialized');
22
+ }
23
+ return highlighter;
24
+ }
25
+
26
+ module.exports = { initHighlighter, getHighlighter };
@@ -0,0 +1,155 @@
1
+ const path = require('path');
2
+ const { makeLogger } = require('./log');
3
+
4
+ const log = makeLogger(__filename);
5
+
6
+ function stripQueryAndHash(href) {
7
+ return href.split('#')[0].split('?')[0];
8
+ }
9
+
10
+ function isExternalOrAnchor(href) {
11
+ if (!href || href.startsWith('#') || href.startsWith('//')) {
12
+ return true;
13
+ }
14
+
15
+ return /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(href);
16
+ }
17
+
18
+ function normalizePathname(pathname) {
19
+ const normalized = path.posix.normalize(pathname);
20
+ if (normalized === '.') {
21
+ return '/';
22
+ }
23
+ return normalized.startsWith('/') ? normalized : `/${normalized}`;
24
+ }
25
+
26
+ function createCodeExtPattern(codeExtensions) {
27
+ const escaped = codeExtensions.map(ext =>
28
+ ext.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
29
+ );
30
+ if (escaped.length === 0) {
31
+ return null;
32
+ }
33
+ return new RegExp(`\\.(${escaped.join('|')})$`, 'i');
34
+ }
35
+
36
+ function rewriteCodeLink(pathname, codeExtPattern) {
37
+ if (!codeExtPattern) {
38
+ return pathname;
39
+ }
40
+
41
+ return pathname.replace(codeExtPattern, '.html');
42
+ }
43
+
44
+ function resolveLinkPath(sourceUrlPath, rawHref, codeExtPattern) {
45
+ const hrefPath = stripQueryAndHash(rawHref.trim());
46
+ if (!hrefPath) {
47
+ return null;
48
+ }
49
+
50
+ const sourceDir = path.posix.dirname(sourceUrlPath);
51
+ const resolved = hrefPath.startsWith('/')
52
+ ? normalizePathname(hrefPath)
53
+ : normalizePathname(path.posix.join(sourceDir, hrefPath));
54
+
55
+ return rewriteCodeLink(resolved, codeExtPattern);
56
+ }
57
+
58
+ function getDirectoryIndexPath(pathname) {
59
+ return normalizePathname(path.posix.join(pathname, 'index.html'));
60
+ }
61
+
62
+ module.exports = function validateInternalLinks(md, options = {}) {
63
+ const {
64
+ enabled = true,
65
+ filePath,
66
+ sourceUrlPath,
67
+ validTargets,
68
+ codeExtensions = [],
69
+ } = options;
70
+
71
+ if (!enabled) {
72
+ return;
73
+ }
74
+
75
+ if (!filePath || !sourceUrlPath || !(validTargets instanceof Set)) {
76
+ throw new Error(
77
+ 'validate-internal-links-plugin requires filePath, sourceUrlPath, and validTargets',
78
+ );
79
+ }
80
+
81
+ const codeExtPattern = createCodeExtPattern(codeExtensions);
82
+ const seenErrors = new Set();
83
+
84
+ function reportBrokenLink(rawHref, resolvedPath) {
85
+ const key = `${rawHref}|${resolvedPath}`;
86
+ if (seenErrors.has(key)) {
87
+ return;
88
+ }
89
+ seenErrors.add(key);
90
+
91
+ log.error`${filePath}: broken internal link: "${rawHref}" (resolved to "${resolvedPath}")`;
92
+ }
93
+
94
+ function reportDirectoryLink(rawHref, resolvedPath, indexPath) {
95
+ const key = `${rawHref}|${resolvedPath}|directory`;
96
+ if (seenErrors.has(key)) {
97
+ return;
98
+ }
99
+ seenErrors.add(key);
100
+
101
+ log.error`${filePath}: directory link must reference index.html explicitly: "${rawHref}" (resolved to "${resolvedPath}", expected "${indexPath}")`;
102
+ }
103
+
104
+ function validateHref(rawHref) {
105
+ if (!rawHref) {
106
+ return;
107
+ }
108
+
109
+ const href = rawHref.trim();
110
+ if (!href || isExternalOrAnchor(href)) {
111
+ return;
112
+ }
113
+
114
+ const resolvedPath = resolveLinkPath(sourceUrlPath, href, codeExtPattern);
115
+ if (!resolvedPath) {
116
+ return;
117
+ }
118
+
119
+ const directoryIndexPath = getDirectoryIndexPath(resolvedPath);
120
+ if (
121
+ directoryIndexPath !== resolvedPath &&
122
+ validTargets.has(directoryIndexPath)
123
+ ) {
124
+ reportDirectoryLink(rawHref, resolvedPath, directoryIndexPath);
125
+ return;
126
+ }
127
+
128
+ if (!validTargets.has(resolvedPath)) {
129
+ reportBrokenLink(rawHref, resolvedPath);
130
+ }
131
+ }
132
+
133
+ function validateToken(token) {
134
+ if (token.type === 'link_open') {
135
+ validateHref(token.attrGet('href'));
136
+ } else if (token.type === 'html_block' || token.type === 'html_inline') {
137
+ token.content.replace(/<a\b[^>]*\bhref\s*=\s*"([^"]+)"/gi, (_, href) => {
138
+ validateHref(href);
139
+ return _;
140
+ });
141
+ }
142
+
143
+ token.children?.forEach(validateToken);
144
+ }
145
+
146
+ md.core.ruler.push('validate_internal_links', state => {
147
+ state.tokens.forEach(validateToken);
148
+
149
+ if (seenErrors.size > 0) {
150
+ throw new Error(
151
+ `${filePath}: found ${seenErrors.size} broken internal link(s)`,
152
+ );
153
+ }
154
+ });
155
+ };