@abreen/tada 1.11.2 → 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 (80) 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/search/style.scss +1 -1
  61. package/src/slides/index.ts +467 -0
  62. package/src/slides/style.scss +104 -0
  63. package/src/style.scss +8 -3
  64. package/src/timezone/index.ts +16 -20
  65. package/src/top/index.ts +2 -4
  66. package/src/trace/index.ts +91 -7
  67. package/src/trace/style.scss +1 -1
  68. package/src/util.ts +6 -2
  69. package/templates/_author.html +2 -2
  70. package/templates/_heading.html +1 -1
  71. package/templates/_nav.html +2 -2
  72. package/templates/_theme.scss +3 -3
  73. package/templates/_top.html +14 -14
  74. package/templates/default.html +8 -0
  75. package/types/dev.d.ts +1 -1
  76. package/types/site-variables.d.ts +4 -5
  77. package/watch/fs-commit.ts +63 -10
  78. package/build/apply-base-path-plugin.ts +0 -186
  79. package/build/content-watch.ts +0 -104
  80. package/build/validate-internal-links-plugin.ts +0 -207
@@ -5,10 +5,18 @@ import type MarkdownIt from 'markdown-it';
5
5
  import { stripHtml } from 'string-strip-html';
6
6
  import { makeLogger } from '../log';
7
7
  import { B } from '../colors';
8
- import createGlobals from '../globals';
8
+ import createTemplateGlobals from '../template-globals';
9
9
  import { getExtensionToShikiLanguage } from '../site-variables';
10
- import { render, json } from '../templates';
11
- import { validateParentLink } from '../validate-config-links';
10
+ import { config } from '../templates';
11
+ import { render } from '../templates';
12
+ import {
13
+ getProjectConfigBaseName,
14
+ getSupportedConfigFileNamesText,
15
+ } from '../config-files';
16
+ import {
17
+ resolveParentLinkTarget,
18
+ validateParentLink,
19
+ } from '../validate-config-links';
12
20
  import {
13
21
  extractJavaMethodToc,
14
22
  renderCodeSegment,
@@ -18,6 +26,7 @@ import {
18
26
  import { extensionIsMarkdown } from './file-types';
19
27
  import { createTraceHelpers } from './trace';
20
28
  import { createIncludeFunction } from './include';
29
+ import { finalizeHtmlPage } from './final-html';
21
30
  import {
22
31
  createApplyBasePath,
23
32
  normalizeOutputPath,
@@ -75,7 +84,6 @@ interface TemplateParametersInput {
75
84
  pageVariables: Record<string, unknown>;
76
85
  siteVariables: SiteVariables;
77
86
  content: string | null;
78
- applyBasePath: (subPath: string) => string;
79
87
  subPath: string;
80
88
  isWatchMode: boolean;
81
89
  }
@@ -88,12 +96,12 @@ function resolveAuthor(
88
96
  if (!pageVariables.author) {
89
97
  return;
90
98
  }
91
- const authors = json('authors.json') as
99
+ const authors = config('authors') as
92
100
  | Record<string, Record<string, unknown>>
93
101
  | undefined;
94
102
  if (!authors) {
95
103
  throw new Error(
96
- `${filePath}: author "${pageVariables.author}" specified but no authors.json found`,
104
+ `${filePath}: author "${pageVariables.author}" specified but no author config found (tried ${getSupportedConfigFileNamesText(getProjectConfigBaseName('authors'))})`,
97
105
  );
98
106
  }
99
107
  const authorKey = pageVariables.author as string;
@@ -101,7 +109,7 @@ function resolveAuthor(
101
109
  const authorEntry = authors[authorKey];
102
110
  if (!authorEntry) {
103
111
  throw new Error(
104
- `${filePath}: unknown author "${authorKey}" (not found in authors.json)`,
112
+ `${filePath}: unknown author "${authorKey}" (not found in author config)`,
105
113
  );
106
114
  }
107
115
  pageVariables.author = authorEntry;
@@ -127,18 +135,18 @@ function createTemplateParameters({
127
135
  pageVariables,
128
136
  siteVariables,
129
137
  content,
130
- applyBasePath,
131
138
  subPath,
132
139
  isWatchMode,
133
140
  }: TemplateParametersInput): Record<string, unknown> {
141
+ const applyBasePath = createApplyBasePath(siteVariables);
134
142
  return {
135
143
  vars: siteVariables.vars || {},
136
- ...createGlobals(pageVariables, siteVariables, subPath),
144
+ ...createTemplateGlobals(pageVariables, siteVariables, subPath),
137
145
  site: siteVariables,
138
146
  page: pageVariables,
139
147
  content,
140
- applyBasePath,
141
148
  isWatchMode,
149
+ speculationRulesHrefMatches: `${applyBasePath('/')}*`,
142
150
  tadaVersion,
143
151
  };
144
152
  }
@@ -146,14 +154,16 @@ function createTemplateParameters({
146
154
  export function injectAssetTags(
147
155
  html: string,
148
156
  assetFiles: string[],
149
- applyBasePath: (subPath: string) => string,
150
157
  distDir: string,
151
158
  ): string {
152
159
  const jsAssets = assetFiles.filter(f => f.endsWith('.js'));
153
160
  const cssAssets = assetFiles.filter(f => f.endsWith('.css'));
154
161
 
155
162
  const scriptTags = jsAssets
156
- .map(asset => `<script defer src="${applyBasePath('/' + asset)}"></script>`)
163
+ .map(
164
+ asset =>
165
+ `<script defer src="${normalizeOutputPath('/' + asset)}"></script>`,
166
+ )
157
167
  .join('');
158
168
  const criticalAssets = cssAssets.filter(f => f.includes('critical.bundle.'));
159
169
  const asyncAssets = cssAssets.filter(f => !f.includes('critical.bundle.'));
@@ -165,7 +175,8 @@ export function injectAssetTags(
165
175
  .join('');
166
176
  const asyncLinkTags = asyncAssets
167
177
  .map(
168
- asset => `<link href="${applyBasePath('/' + asset)}" rel="stylesheet">`,
178
+ asset =>
179
+ `<link href="${normalizeOutputPath('/' + asset)}" rel="stylesheet">`,
169
180
  )
170
181
  .join('');
171
182
 
@@ -176,7 +187,7 @@ export function injectAssetTags(
176
187
  .filter(f => fs.existsSync(path.join(distDir, f)))
177
188
  .map(
178
189
  f =>
179
- `<link rel="preload" href="${applyBasePath('/' + f)}" as="font" type="font/woff2" crossorigin>`,
190
+ `<link rel="preload" href="${normalizeOutputPath('/' + f)}" as="font" type="font/woff2" crossorigin>`,
180
191
  )
181
192
  .join('');
182
193
 
@@ -188,15 +199,27 @@ export function injectAssetTags(
188
199
  .replace('</head>', `${scriptTags}</head>`);
189
200
  }
190
201
 
191
- export function injectKatexStylesheet(
192
- html: string,
193
- applyBasePath: (subPath: string) => string,
194
- ): string {
195
- const href = applyBasePath('/katex/katex.min.css');
196
- const tag = `<link href="${href}" rel="stylesheet">`;
202
+ export function injectKatexStylesheet(html: string): string {
203
+ const tag = `<link href="/katex/katex.min.css" rel="stylesheet">`;
197
204
  return html.replace('<head>', `<head>${tag}`);
198
205
  }
199
206
 
207
+ export function preparePageTemplateHtml({
208
+ templateHtml,
209
+ assetFiles,
210
+ distDir,
211
+ }: {
212
+ templateHtml: string;
213
+ assetFiles: string[];
214
+ distDir: string;
215
+ }): string {
216
+ let html = injectAssetTags(templateHtml, assetFiles, distDir);
217
+ if (templateHtml.includes('class="katex"')) {
218
+ html = injectKatexStylesheet(html);
219
+ }
220
+ return html;
221
+ }
222
+
200
223
  function toContentAssetPath(contentDir: string, filePath: string): string {
201
224
  return toPosix(path.relative(contentDir, filePath));
202
225
  }
@@ -216,24 +239,22 @@ export function renderPlainTextPageAsset({
216
239
  }: RenderPlainTextOptions): Asset[] {
217
240
  const { dir, name, ext } = path.parse(filePath);
218
241
  const subPath = toPosix(path.relative(contentDir, path.join(dir, name)));
219
- const applyBasePath = createApplyBasePath(siteVariables);
242
+ const sourceUrlPath = normalizeOutputPath(`/${subPath}.html`);
220
243
 
221
244
  log.info`Rendering page ${B`${subPath + ext}`}`;
222
245
  const watchMode = isWatchMode(assetFiles);
223
246
  const { content, pageVariables, tocItems, alertIds } = renderPlainTextContent(
224
247
  filePath,
225
248
  subPath,
249
+ sourceUrlPath,
226
250
  siteVariables,
227
- applyBasePath,
228
251
  validInternalTargets,
229
252
  watchMode,
230
253
  {
231
- validateInternalLinks: extensionIsMarkdown(ext.toLowerCase()),
232
254
  dependencyCollector,
233
255
  traceCache,
234
256
  contentDir,
235
257
  distDir,
236
- literateJavaOutputPaths,
237
258
  cachedTraceSourceDir,
238
259
  traceToolAvailability,
239
260
  },
@@ -256,7 +277,6 @@ export function renderPlainTextPageAsset({
256
277
  pageVariables,
257
278
  siteVariables,
258
279
  content,
259
- applyBasePath,
260
280
  subPath,
261
281
  isWatchMode: watchMode,
262
282
  });
@@ -265,15 +285,21 @@ export function renderPlainTextPageAsset({
265
285
  `${pageVariables.template}.html`,
266
286
  templateParameters,
267
287
  ) as string;
268
- let html = injectAssetTags(templateHtml, assetFiles, applyBasePath, distDir);
269
- if (templateHtml.includes('class="katex"')) {
270
- html = injectKatexStylesheet(html, applyBasePath);
271
- }
288
+ const finalized = finalizeHtmlPage({
289
+ filePath,
290
+ html: preparePageTemplateHtml({ templateHtml, assetFiles, distDir }),
291
+ siteVariables,
292
+ sourceUrlPath,
293
+ validInternalTargets,
294
+ literateJavaOutputPaths,
295
+ dependencyCollector,
296
+ });
272
297
 
273
298
  return [
274
299
  {
275
300
  assetPath: toContentAssetPath(contentDir, path.join(dir, `${name}.html`)),
276
- content: html,
301
+ content: finalized.html,
302
+ htmlAnalysis: finalized.analysis,
277
303
  },
278
304
  ];
279
305
  }
@@ -286,10 +312,10 @@ export function renderCodePageAsset({
286
312
  assetFiles,
287
313
  validInternalTargets,
288
314
  literateJavaOutputPaths,
315
+ dependencyCollector,
289
316
  }: RenderCodePageOptions): Asset[] {
290
317
  const { dir, name, ext } = path.parse(filePath);
291
318
  const subPath = toPosix(path.relative(contentDir, path.join(dir, name)));
292
- const applyBasePath = createApplyBasePath(siteVariables);
293
319
  const lang =
294
320
  getExtensionToShikiLanguage(siteVariables)[ext.slice(1).toLowerCase()];
295
321
  const rawSource = fs.readFileSync(filePath, 'utf-8');
@@ -305,12 +331,9 @@ export function renderCodePageAsset({
305
331
  lang,
306
332
  siteVariables,
307
333
  pageDirPath,
308
- sourceUrlPath,
309
- validInternalTargets,
310
- literateJavaOutputPaths,
311
334
  );
312
- const codeFilePath = applyBasePath(
313
- normalizeOutputPath(`/${toUrlPath(path.relative(contentDir, filePath))}`),
335
+ const codeFilePath = normalizeOutputPath(
336
+ `/${toUrlPath(path.relative(contentDir, filePath))}`,
314
337
  );
315
338
  const titleHtml = `<code>${name + ext}</code>`;
316
339
  const tocItems = lang === 'java' ? extractJavaMethodToc(sourceCode) : [];
@@ -330,17 +353,23 @@ export function renderCodePageAsset({
330
353
  pageVariables,
331
354
  siteVariables,
332
355
  content,
333
- applyBasePath,
334
356
  subPath,
335
357
  isWatchMode: isWatchMode(assetFiles),
336
358
  });
337
359
 
338
- const html = injectAssetTags(
339
- render('code.html', templateParameters) as string,
340
- assetFiles,
341
- applyBasePath,
342
- distDir,
343
- );
360
+ const finalized = finalizeHtmlPage({
361
+ filePath,
362
+ html: injectAssetTags(
363
+ render('code.html', templateParameters) as string,
364
+ assetFiles,
365
+ distDir,
366
+ ),
367
+ siteVariables,
368
+ sourceUrlPath,
369
+ validInternalTargets,
370
+ literateJavaOutputPaths,
371
+ dependencyCollector,
372
+ });
344
373
 
345
374
  return [
346
375
  {
@@ -348,7 +377,8 @@ export function renderCodePageAsset({
348
377
  contentDir,
349
378
  path.join(dir, `${name}${ext}.html`),
350
379
  ),
351
- content: html,
380
+ content: finalized.html,
381
+ htmlAnalysis: finalized.analysis,
352
382
  },
353
383
  ];
354
384
  }
@@ -385,21 +415,18 @@ export function renderCopiedContentAsset({
385
415
  function renderPlainTextContent(
386
416
  filePath: string,
387
417
  subPath: string,
418
+ sourceUrlPath: string,
388
419
  siteVariables: SiteVariables,
389
- applyBasePath: (subPath: string) => string,
390
420
  validInternalTargets: Set<string>,
391
421
  isWatchMode: boolean,
392
422
  {
393
- validateInternalLinks = true,
394
423
  dependencyCollector,
395
424
  traceCache,
396
425
  contentDir,
397
426
  distDir,
398
- literateJavaOutputPaths,
399
427
  cachedTraceSourceDir,
400
428
  traceToolAvailability,
401
429
  }: {
402
- validateInternalLinks?: boolean;
403
430
  dependencyCollector?: RenderDependencyCollector;
404
431
  traceCache?: Map<
405
432
  string,
@@ -412,7 +439,6 @@ function renderPlainTextContent(
412
439
  >;
413
440
  contentDir?: string;
414
441
  distDir?: string;
415
- literateJavaOutputPaths?: Set<string>;
416
442
  cachedTraceSourceDir?: string;
417
443
  traceToolAvailability?: TraceToolAvailability;
418
444
  } = {},
@@ -422,35 +448,26 @@ function renderPlainTextContent(
422
448
  tocItems: unknown[] | null;
423
449
  alertIds: string[];
424
450
  } {
425
- const sourceUrlPath = `/${subPath}.html`;
426
- const md = createMarkdown(siteVariables, {
427
- filePath,
428
- validatorOptions: {
429
- enabled: validateInternalLinks,
430
- filePath,
431
- sourceUrlPath,
432
- validTargets: validInternalTargets,
433
- codeExtensions: Object.keys(getExtensionToShikiLanguage(siteVariables)),
434
- onValidLink: (targetPath: string) => {
435
- dependencyCollector?.internalTargets?.add(targetPath);
436
- },
437
- },
438
- literateJavaOutputPaths,
439
- sourceUrlPath,
440
- validTargets: validInternalTargets,
441
- });
451
+ const applyBasePath = createApplyBasePath(siteVariables);
442
452
 
443
453
  const ext = path.extname(filePath);
444
454
  const raw = fs.readFileSync(filePath, 'utf-8');
445
-
446
455
  const { pageVariables, content } = parseFrontMatterAndContent(raw, ext);
456
+ if (pageVariables.slides === true && !extensionIsMarkdown(ext)) {
457
+ throw new Error(
458
+ `${filePath}: slides mode is only supported on Markdown pages`,
459
+ );
460
+ }
461
+ const md = createMarkdown(siteVariables, {
462
+ filePath,
463
+ slides: pageVariables.slides === true,
464
+ });
447
465
 
448
466
  // Handle substitutions inside front matter using siteVariables
449
467
  const siteOnlyParams = createTemplateParameters({
450
468
  pageVariables: {},
451
469
  siteVariables,
452
470
  content: null,
453
- applyBasePath,
454
471
  subPath,
455
472
  isWatchMode,
456
473
  });
@@ -471,14 +488,17 @@ function renderPlainTextContent(
471
488
  pageVariablesProcessed.parent,
472
489
  filePath,
473
490
  validInternalTargets,
491
+ sourceUrlPath,
474
492
  );
475
493
  if (parentError) {
476
494
  throw new Error(parentError);
477
495
  }
478
- if (typeof pageVariablesProcessed.parent === 'string') {
479
- dependencyCollector?.internalTargets?.add(
480
- normalizeOutputPath(pageVariablesProcessed.parent),
481
- );
496
+ const resolvedParentTarget = resolveParentLinkTarget(
497
+ pageVariablesProcessed.parent,
498
+ sourceUrlPath,
499
+ );
500
+ if (resolvedParentTarget) {
501
+ dependencyCollector?.internalTargets?.add(resolvedParentTarget);
482
502
  }
483
503
 
484
504
  const strippedContent = stripHtmlComments(content);
@@ -487,7 +507,6 @@ function renderPlainTextContent(
487
507
  pageVariables: pageVariablesProcessed,
488
508
  siteVariables,
489
509
  content: strippedContent,
490
- applyBasePath,
491
510
  subPath,
492
511
  isWatchMode,
493
512
  });
@@ -553,7 +572,6 @@ export function renderLiterateJavaPageAsset({
553
572
  const { dir, name } = path.parse(filePath);
554
573
  const className = deriveClassName(filePath);
555
574
  const subPath = toPosix(path.relative(contentDir, path.join(dir, className)));
556
- const applyBasePath = createApplyBasePath(siteVariables);
557
575
 
558
576
  log.info`Rendering literate Java page ${B`${name}`}`;
559
577
 
@@ -600,21 +618,7 @@ export function renderLiterateJavaPageAsset({
600
618
  // Render full markdown with a custom fence rule that replaces fences
601
619
  // with Shiki-highlighted code blocks and optional JDI output columns
602
620
  const sourceUrlPath = `/${subPath}.java.html`;
603
- const md = createMarkdown(siteVariables, {
604
- filePath,
605
- validatorOptions: {
606
- filePath,
607
- sourceUrlPath,
608
- validTargets: validInternalTargets,
609
- codeExtensions: Object.keys(getExtensionToShikiLanguage(siteVariables)),
610
- onValidLink: (targetPath: string) => {
611
- dependencyCollector?.internalTargets?.add(targetPath);
612
- },
613
- },
614
- literateJavaOutputPaths,
615
- sourceUrlPath,
616
- validTargets: validInternalTargets,
617
- });
621
+ const md = createMarkdown(siteVariables, { filePath });
618
622
  let fenceIndex = 0;
619
623
  const defaultFence = md.renderer.rules.fence!;
620
624
 
@@ -667,10 +671,8 @@ export function renderLiterateJavaPageAsset({
667
671
 
668
672
  // Build page variables
669
673
  const javaFileName = `${className}.java`;
670
- const codeFilePath = applyBasePath(
671
- normalizeOutputPath(
672
- `/${toUrlPath(path.relative(contentDir, path.join(dir, javaFileName)))}`,
673
- ),
674
+ const codeFilePath = normalizeOutputPath(
675
+ `/${toUrlPath(path.relative(contentDir, path.join(dir, javaFileName)))}`,
674
676
  );
675
677
 
676
678
  renderInlineField(md, pageVariables, 'title');
@@ -691,16 +693,20 @@ export function renderLiterateJavaPageAsset({
691
693
  pageVariables,
692
694
  siteVariables,
693
695
  content: contentHtml,
694
- applyBasePath,
695
696
  subPath,
696
697
  isWatchMode: isWatchMode(assetFiles),
697
698
  });
698
699
 
699
700
  const templateHtml = render('literate.html', templateParameters) as string;
700
- let html = injectAssetTags(templateHtml, assetFiles, applyBasePath, distDir);
701
- if (templateHtml.includes('class="katex"')) {
702
- html = injectKatexStylesheet(html, applyBasePath);
703
- }
701
+ const finalized = finalizeHtmlPage({
702
+ filePath,
703
+ html: preparePageTemplateHtml({ templateHtml, assetFiles, distDir }),
704
+ siteVariables,
705
+ sourceUrlPath,
706
+ validInternalTargets,
707
+ literateJavaOutputPaths,
708
+ dependencyCollector,
709
+ });
704
710
 
705
711
  return [
706
712
  {
@@ -708,7 +714,8 @@ export function renderLiterateJavaPageAsset({
708
714
  contentDir,
709
715
  path.join(dir, `${className}.java.html`),
710
716
  ),
711
- content: html,
717
+ content: finalized.html,
718
+ htmlAnalysis: finalized.analysis,
712
719
  },
713
720
  {
714
721
  assetPath: toContentAssetPath(contentDir, path.join(dir, javaFileName)),
@@ -716,3 +723,5 @@ export function renderLiterateJavaPageAsset({
716
723
  },
717
724
  ];
718
725
  }
726
+
727
+ export { finalizeHtmlPage } from './final-html';
@@ -1,3 +1,4 @@
1
+ import path from 'path';
1
2
  import { normalizeOutputPath } from './utils/paths';
2
3
 
3
4
  interface NavLink {
@@ -12,9 +13,21 @@ interface NavSection {
12
13
  links: NavLink[];
13
14
  }
14
15
 
16
+ function requireRootRelativePath(
17
+ value: string,
18
+ errorPrefix: string,
19
+ ): string | null {
20
+ if (value.startsWith('/')) {
21
+ return null;
22
+ }
23
+
24
+ return `${errorPrefix} must start with "/": "${value}"`;
25
+ }
26
+
15
27
  export function validateNavLinks(
16
28
  navData: unknown,
17
29
  validTargets: Set<string>,
30
+ fileName: string = 'nav.yaml',
18
31
  ): string[] {
19
32
  if (!Array.isArray(navData)) {
20
33
  return [];
@@ -28,10 +41,19 @@ export function validateNavLinks(
28
41
  continue;
29
42
  }
30
43
 
44
+ const rootRelativeError = requireRootRelativePath(
45
+ link.internal,
46
+ `${fileName}: internal link in section "${section.title}"`,
47
+ );
48
+ if (rootRelativeError) {
49
+ errors.push(rootRelativeError);
50
+ continue;
51
+ }
52
+
31
53
  const normalized = normalizeOutputPath(link.internal);
32
54
  if (!validTargets.has(normalized)) {
33
55
  errors.push(
34
- `nav.json: broken internal link in section "${section.title}": "${link.internal}"`,
56
+ `${fileName}: broken internal link in section "${section.title}": "${link.internal}"`,
35
57
  );
36
58
  }
37
59
  }
@@ -49,6 +71,7 @@ interface AuthorEntry {
49
71
  export function validateAuthorLinks(
50
72
  authorsData: unknown,
51
73
  validTargets: Set<string>,
74
+ fileName: string = 'authors.yaml',
52
75
  ): string[] {
53
76
  if (!authorsData || typeof authorsData !== 'object') {
54
77
  return [];
@@ -58,17 +81,33 @@ export function validateAuthorLinks(
58
81
  const authors = authorsData as Record<string, AuthorEntry>;
59
82
 
60
83
  for (const [key, author] of Object.entries(authors)) {
61
- const avatarPath = normalizeOutputPath(author.avatar);
62
- if (!validTargets.has(avatarPath)) {
63
- errors.push(
64
- `authors.json: broken avatar path for "${key}": "${author.avatar}"`,
65
- );
84
+ const avatarRootRelativeError = requireRootRelativePath(
85
+ author.avatar,
86
+ `${fileName}: avatar path for "${key}"`,
87
+ );
88
+ if (avatarRootRelativeError) {
89
+ errors.push(avatarRootRelativeError);
90
+ } else {
91
+ const avatarPath = normalizeOutputPath(author.avatar);
92
+ if (!validTargets.has(avatarPath)) {
93
+ errors.push(
94
+ `${fileName}: broken avatar path for "${key}": "${author.avatar}"`,
95
+ );
96
+ }
66
97
  }
67
98
 
68
99
  if (author.url) {
69
- const urlPath = normalizeOutputPath(author.url);
70
- if (!validTargets.has(urlPath)) {
71
- errors.push(`authors.json: broken url for "${key}": "${author.url}"`);
100
+ const urlRootRelativeError = requireRootRelativePath(
101
+ author.url,
102
+ `${fileName}: url for "${key}"`,
103
+ );
104
+ if (urlRootRelativeError) {
105
+ errors.push(urlRootRelativeError);
106
+ } else {
107
+ const urlPath = normalizeOutputPath(author.url);
108
+ if (!validTargets.has(urlPath)) {
109
+ errors.push(`${fileName}: broken url for "${key}": "${author.url}"`);
110
+ }
72
111
  }
73
112
  }
74
113
  }
@@ -80,26 +119,64 @@ export function validateConfigLinks(
80
119
  validTargets: Set<string>,
81
120
  navData: unknown,
82
121
  authorsData: unknown,
122
+ {
123
+ navFileName = 'nav.yaml',
124
+ authorsFileName = 'authors.yaml',
125
+ }: { navFileName?: string; authorsFileName?: string } = {},
83
126
  ): string[] {
84
127
  return [
85
- ...validateNavLinks(navData, validTargets),
86
- ...validateAuthorLinks(authorsData, validTargets),
128
+ ...validateNavLinks(navData, validTargets, navFileName),
129
+ ...validateAuthorLinks(authorsData, validTargets, authorsFileName),
87
130
  ];
88
131
  }
89
132
 
133
+ function splitHref(href: string): { pathname: string; suffix: string } {
134
+ const match = href.match(/^([^?#]*)(.*)$/);
135
+ return { pathname: match ? match[1] : href, suffix: match ? match[2] : '' };
136
+ }
137
+
90
138
  export function validateParentLink(
91
139
  parent: unknown,
92
140
  filePath: string,
93
141
  validTargets: Set<string>,
142
+ sourceUrlPath: string,
94
143
  ): string | null {
95
- if (!parent || typeof parent !== 'string') {
144
+ if (typeof parent === 'string' && splitHref(parent).pathname === '') {
145
+ return `${filePath}: broken parent link: "${parent}"`;
146
+ }
147
+
148
+ const resolvedTarget = resolveParentLinkTarget(parent, sourceUrlPath);
149
+ if (!resolvedTarget) {
96
150
  return null;
97
151
  }
98
152
 
99
- const normalized = normalizeOutputPath(parent);
100
- if (!validTargets.has(normalized)) {
153
+ if (!validTargets.has(resolvedTarget)) {
101
154
  return `${filePath}: broken parent link: "${parent}"`;
102
155
  }
103
156
 
104
157
  return null;
105
158
  }
159
+
160
+ export function resolveParentLinkTarget(
161
+ parent: unknown,
162
+ sourceUrlPath: string,
163
+ ): string | null {
164
+ if (!parent || typeof parent !== 'string') {
165
+ return null;
166
+ }
167
+
168
+ const { pathname } = splitHref(parent);
169
+ if (!pathname) {
170
+ return null;
171
+ }
172
+ const sourceDir = path.posix.dirname(sourceUrlPath);
173
+ const resolved = pathname.startsWith('/')
174
+ ? normalizeOutputPath(pathname)
175
+ : normalizeOutputPath(path.posix.join(sourceDir, pathname));
176
+
177
+ try {
178
+ return normalizeOutputPath(decodeURIComponent(resolved));
179
+ } catch {
180
+ return resolved;
181
+ }
182
+ }
@@ -34,7 +34,6 @@ export async function ensureHighlighter(
34
34
  export async function bundleWatchAssets(
35
35
  outputDir: string,
36
36
  siteVariables: SiteVariables,
37
- wsPort: number,
38
37
  ): Promise<string[]> {
39
38
  const appAssets = await bundle(siteVariables, {
40
39
  mode: 'development',
@@ -45,7 +44,6 @@ export async function bundleWatchAssets(
45
44
  outdir: outputDir,
46
45
  naming: '[name].bundle.[ext]',
47
46
  sourcemap: 'inline',
48
- define: { __WEBSOCKET_PORT__: String(wsPort) },
49
47
  });
50
48
  return [
51
49
  ...appAssets,