@abreen/tada 1.11.3 → 1.13.2

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 (79) hide show
  1. package/README.md +65 -64
  2. package/bin/tada.ts +61 -66
  3. package/build/build-manifest.ts +14 -3
  4. package/build/bundle.ts +0 -1
  5. package/build/config-files.ts +183 -0
  6. package/build/config-loader.ts +159 -0
  7. package/build/copy.ts +0 -83
  8. package/build/generate-content-assets.ts +75 -160
  9. package/build/globals.ts +48 -89
  10. package/build/log.ts +27 -18
  11. package/build/pagefind.ts +33 -26
  12. package/build/pipeline.ts +98 -79
  13. package/build/reachability.ts +51 -248
  14. package/build/serve.ts +35 -6
  15. package/build/site-variables.ts +20 -11
  16. package/build/source-model.ts +400 -0
  17. package/build/{watch/records.ts → source-records.ts} +111 -49
  18. package/build/template-globals.ts +91 -0
  19. package/build/templates.ts +50 -40
  20. package/build/test-helpers.ts +15 -0
  21. package/build/toc-plugin.ts +5 -1
  22. package/build/types.d.ts +9 -1
  23. package/build/util.ts +8 -0
  24. package/build/utils/code.ts +6 -16
  25. package/build/utils/content-files.ts +57 -126
  26. package/build/utils/final-html.ts +239 -0
  27. package/build/utils/markdown.ts +237 -17
  28. package/build/utils/paths.ts +2 -1
  29. package/build/utils/render.ts +108 -99
  30. package/build/validate-config-links.ts +91 -14
  31. package/build/watch/assets.ts +0 -2
  32. package/build/watch/build-full.ts +14 -15
  33. package/build/watch/build-incremental.ts +15 -8
  34. package/build/watch/compiler-types.d.ts +6 -1
  35. package/build/watch/compiler.ts +27 -15
  36. package/build/watch/index.ts +2 -7
  37. package/build/watch/planner.ts +32 -16
  38. package/build/watch/reload.ts +4 -0
  39. package/build/watch/runtime.ts +29 -45
  40. package/build/watch/snapshot.ts +50 -402
  41. package/build/watch/validation.ts +6 -3
  42. package/build/watch-reachability-state.ts +16 -23
  43. package/build/watch-reload-client.ts +11 -4
  44. package/init/content/index.md +1 -1
  45. package/init/content/labs/02/index.md +61 -0
  46. package/init/content/labs/02/selection_sort.py +23 -0
  47. package/init/content/labs/index.md +4 -2
  48. package/init/content/lectures/01/index.md +1 -1
  49. package/init/content/markdown.md +1 -1
  50. package/package.json +3 -4
  51. package/src/code/index.ts +46 -18
  52. package/src/code.scss +2 -1
  53. package/src/globals.ts +47 -0
  54. package/src/index.ts +1 -0
  55. package/src/navigate/index.ts +2 -5
  56. package/src/navigate/lifecycle.ts +3 -1
  57. package/src/navigate/runtime.ts +6 -5
  58. package/src/page-update/index.ts +20 -16
  59. package/src/search/index.ts +53 -25
  60. package/src/slides/index.ts +467 -0
  61. package/src/slides/style.scss +104 -0
  62. package/src/style.scss +8 -3
  63. package/src/timezone/index.ts +16 -20
  64. package/src/top/index.ts +2 -4
  65. package/src/trace/index.ts +91 -7
  66. package/src/trace/style.scss +1 -1
  67. package/src/util.ts +6 -2
  68. package/templates/_author.html +2 -2
  69. package/templates/_heading.html +1 -1
  70. package/templates/_nav.html +2 -2
  71. package/templates/_theme.scss +3 -3
  72. package/templates/_top.html +14 -14
  73. package/templates/default.html +8 -0
  74. package/types/dev.d.ts +1 -1
  75. package/types/site-variables.d.ts +4 -5
  76. package/watch/fs-commit.ts +63 -10
  77. package/build/apply-base-path-plugin.ts +0 -186
  78. package/build/content-watch.ts +0 -104
  79. package/build/validate-internal-links-plugin.ts +0 -207
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ import { globals, type Globals } from '../build/globals';
3
4
  import type {
4
5
  ApplyMutationsCommitPlan,
5
6
  CommitPlan,
@@ -7,6 +8,17 @@ import type {
7
8
  ReplaceRootCommitPlan,
8
9
  } from './types';
9
10
 
11
+ type CommitPlanGlobals = Pick<Globals, 'now' | 'pid' | 'sleepSync'>;
12
+
13
+ const TRANSIENT_RENAME_ERROR_CODES = new Set([
14
+ 'EACCES',
15
+ 'EBUSY',
16
+ 'ENOTEMPTY',
17
+ 'EPERM',
18
+ ]);
19
+ const RENAME_RETRY_DELAY_MS = 25;
20
+ const RENAME_RETRY_COUNT = 8;
21
+
10
22
  function removeDirIfExists(dir: string): void {
11
23
  fs.rmSync(dir, { recursive: true, force: true });
12
24
  }
@@ -15,6 +27,39 @@ function ensureParentDir(filePath: string): void {
15
27
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
16
28
  }
17
29
 
30
+ function isTransientRenameError(
31
+ error: unknown,
32
+ sourcePath: string,
33
+ ): error is NodeJS.ErrnoException {
34
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
35
+ return (
36
+ typeof code === 'string' &&
37
+ TRANSIENT_RENAME_ERROR_CODES.has(code) &&
38
+ fs.existsSync(sourcePath)
39
+ );
40
+ }
41
+
42
+ function renameWithRetry(
43
+ sourcePath: string,
44
+ targetPath: string,
45
+ globals: CommitPlanGlobals,
46
+ ): void {
47
+ for (let attempt = 0; attempt <= RENAME_RETRY_COUNT; attempt++) {
48
+ try {
49
+ fs.renameSync(sourcePath, targetPath);
50
+ return;
51
+ } catch (error) {
52
+ if (
53
+ attempt === RENAME_RETRY_COUNT ||
54
+ !isTransientRenameError(error, sourcePath)
55
+ ) {
56
+ throw error;
57
+ }
58
+ globals.sleepSync(RENAME_RETRY_DELAY_MS);
59
+ }
60
+ }
61
+ }
62
+
18
63
  function pruneEmptyDirs(rootDir: string, filePath: string): void {
19
64
  let currentDir = path.dirname(filePath);
20
65
  const root = path.resolve(rootDir);
@@ -36,6 +81,7 @@ function applyFileMutation(
36
81
  rootDir: string,
37
82
  mutation: FileMutation,
38
83
  stagedRoot: string,
84
+ globals: CommitPlanGlobals,
39
85
  ): void {
40
86
  const targetPath = path.join(rootDir, mutation.path);
41
87
  const stagedPath = path.join(stagedRoot, mutation.path);
@@ -44,7 +90,7 @@ function applyFileMutation(
44
90
  ensureParentDir(stagedPath);
45
91
  fs.writeFileSync(stagedPath, mutation.content);
46
92
  ensureParentDir(targetPath);
47
- fs.renameSync(stagedPath, targetPath);
93
+ renameWithRetry(stagedPath, targetPath, globals);
48
94
  return;
49
95
  }
50
96
 
@@ -52,7 +98,10 @@ function applyFileMutation(
52
98
  pruneEmptyDirs(rootDir, targetPath);
53
99
  }
54
100
 
55
- function applyMutations(plan: ApplyMutationsCommitPlan): void {
101
+ function applyMutations(
102
+ plan: ApplyMutationsCommitPlan,
103
+ globals: CommitPlanGlobals,
104
+ ): void {
56
105
  const stagedRoot = fs.mkdtempSync(path.join(plan.rootDir, '.watch-stage-'));
57
106
  try {
58
107
  for (const mutation of plan.mutations) {
@@ -64,39 +113,43 @@ function applyMutations(plan: ApplyMutationsCommitPlan): void {
64
113
  }
65
114
 
66
115
  for (const mutation of plan.mutations) {
67
- applyFileMutation(plan.rootDir, mutation, stagedRoot);
116
+ applyFileMutation(plan.rootDir, mutation, stagedRoot, globals);
68
117
  }
69
118
  } finally {
70
119
  removeDirIfExists(stagedRoot);
71
120
  }
72
121
  }
73
122
 
74
- function replaceRoot(plan: ReplaceRootCommitPlan): void {
123
+ function replaceRoot(
124
+ plan: ReplaceRootCommitPlan,
125
+ globals: CommitPlanGlobals,
126
+ ): void {
75
127
  const { targetPath, stagedPath } = plan;
76
- const backupDir = `${targetPath}.bak-${process.pid}-${Date.now()}`;
128
+ const backupDir = `${targetPath}.bak-${globals.pid()}-${globals.now()}`;
77
129
  const targetExists = fs.existsSync(targetPath);
78
130
 
79
131
  try {
80
132
  if (targetExists) {
81
- fs.renameSync(targetPath, backupDir);
133
+ renameWithRetry(targetPath, backupDir, globals);
82
134
  }
83
- fs.renameSync(stagedPath, targetPath);
135
+ renameWithRetry(stagedPath, targetPath, globals);
84
136
  if (targetExists) {
85
137
  removeDirIfExists(backupDir);
86
138
  }
87
139
  } catch (err) {
88
140
  if (!fs.existsSync(targetPath) && fs.existsSync(backupDir)) {
89
- fs.renameSync(backupDir, targetPath);
141
+ renameWithRetry(backupDir, targetPath, globals);
90
142
  }
91
143
  throw err;
92
144
  }
93
145
  }
94
146
 
95
147
  export function applyCommitPlan(plan: CommitPlan): void {
148
+ const runtimeGlobals: CommitPlanGlobals = globals;
96
149
  if (plan.kind === 'replace-root') {
97
- replaceRoot(plan);
150
+ replaceRoot(plan, runtimeGlobals);
98
151
  return;
99
152
  }
100
153
 
101
- applyMutations(plan);
154
+ applyMutations(plan, runtimeGlobals);
102
155
  }
@@ -1,186 +0,0 @@
1
- import path from 'path';
2
- import type MarkdownIt from 'markdown-it';
3
- import type Token from 'markdown-it/lib/token.mjs';
4
- import { JSDOM } from 'jsdom';
5
- import { createApplyBasePath } from './utils/paths';
6
- import { getExtensionToShikiLanguage } from './site-variables';
7
- import { isInternalLink } from './utils/link';
8
- import { makeLogger } from './log';
9
- import type { SiteVariables } from './types';
10
-
11
- const log = makeLogger(import.meta.url);
12
-
13
- function serializeStartTag(element: Element): string {
14
- const attrs = Array.from(element.attributes).map(attr =>
15
- attr.value === '' ? attr.name : `${attr.name}="${attr.value}"`,
16
- );
17
- return `<${element.tagName.toLowerCase()}${attrs.length > 0 ? ` ${attrs.join(' ')}` : ''}>`;
18
- }
19
-
20
- function isStandaloneOpeningAnchorTag(html: string): boolean {
21
- if (!/^<a\b/i.test(html)) {
22
- return false;
23
- }
24
-
25
- let i = 2;
26
- let quote: '"' | "'" | null = null;
27
-
28
- while (i < html.length) {
29
- const char = html[i];
30
- if (quote) {
31
- if (char === quote) {
32
- quote = null;
33
- }
34
- i += 1;
35
- continue;
36
- }
37
-
38
- if (char === '"' || char === "'") {
39
- quote = char;
40
- i += 1;
41
- continue;
42
- }
43
-
44
- if (char === '>') {
45
- return html.slice(i + 1).trim() === '';
46
- }
47
-
48
- i += 1;
49
- }
50
-
51
- return false;
52
- }
53
-
54
- interface ApplyBasePathOptions {
55
- literateJavaOutputPaths?: Set<string>;
56
- sourceUrlPath?: string;
57
- validTargets?: Set<string>;
58
- }
59
-
60
- export default function applyBasePathPlugin(
61
- md: MarkdownIt,
62
- siteVariables: SiteVariables,
63
- pluginOptions: ApplyBasePathOptions = {},
64
- ): void {
65
- const applyBasePath = createApplyBasePath(siteVariables);
66
- const literateJavaOutputPaths = pluginOptions.literateJavaOutputPaths;
67
- const sourceUrlPath = pluginOptions.sourceUrlPath;
68
- const validTargets = pluginOptions.validTargets;
69
-
70
- function rewriteInternalHref(href: string): string {
71
- const resolveAgainstSource = (p: string): string =>
72
- p.startsWith('/')
73
- ? p
74
- : path.posix.join(path.posix.dirname(sourceUrlPath!), p);
75
-
76
- const match = href.match(/^([^?#]*)(.*)$/);
77
- const pathname = match ? match[1] : href;
78
- const suffix = match ? match[2] : '';
79
- let modifiedPath = pathname;
80
-
81
- for (const ext of Object.keys(getExtensionToShikiLanguage(siteVariables))) {
82
- if (modifiedPath.endsWith(`.${ext}`)) {
83
- if (literateJavaOutputPaths && sourceUrlPath) {
84
- const resolved = resolveAgainstSource(modifiedPath);
85
- if (literateJavaOutputPaths.has(resolved)) {
86
- break;
87
- }
88
- }
89
- // Only rewrite if the .html version exists. Public files with
90
- // code extensions are copied as-is and have no .html page.
91
- if (validTargets && sourceUrlPath) {
92
- const resolved = resolveAgainstSource(modifiedPath);
93
- if (!validTargets.has(`${resolved}.html`)) {
94
- break;
95
- }
96
- }
97
- modifiedPath += '.html';
98
- break;
99
- }
100
- }
101
-
102
- return modifiedPath + suffix;
103
- }
104
-
105
- function checkAndApplyBasePath(token: Token): void {
106
- if (token.type === 'link_open') {
107
- const href = token.attrGet('href');
108
- if (!href) {
109
- return;
110
- }
111
-
112
- if (href.startsWith('/')) {
113
- const modifiedHref = rewriteInternalHref(href);
114
- const afterApply = applyBasePath(modifiedHref);
115
- log.debug`Applying base path: ${href} -> ${afterApply}`;
116
- token.attrSet('href', afterApply);
117
- } else if (isInternalLink(href)) {
118
- // Relative link: only rewrite code extensions, don't apply base path
119
- const modifiedHref = rewriteInternalHref(href);
120
- if (modifiedHref !== href) {
121
- log.debug`Rewriting internal link: ${href} -> ${modifiedHref}`;
122
- token.attrSet('href', modifiedHref);
123
- }
124
- }
125
- } else if (token.type === 'image') {
126
- const src = token.attrGet('src');
127
- if (src && src.startsWith('/')) {
128
- const afterApply = applyBasePath(src);
129
- log.debug`Applying base path to image: ${src} -> ${afterApply}`;
130
- token.attrSet('src', afterApply);
131
- }
132
- } else if (token.type === 'html_block' || token.type === 'html_inline') {
133
- if (isStandaloneOpeningAnchorTag(token.content)) {
134
- const dom = new JSDOM(`<body>${token.content}__tada__</a></body>`);
135
- const anchor = dom.window.document.body.querySelector('a[href]');
136
- const href = anchor?.getAttribute('href');
137
- if (anchor && href && isInternalLink(href)) {
138
- const rewrittenHref = rewriteInternalHref(href);
139
- const finalHref = href.startsWith('/')
140
- ? applyBasePath(rewrittenHref)
141
- : rewrittenHref;
142
- log.debug`Rewriting raw HTML anchor: ${href} -> ${finalHref}`;
143
- anchor.setAttribute('href', finalHref);
144
- token.content = serializeStartTag(anchor);
145
- }
146
- } else {
147
- const dom = new JSDOM(`<body>${token.content}</body>`);
148
- const { document } = dom.window;
149
- let changed = false;
150
-
151
- for (const anchor of document.body.querySelectorAll('a[href]')) {
152
- const href = anchor.getAttribute('href');
153
- if (href && isInternalLink(href)) {
154
- const rewrittenHref = rewriteInternalHref(href);
155
- const finalHref = href.startsWith('/')
156
- ? applyBasePath(rewrittenHref)
157
- : rewrittenHref;
158
- log.debug`Rewriting raw HTML anchor: ${href} -> ${finalHref}`;
159
- anchor.setAttribute('href', finalHref);
160
- changed = true;
161
- }
162
- }
163
-
164
- for (const image of document.body.querySelectorAll('img[src]')) {
165
- const src = image.getAttribute('src');
166
- if (src && src.startsWith('/')) {
167
- const afterApply = applyBasePath(src);
168
- log.debug`Applying base path to raw HTML image: ${src} -> ${afterApply}`;
169
- image.setAttribute('src', afterApply);
170
- changed = true;
171
- }
172
- }
173
-
174
- if (changed) {
175
- token.content = document.body.innerHTML;
176
- }
177
- }
178
- }
179
-
180
- token.children?.forEach(checkAndApplyBasePath);
181
- }
182
-
183
- md.core.ruler.push('apply_base_path', state => {
184
- state.tokens.forEach(checkAndApplyBasePath);
185
- });
186
- }
@@ -1,104 +0,0 @@
1
- import path from 'path';
2
- import type { SiteVariables } from './types';
3
- import { getExtensionToShikiLanguage } from './site-variables';
4
- import { getContentDir, getBuildContentFiles, isPartial } from './util';
5
- import { compileTemplates, getJsonDataDir, JSON_DATA_FILES } from './templates';
6
- import { B } from './colors';
7
- import { makeLogger } from './log';
8
-
9
- const log = makeLogger(import.meta.url);
10
-
11
- interface ChangeDetectionResult {
12
- templateError: Error | null;
13
- needsRestart: boolean;
14
- changedContentFiles?: Set<string>;
15
- jsonDataChanged?: boolean;
16
- partialsChanged?: boolean;
17
- }
18
-
19
- export class ContentChangeDetector {
20
- private siteVariables: SiteVariables;
21
- private lastSig: string | null;
22
-
23
- constructor(siteVariables: SiteVariables) {
24
- this.siteVariables = siteVariables;
25
- this.lastSig = null;
26
- }
27
-
28
- detectChanges(modifiedFiles: Iterable<string>): ChangeDetectionResult {
29
- const resolvedFiles = new Set(
30
- [...modifiedFiles].map(filePath => path.resolve(filePath)),
31
- );
32
-
33
- const jsonDataDir = getJsonDataDir();
34
-
35
- // Try to recompile templates (also re-reads JSON data files)
36
- let templateError: Error | null = null;
37
- try {
38
- compileTemplates(this.siteVariables);
39
- } catch (err) {
40
- templateError = err as Error;
41
- }
42
-
43
- if (templateError) {
44
- return { templateError, needsRestart: false };
45
- }
46
-
47
- const contentDir = getContentDir();
48
- const normalizedContentDir = path.resolve(contentDir) + path.sep;
49
- const buildContentFiles = getBuildContentFiles(
50
- contentDir,
51
- Object.keys(getExtensionToShikiLanguage(this.siteVariables)),
52
- );
53
-
54
- // Detect structural changes (files added or deleted)
55
- const sig = buildContentFiles.slice().sort().join('\0');
56
- let needsRestart = false;
57
- if (this.lastSig !== null && sig !== this.lastSig) {
58
- needsRestart = true;
59
- }
60
- this.lastSig = sig;
61
-
62
- const changedContentFiles = new Set(
63
- [...resolvedFiles].filter(filePath =>
64
- filePath.startsWith(normalizedContentDir),
65
- ),
66
- );
67
-
68
- // Check if any JSON data file changed (nav.json, authors.json)
69
- const jsonDataPaths = JSON_DATA_FILES.map(f =>
70
- path.resolve(jsonDataDir, f),
71
- );
72
- const changedJsonDataPaths = [...resolvedFiles].filter(filePath =>
73
- jsonDataPaths.includes(filePath),
74
- );
75
- const jsonDataChanged = changedJsonDataPaths.length > 0;
76
- const changedPartials = [...changedContentFiles].filter(f => isPartial(f));
77
- const partialsChanged = changedPartials.length > 0;
78
-
79
- for (const filePath of changedJsonDataPaths) {
80
- log.event`${B`${path.basename(filePath)}`} changed, rebuilding`;
81
- }
82
-
83
- if (partialsChanged) {
84
- for (const filePath of changedPartials) {
85
- log.event`Partial ${B`${path.basename(filePath)}`} changed, rebuilding`;
86
- }
87
- }
88
-
89
- const changedTraceFiles = [...changedContentFiles].filter(
90
- f => f.endsWith('.java') || f.endsWith('.py'),
91
- );
92
- for (const filePath of changedTraceFiles) {
93
- log.event`${B`${path.basename(filePath)}`} changed, re-running traces`;
94
- }
95
-
96
- return {
97
- templateError: null,
98
- needsRestart,
99
- changedContentFiles,
100
- jsonDataChanged,
101
- partialsChanged,
102
- };
103
- }
104
- }
@@ -1,207 +0,0 @@
1
- import type MarkdownIt from 'markdown-it';
2
- import type Token from 'markdown-it/lib/token.mjs';
3
- import { JSDOM } from 'jsdom';
4
- import path from 'path';
5
- import { isInternalLink } from './utils/link';
6
- import { makeLogger } from './log';
7
-
8
- const log = makeLogger(import.meta.url);
9
-
10
- interface ValidateInternalLinksOptions {
11
- enabled?: boolean;
12
- filePath?: string;
13
- sourceUrlPath?: string;
14
- validTargets?: Set<string>;
15
- codeExtensions?: string[];
16
- onValidLink?: (targetPath: string) => void;
17
- }
18
-
19
- function stripQueryAndHash(href: string): string {
20
- return href.split('#')[0].split('?')[0];
21
- }
22
-
23
- function normalizePathname(pathname: string): string {
24
- const normalized = path.posix.normalize(pathname);
25
- if (normalized === '.') {
26
- return '/';
27
- }
28
- return normalized.startsWith('/') ? normalized : `/${normalized}`;
29
- }
30
-
31
- function createCodeExtPattern(codeExtensions: string[]): RegExp | null {
32
- const escaped = codeExtensions.map(ext =>
33
- ext.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
34
- );
35
- if (escaped.length === 0) {
36
- return null;
37
- }
38
- return new RegExp(`\\.(${escaped.join('|')})$`, 'i');
39
- }
40
-
41
- function rewriteCodeLink(
42
- pathname: string,
43
- codeExtPattern: RegExp | null,
44
- ): string {
45
- if (!codeExtPattern || !codeExtPattern.test(pathname)) {
46
- return pathname;
47
- }
48
-
49
- return `${pathname}.html`;
50
- }
51
-
52
- function resolveLinkPath(
53
- sourceUrlPath: string,
54
- rawHref: string,
55
- codeExtPattern: RegExp | null,
56
- ): string | null {
57
- const hrefPath = stripQueryAndHash(rawHref.trim());
58
- if (!hrefPath) {
59
- return null;
60
- }
61
-
62
- const sourceDir = path.posix.dirname(sourceUrlPath);
63
- const resolved = hrefPath.startsWith('/')
64
- ? normalizePathname(hrefPath)
65
- : normalizePathname(path.posix.join(sourceDir, hrefPath));
66
-
67
- // Decode percent-encoded characters so the resolved path can be matched
68
- // against `validTargets`, which contains raw filesystem-derived paths.
69
- // markdown-it's normalizeLink percent-encodes hrefs, so a link like
70
- // `[x](/my notes.md)` arrives here as `/my%20notes.md`.
71
- let decoded: string;
72
- try {
73
- decoded = normalizePathname(decodeURIComponent(resolved));
74
- } catch {
75
- decoded = resolved;
76
- }
77
-
78
- return rewriteCodeLink(decoded, codeExtPattern);
79
- }
80
-
81
- function getDirectoryIndexPath(pathname: string): string {
82
- return normalizePathname(path.posix.join(pathname, 'index.html'));
83
- }
84
-
85
- export default function validateInternalLinks(
86
- md: MarkdownIt,
87
- options: ValidateInternalLinksOptions = {},
88
- ): void {
89
- const {
90
- enabled = true,
91
- filePath,
92
- sourceUrlPath,
93
- validTargets,
94
- codeExtensions = [],
95
- onValidLink,
96
- } = options;
97
-
98
- if (!enabled) {
99
- return;
100
- }
101
-
102
- if (!filePath || !sourceUrlPath || !(validTargets instanceof Set)) {
103
- throw new Error(
104
- 'validate-internal-links-plugin requires filePath, sourceUrlPath, and validTargets',
105
- );
106
- }
107
-
108
- const codeExtPattern = createCodeExtPattern(codeExtensions);
109
- const seenErrors = new Set<string>();
110
-
111
- function reportBrokenLink(rawHref: string, resolvedPath: string): void {
112
- const key = `${rawHref}|${resolvedPath}`;
113
- if (seenErrors.has(key)) {
114
- return;
115
- }
116
- seenErrors.add(key);
117
-
118
- log.error`${filePath}: broken internal link: "${rawHref}" (resolved to "${resolvedPath}")`;
119
- }
120
-
121
- function reportDirectoryLink(
122
- rawHref: string,
123
- resolvedPath: string,
124
- indexPath: string,
125
- ): void {
126
- const key = `${rawHref}|${resolvedPath}|directory`;
127
- if (seenErrors.has(key)) {
128
- return;
129
- }
130
- seenErrors.add(key);
131
-
132
- log.error`${filePath}: directory link must reference index.html explicitly: "${rawHref}" (resolved to "${resolvedPath}", expected "${indexPath}")`;
133
- }
134
-
135
- function validateHref(rawHref: string): void {
136
- if (!rawHref) {
137
- return;
138
- }
139
-
140
- const href = rawHref.trim();
141
- if (!href || !isInternalLink(href)) {
142
- return;
143
- }
144
-
145
- const resolvedPath = resolveLinkPath(sourceUrlPath!, href, codeExtPattern);
146
- if (!resolvedPath) {
147
- return;
148
- }
149
-
150
- const directoryIndexPath = getDirectoryIndexPath(resolvedPath);
151
- if (
152
- directoryIndexPath !== resolvedPath &&
153
- validTargets!.has(directoryIndexPath)
154
- ) {
155
- reportDirectoryLink(rawHref, resolvedPath, directoryIndexPath);
156
- return;
157
- }
158
-
159
- if (!validTargets!.has(resolvedPath)) {
160
- // If code extension rewriting changed the path, check the original
161
- // path too. Public files with code extensions are copied as-is and
162
- // do not produce .html versions.
163
- if (codeExtPattern) {
164
- const unrewritten = resolveLinkPath(sourceUrlPath!, href, null);
165
- if (unrewritten && validTargets!.has(unrewritten)) {
166
- onValidLink?.(unrewritten);
167
- return;
168
- }
169
- }
170
- reportBrokenLink(rawHref, resolvedPath);
171
- return;
172
- }
173
-
174
- onValidLink?.(resolvedPath);
175
- }
176
-
177
- function validateToken(token: Token): void {
178
- if (token.type === 'link_open') {
179
- const href = token.attrGet('href');
180
- if (href) {
181
- validateHref(href);
182
- }
183
- } else if (token.type === 'html_block' || token.type === 'html_inline') {
184
- const dom = new JSDOM(`<body>${token.content}</body>`);
185
- for (const anchor of dom.window.document.body.querySelectorAll(
186
- 'a[href]',
187
- )) {
188
- const href = anchor.getAttribute('href');
189
- if (href) {
190
- validateHref(href);
191
- }
192
- }
193
- }
194
-
195
- token.children?.forEach(validateToken);
196
- }
197
-
198
- md.core.ruler.push('validate_internal_links', state => {
199
- state.tokens.forEach(validateToken);
200
-
201
- if (seenErrors.size > 0) {
202
- throw new Error(
203
- `${filePath}: found ${seenErrors.size} broken internal link(s)`,
204
- );
205
- }
206
- });
207
- }