@abreen/tada 1.5.8 → 1.6.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.
package/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # Tada :tada:
2
2
 
3
+ [![CI/CD](https://github.com/abreen/Tada/actions/workflows/ci.yml/badge.svg)](https://github.com/abreen/Tada/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/%40abreen%2Ftada)](https://www.npmjs.com/package/@abreen/tada)
5
+
3
6
  A static site generator. The successor to Presto.
4
7
 
5
8
  ## Features
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ import { toPosix } from './utils/paths';
3
4
 
4
5
  const EXCLUDED_DIRS = new Set(['pagefind']);
5
6
  const EXCLUDED_FILES = new Set(['tada.manifest.json']);
@@ -68,7 +69,7 @@ export async function walkAndHash(
68
69
  }
69
70
 
70
71
  const abs = path.join(entry.parentPath, entry.name);
71
- const rel = path.relative(dir, abs).split(path.sep).join(path.posix.sep);
72
+ const rel = toPosix(path.relative(dir, abs));
72
73
 
73
74
  const topLevel = rel.split('/')[0];
74
75
  if (EXCLUDED_DIRS.has(topLevel)) {
package/build/bundle.ts CHANGED
@@ -3,8 +3,13 @@ import os from 'os';
3
3
  import path from 'path';
4
4
  import _ from 'lodash';
5
5
  import * as sass from 'sass';
6
- import { getPackageDir, getProjectDir, getDistDir } from './utils/paths';
7
- import { deriveTheme } from './utils/derive-theme';
6
+ import {
7
+ getPackageDir,
8
+ getProjectDir,
9
+ getDistDir,
10
+ toPosix,
11
+ } from './utils/paths';
12
+ import { deriveTheme, deriveLinkHue } from './utils/derive-theme';
8
13
  import type { PluginBuilder } from 'bun';
9
14
  import type { SiteVariables } from './types';
10
15
  import timezones from '../src/timezone/timezones.json' with { type: 'json' };
@@ -22,6 +27,12 @@ function renderThemeScss(siteVariables: SiteVariables): string {
22
27
  const iconColorHoverDark = `hsl(${tintHue}deg ${(6 * tintAmount) / 100}% 60%)`;
23
28
  const iconColorTranslucentDark = `hsl(${tintHue}deg ${(85 * tintAmount) / 100}% 90%)`;
24
29
 
30
+ const linkHue = deriveLinkHue(tintHue);
31
+ const linkColor = `hsl(${linkHue}deg 44.4% 49.4%)`;
32
+ const linkColorHover = `hsl(${linkHue}deg 34% 60%)`;
33
+ const linkColorDark = `hsl(${linkHue}deg 50% 72%)`;
34
+ const linkColorHoverDark = `hsl(${linkHue}deg 40% 80%)`;
35
+
25
36
  const rendered = _.template(template)({
26
37
  ...theme,
27
38
  tintHue,
@@ -31,6 +42,10 @@ function renderThemeScss(siteVariables: SiteVariables): string {
31
42
  iconColorDark,
32
43
  iconColorHoverDark,
33
44
  iconColorTranslucentDark,
45
+ linkColor,
46
+ linkColorHover,
47
+ linkColorDark,
48
+ linkColorHoverDark,
34
49
  });
35
50
 
36
51
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tada-'));
@@ -104,10 +119,7 @@ export async function bundle(
104
119
 
105
120
  // Return the output filenames for asset tag injection
106
121
  const assetFiles = result.outputs.map(output =>
107
- path
108
- .relative(resolvedDistDir, output.path)
109
- .split(path.sep)
110
- .join(path.posix.sep),
122
+ toPosix(path.relative(resolvedDistDir, output.path)),
111
123
  );
112
124
 
113
125
  return assetFiles;
package/build/copy.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ import { toPosix } from './utils/paths';
3
4
  import { makeLogger } from './log';
4
5
  import { B } from './colors';
5
6
 
@@ -22,7 +23,7 @@ function collectFiles(dir: string): CollectedFile[] {
22
23
  .map(entry => {
23
24
  const abs = path.join(entry.parentPath, entry.name);
24
25
  const rel = path.relative(dir, abs);
25
- return { abs, rel: rel.split(path.sep).join(path.posix.sep) };
26
+ return { abs, rel: toPosix(rel) };
26
27
  });
27
28
  }
28
29
 
@@ -83,10 +84,7 @@ export function copyPublicFile(
83
84
  filePath: string,
84
85
  contentAssetRelPaths?: Set<string>,
85
86
  ): void {
86
- const rel = path
87
- .relative(publicDir, filePath)
88
- .split(path.sep)
89
- .join(path.posix.sep);
87
+ const rel = toPosix(path.relative(publicDir, filePath));
90
88
  if (contentAssetRelPaths && contentAssetRelPaths.has(rel)) {
91
89
  log.error`public/${B`${rel}`} conflicts with content/${B`${rel}`}`;
92
90
  throw new Error(`public/${rel} and content/${rel} have the same path`);
@@ -103,10 +101,7 @@ export function copyContentFile(
103
101
  filePath: string,
104
102
  publicRelPaths?: Set<string>,
105
103
  ): void {
106
- const rel = path
107
- .relative(contentDir, filePath)
108
- .split(path.sep)
109
- .join(path.posix.sep);
104
+ const rel = toPosix(path.relative(contentDir, filePath));
110
105
  if (publicRelPaths && publicRelPaths.has(rel)) {
111
106
  log.error`content/${B`${rel}`} conflicts with public/${B`${rel}`}`;
112
107
  throw new Error(`content/${rel} and public/${rel} have the same path`);
@@ -1,5 +1,5 @@
1
1
  import type MarkdownIt from 'markdown-it';
2
- import type Token from 'markdown-it/lib/token.mjs';
2
+ import Token from 'markdown-it/lib/token.mjs';
3
3
  import { makeLogger } from './log';
4
4
  import type { SiteVariables } from './types';
5
5
 
@@ -9,38 +9,197 @@ export default function externalLinks(
9
9
  md: MarkdownIt,
10
10
  siteVariables: SiteVariables,
11
11
  ): void {
12
- function addClass(token: Token): void {
13
- if (token.type === 'link_open') {
14
- const href = token.attrGet('href');
15
- if (!href) {
16
- return;
12
+ function isExternal(href: string): boolean {
13
+ if (!href.match(/^https?:\/\/.*$/)) {
14
+ return false;
15
+ }
16
+ const url = new URL(href);
17
+ return !siteVariables.internalDomains?.includes(url.host);
18
+ }
19
+
20
+ function findMatchingClose(children: Token[], openIdx: number): number {
21
+ let depth = 1;
22
+ for (let i = openIdx + 1; i < children.length; i++) {
23
+ if (children[i].type === 'link_open') {
24
+ depth++;
25
+ } else if (children[i].type === 'link_close') {
26
+ depth--;
27
+ if (depth === 0) {
28
+ return i;
29
+ }
17
30
  }
31
+ }
32
+ return -1;
33
+ }
34
+
35
+ function makeSpanOpen(): Token {
36
+ const t = new Token('html_inline', '', 0);
37
+ t.content = '<span class="external-link-tail">';
38
+ return t;
39
+ }
18
40
 
19
- if (href.match(/^https?:\/\/.*$/)) {
20
- const url = new URL(href);
21
- if (!siteVariables.internalDomains?.includes(url.host)) {
22
- const classAttr = token.attrGet('class');
23
- let newClassAttr;
41
+ function makeSpanClose(): Token {
42
+ const t = new Token('html_inline', '', 0);
43
+ t.content = '</span>';
44
+ return t;
45
+ }
46
+
47
+ // Wrap the trailing word of an external link in
48
+ // <span class="external-link-tail"> so the icon (added via that span's
49
+ // ::after) stays glued to the last word and doesn't wrap on its own.
50
+ // The span is inserted inside any inline-formatting elements (em,
51
+ // strong, code, ...) that are still open at the split point, so the
52
+ // resulting markup stays nested.
53
+ function wrapTail(linkChildren: Token[]): Token[] | null {
54
+ if (linkChildren.length === 0) {
55
+ return null;
56
+ }
24
57
 
25
- if (classAttr) {
26
- newClassAttr = classAttr + ' external';
27
- } else {
28
- newClassAttr = 'external';
29
- }
58
+ // Find the rightmost split char (space or hyphen) in a text token,
59
+ // skipping any candidate that would produce an empty tail (e.g. a
60
+ // trailing hyphen with no content after it).
61
+ let splitTokenIdx = -1;
62
+ let splitCharIdx = -1;
63
+ for (let i = linkChildren.length - 1; i >= 0; i--) {
64
+ const tok = linkChildren[i];
65
+ if (tok.type !== 'text') {
66
+ continue;
67
+ }
68
+ const text = tok.content;
69
+ const hasLaterTokens = i < linkChildren.length - 1;
70
+ let foundIdx = -1;
71
+ for (let j = text.length - 1; j >= 0; j--) {
72
+ const c = text[j];
73
+ if (c !== ' ' && c !== '-') {
74
+ continue;
75
+ }
76
+ if (j < text.length - 1 || hasLaterTokens) {
77
+ foundIdx = j;
78
+ break;
79
+ }
80
+ }
81
+ if (foundIdx !== -1) {
82
+ splitTokenIdx = i;
83
+ splitCharIdx = foundIdx;
84
+ break;
85
+ }
86
+ }
30
87
 
31
- log.debug`${href} -> "${newClassAttr}"`;
32
- token.attrSet('class', newClassAttr);
88
+ if (splitTokenIdx === -1) {
89
+ // No space anywhere in the link's text. Wrap the whole content.
90
+ return [makeSpanOpen(), ...linkChildren, makeSpanClose()];
91
+ }
33
92
 
34
- log.debug`${href} -> target="_blank"`;
35
- token.attrSet('target', '_blank');
93
+ // Find where to put the span close: walk forward from the split
94
+ // point tracking nesting depth (locally opened elements after the
95
+ // split). The span must close before any -1 token that would close
96
+ // an element that was already open at the split point.
97
+ let spanCloseIdx = linkChildren.length;
98
+ let depth = 0;
99
+ for (let i = splitTokenIdx + 1; i < linkChildren.length; i++) {
100
+ const tok = linkChildren[i];
101
+ if (tok.nesting === 1) {
102
+ depth++;
103
+ } else if (tok.nesting === -1) {
104
+ if (depth > 0) {
105
+ depth--;
106
+ } else {
107
+ spanCloseIdx = i;
108
+ break;
36
109
  }
37
110
  }
38
111
  }
39
112
 
40
- token.children?.map(addClass);
113
+ const splitToken = linkChildren[splitTokenIdx];
114
+ const before = splitToken.content.slice(0, splitCharIdx + 1);
115
+ const after = splitToken.content.slice(splitCharIdx + 1);
116
+
117
+ const result: Token[] = [];
118
+
119
+ // Tokens before the split, unchanged.
120
+ for (let i = 0; i < splitTokenIdx; i++) {
121
+ result.push(linkChildren[i]);
122
+ }
123
+
124
+ // The "before" part of the split text token.
125
+ if (before.length > 0) {
126
+ const beforeToken = new Token('text', '', 0);
127
+ beforeToken.content = before;
128
+ result.push(beforeToken);
129
+ }
130
+
131
+ result.push(makeSpanOpen());
132
+
133
+ // The "after" part of the split text token.
134
+ if (after.length > 0) {
135
+ const afterToken = new Token('text', '', 0);
136
+ afterToken.content = after;
137
+ result.push(afterToken);
138
+ }
139
+
140
+ // Tokens between the split and the span close.
141
+ for (let i = splitTokenIdx + 1; i < spanCloseIdx; i++) {
142
+ result.push(linkChildren[i]);
143
+ }
144
+
145
+ result.push(makeSpanClose());
146
+
147
+ // Tokens from the span close position to the end.
148
+ for (let i = spanCloseIdx; i < linkChildren.length; i++) {
149
+ result.push(linkChildren[i]);
150
+ }
151
+
152
+ return result;
153
+ }
154
+
155
+ function processChildren(children: Token[]): Token[] {
156
+ const result: Token[] = [];
157
+ let i = 0;
158
+ while (i < children.length) {
159
+ const token = children[i];
160
+
161
+ if (token.type !== 'link_open') {
162
+ result.push(token);
163
+ i++;
164
+ continue;
165
+ }
166
+
167
+ const href = token.attrGet('href');
168
+ if (!href || !isExternal(href)) {
169
+ result.push(token);
170
+ i++;
171
+ continue;
172
+ }
173
+
174
+ const classAttr = token.attrGet('class');
175
+ const newClassAttr = classAttr ? `${classAttr} external` : 'external';
176
+ token.attrSet('class', newClassAttr);
177
+ token.attrSet('target', '_blank');
178
+ log.debug`${href} -> "${newClassAttr}"`;
179
+
180
+ const closeIdx = findMatchingClose(children, i);
181
+ if (closeIdx === -1) {
182
+ result.push(token);
183
+ i++;
184
+ continue;
185
+ }
186
+
187
+ const linkContent = children.slice(i + 1, closeIdx);
188
+ const wrapped = wrapTail(linkContent);
189
+
190
+ result.push(token);
191
+ result.push(...(wrapped ?? linkContent));
192
+ result.push(children[closeIdx]);
193
+ i = closeIdx + 1;
194
+ }
195
+ return result;
41
196
  }
42
197
 
43
198
  md.core.ruler.push('external_links', state => {
44
- state.tokens.map(addClass);
199
+ state.tokens.forEach(token => {
200
+ if (token.children) {
201
+ token.children = processChildren(token.children);
202
+ }
203
+ });
45
204
  });
46
205
  }
@@ -16,6 +16,7 @@ import {
16
16
  } from './util';
17
17
  import { isLiterateJava } from './utils/file-types';
18
18
  import { checkJavac } from './utils/literate-java';
19
+ import { toPosix } from './utils/paths';
19
20
  import type {
20
21
  SiteVariables,
21
22
  Asset,
@@ -291,10 +292,7 @@ export class ContentRenderer {
291
292
  for (const filePath of buildContentFiles) {
292
293
  if (isLiterateJava(filePath)) {
293
294
  const parsed = path.parse(path.relative(contentDir, filePath));
294
- const javaPath = path
295
- .join(parsed.dir, parsed.name)
296
- .split(path.sep)
297
- .join(path.posix.sep);
295
+ const javaPath = toPosix(path.join(parsed.dir, parsed.name));
298
296
  literateJavaOutputPaths.add(`/${javaPath}`);
299
297
  }
300
298
  }
@@ -7,7 +7,7 @@ import {
7
7
  isLiterateJava,
8
8
  isPartial,
9
9
  } from './file-types';
10
- import { getPublicDir, normalizeOutputPath } from './paths';
10
+ import { getPublicDir, normalizeOutputPath, toPosix } from './paths';
11
11
 
12
12
  function walkFiles(dir: string): string[] {
13
13
  return fs.readdirSync(dir).flatMap(file => {
@@ -111,18 +111,12 @@ export function getValidInternalTargets(
111
111
  const relPath = path.relative(contentDir, filePath);
112
112
  const parsed = path.parse(relPath);
113
113
  const ext = parsed.ext.toLowerCase();
114
- const subPath = path
115
- .join(parsed.dir, parsed.name)
116
- .split(path.sep)
117
- .join(path.posix.sep);
114
+ const subPath = toPosix(path.join(parsed.dir, parsed.name));
118
115
 
119
116
  if (isLiterateJava(filePath)) {
120
117
  // parsed.name is e.g. "VowelCounter.java"; the output page is
121
118
  // VowelCounter.java.html and the source file is VowelCounter.java
122
- const javaSubPath = path
123
- .join(parsed.dir, parsed.name)
124
- .split(path.sep)
125
- .join(path.posix.sep);
119
+ const javaSubPath = toPosix(path.join(parsed.dir, parsed.name));
126
120
  addGeneratedRouteAliases(targets, `/${javaSubPath}.html`);
127
121
  targets.add(normalizeOutputPath(`/${javaSubPath}`));
128
122
  } else if (extensionIsMarkdown(ext) || ext === '.html') {
@@ -14,6 +14,9 @@ const LIGHT_TEXT_L_MAX = 0.5;
14
14
  const DARK_TEXT_L_MIN = 0.7;
15
15
  const DARK_TEXT_L_MAX = 0.82;
16
16
 
17
+ const LINK_ANCHOR_HUE = 212;
18
+ const LINK_TINT_BLEND = 0.05;
19
+
17
20
  function clamp(v: number, min: number, max: number): number {
18
21
  return Math.min(Math.max(v, min), max);
19
22
  }
@@ -75,6 +78,11 @@ export function deriveTheme(cssColor: string): DerivedTheme {
75
78
  };
76
79
  }
77
80
 
81
+ export function deriveLinkHue(tintHue: number): number {
82
+ const diff = ((tintHue - LINK_ANCHOR_HUE + 540) % 360) - 180;
83
+ return (LINK_ANCHOR_HUE + diff * LINK_TINT_BLEND + 360) % 360;
84
+ }
85
+
78
86
  export function getTextOnColor(cssColor: string): '#fff' | '#000' {
79
87
  const parsed = parse(cssColor);
80
88
  if (!parsed) {
@@ -45,8 +45,23 @@ export function createApplyBasePath(
45
45
  };
46
46
  }
47
47
 
48
+ export function toPosix(p: string): string {
49
+ return p.split(path.sep).join(path.posix.sep);
50
+ }
51
+
52
+ /**
53
+ * Convert an OS-native relative filesystem path to a URL path: convert
54
+ * separators to '/', then percent-encode each segment so characters that
55
+ * are unsafe in URLs (space, ?, #, non-ASCII, etc.) are encoded. Use this
56
+ * anywhere a filesystem-derived path is emitted as an href or looked up
57
+ * against a URL-encoded href.
58
+ */
59
+ export function toUrlPath(p: string): string {
60
+ return toPosix(p).split('/').map(encodeURIComponent).join('/');
61
+ }
62
+
48
63
  export function normalizeOutputPath(outputPath: string): string {
49
- const normalized = path.posix.normalize(outputPath);
64
+ const normalized = path.posix.normalize(toPosix(outputPath));
50
65
  if (normalized === '.' || normalized === '') {
51
66
  return '/';
52
67
  }
@@ -17,7 +17,12 @@ import {
17
17
  import { extensionIsMarkdown } from './file-types';
18
18
  import { createTraceHelpers } from './trace';
19
19
  import { createIncludeFunction } from './include';
20
- import { createApplyBasePath, normalizeOutputPath } from './paths';
20
+ import {
21
+ createApplyBasePath,
22
+ normalizeOutputPath,
23
+ toPosix,
24
+ toUrlPath,
25
+ } from './paths';
21
26
  import { parseFrontMatterAndContent } from './front-matter';
22
27
  import { createMarkdown } from './markdown';
23
28
  import { generateTocHtml, generateCodeTocHtml } from '../toc-plugin';
@@ -41,12 +46,17 @@ const log = makeLogger(import.meta.url);
41
46
 
42
47
  const REQUIRED_FRONT_MATTER_FIELDS = ['title'];
43
48
 
49
+ function isWatchMode(assetFiles: string[]): boolean {
50
+ return assetFiles.some(f => f.includes('watch-reload-client'));
51
+ }
52
+
44
53
  interface TemplateParametersInput {
45
54
  pageVariables: Record<string, unknown>;
46
55
  siteVariables: SiteVariables;
47
56
  content: string | null;
48
57
  applyBasePath: (subPath: string) => string;
49
58
  subPath: string;
59
+ isWatchMode: boolean;
50
60
  }
51
61
 
52
62
  function resolveAuthor(
@@ -96,6 +106,7 @@ function createTemplateParameters({
96
106
  content,
97
107
  applyBasePath,
98
108
  subPath,
109
+ isWatchMode,
99
110
  }: TemplateParametersInput): Record<string, unknown> {
100
111
  return {
101
112
  vars: siteVariables.vars || {},
@@ -106,6 +117,7 @@ function createTemplateParameters({
106
117
  page: pageVariables,
107
118
  content,
108
119
  applyBasePath,
120
+ isWatchMode,
109
121
  };
110
122
  }
111
123
 
@@ -164,10 +176,7 @@ export function injectKatexStylesheet(
164
176
  }
165
177
 
166
178
  function toContentAssetPath(contentDir: string, filePath: string): string {
167
- return path
168
- .relative(contentDir, filePath)
169
- .split(path.sep)
170
- .join(path.posix.sep);
179
+ return toPosix(path.relative(contentDir, filePath));
171
180
  }
172
181
 
173
182
  export function renderPlainTextPageAsset({
@@ -182,16 +191,18 @@ export function renderPlainTextPageAsset({
182
191
  javacAvailable,
183
192
  }: RenderPlainTextOptions): Asset[] {
184
193
  const { dir, name, ext } = path.parse(filePath);
185
- const subPath = path.relative(contentDir, path.join(dir, name));
194
+ const subPath = toPosix(path.relative(contentDir, path.join(dir, name)));
186
195
  const applyBasePath = createApplyBasePath(siteVariables);
187
196
 
188
197
  log.info`Rendering page ${B`${subPath + ext}`}`;
198
+ const watchMode = isWatchMode(assetFiles);
189
199
  const { content, pageVariables, tocItems, alertIds } = renderPlainTextContent(
190
200
  filePath,
191
201
  subPath,
192
202
  siteVariables,
193
203
  applyBasePath,
194
204
  validInternalTargets,
205
+ watchMode,
195
206
  {
196
207
  validateInternalLinks: extensionIsMarkdown(ext.toLowerCase()),
197
208
  traceCache,
@@ -221,6 +232,7 @@ export function renderPlainTextPageAsset({
221
232
  content,
222
233
  applyBasePath,
223
234
  subPath,
235
+ isWatchMode: watchMode,
224
236
  });
225
237
 
226
238
  const templateHtml = render(
@@ -248,11 +260,11 @@ export function renderCodePageAsset({
248
260
  assetFiles,
249
261
  }: RenderCodePageOptions): Asset[] {
250
262
  const { dir, name, ext } = path.parse(filePath);
251
- const subPath = path.relative(contentDir, path.join(dir, name));
263
+ const subPath = toPosix(path.relative(contentDir, path.join(dir, name)));
252
264
  const applyBasePath = createApplyBasePath(siteVariables);
253
265
  const lang = siteVariables.codeLanguages![ext.slice(1).toLowerCase()];
254
266
  const sourceCode = fs.readFileSync(filePath, 'utf-8');
255
- const pageDirPath = path.relative(contentDir, dir);
267
+ const pageDirPath = toPosix(path.relative(contentDir, dir));
256
268
 
257
269
  log.info`Rendering code page ${B`${subPath + ext}`}`;
258
270
  const content = renderCodeWithComments(
@@ -262,7 +274,7 @@ export function renderCodePageAsset({
262
274
  pageDirPath,
263
275
  );
264
276
  const codeFilePath = applyBasePath(
265
- normalizeOutputPath(`/${toContentAssetPath(contentDir, filePath)}`),
277
+ normalizeOutputPath(`/${toUrlPath(path.relative(contentDir, filePath))}`),
266
278
  );
267
279
  const titleHtml = `<tt>${name + ext}</tt>`;
268
280
  const tocItems = lang === 'java' ? extractJavaMethodToc(sourceCode) : [];
@@ -284,6 +296,7 @@ export function renderCodePageAsset({
284
296
  content,
285
297
  applyBasePath,
286
298
  subPath,
299
+ isWatchMode: isWatchMode(assetFiles),
287
300
  });
288
301
 
289
302
  const html = injectAssetTags(
@@ -316,7 +329,9 @@ export function renderCopiedContentAsset({
316
329
 
317
330
  if (filePath.endsWith('.java') && isFeatureEnabled(siteVariables, 'code')) {
318
331
  const sourceCode = fs.readFileSync(filePath, 'utf-8');
319
- const pageDirPath = path.relative(contentDir, path.dirname(filePath));
332
+ const pageDirPath = toPosix(
333
+ path.relative(contentDir, path.dirname(filePath)),
334
+ );
320
335
  const rewrittenLines = rewriteProseLinks(
321
336
  sourceCode.split('\n'),
322
337
  siteVariables,
@@ -335,6 +350,7 @@ function renderPlainTextContent(
335
350
  siteVariables: SiteVariables,
336
351
  applyBasePath: (subPath: string) => string,
337
352
  validInternalTargets: Set<string>,
353
+ isWatchMode: boolean,
338
354
  {
339
355
  validateInternalLinks = true,
340
356
  traceCache,
@@ -392,6 +408,7 @@ function renderPlainTextContent(
392
408
  content: null,
393
409
  applyBasePath,
394
410
  subPath,
411
+ isWatchMode,
395
412
  });
396
413
  const pageVariablesProcessed: Record<string, unknown> = Object.entries(
397
414
  pageVariables,
@@ -442,6 +459,7 @@ function renderPlainTextContent(
442
459
  content: strippedContent,
443
460
  applyBasePath,
444
461
  subPath,
462
+ isWatchMode,
445
463
  });
446
464
 
447
465
  if (traceCache && contentDir && distDir) {
@@ -501,7 +519,7 @@ export function renderLiterateJavaPageAsset({
501
519
  }: RenderLiterateJavaOptions): Asset[] {
502
520
  const { dir, name } = path.parse(filePath);
503
521
  const className = deriveClassName(filePath);
504
- const subPath = path.relative(contentDir, path.join(dir, className));
522
+ const subPath = toPosix(path.relative(contentDir, path.join(dir, className)));
505
523
  const applyBasePath = createApplyBasePath(siteVariables);
506
524
 
507
525
  log.info`Rendering literate Java page ${B`${name}`}`;
@@ -619,7 +637,7 @@ export function renderLiterateJavaPageAsset({
619
637
  const javaFileName = `${className}.java`;
620
638
  const codeFilePath = applyBasePath(
621
639
  normalizeOutputPath(
622
- `/${toContentAssetPath(contentDir, path.join(dir, javaFileName))}`,
640
+ `/${toUrlPath(path.relative(contentDir, path.join(dir, javaFileName)))}`,
623
641
  ),
624
642
  );
625
643
 
@@ -645,6 +663,7 @@ export function renderLiterateJavaPageAsset({
645
663
  content: contentHtml,
646
664
  applyBasePath,
647
665
  subPath,
666
+ isWatchMode: isWatchMode(assetFiles),
648
667
  });
649
668
 
650
669
  const templateHtml = render('literate.html', templateParameters) as string;
@@ -4,7 +4,7 @@ import path from 'path';
4
4
  import { execFileSync } from 'child_process';
5
5
  import { makeLogger } from '../log';
6
6
  import { B } from '../colors';
7
- import { normalizeOutputPath } from './paths';
7
+ import { normalizeOutputPath, toPosix } from './paths';
8
8
  import { hasMainMethod } from './literate-java';
9
9
  import { renderCodeSegment } from './code';
10
10
  import { computeLayout } from './trace-layout';
@@ -324,10 +324,7 @@ export function createTraceHelpers(context: TraceContext): {
324
324
 
325
325
  const highlightedSource = highlightSource(javaFile, source);
326
326
 
327
- const relDir = path
328
- .relative(contentDir, pageDir)
329
- .split(path.sep)
330
- .join(path.posix.sep);
327
+ const relDir = toPosix(path.relative(contentDir, pageDir));
331
328
  const traceOutputDir = path.join(distDir, relDir, '_traces', className);
332
329
 
333
330
  const manifest = chunkTraceOutput(
@@ -69,7 +69,18 @@ function resolveLinkPath(
69
69
  ? normalizePathname(hrefPath)
70
70
  : normalizePathname(path.posix.join(sourceDir, hrefPath));
71
71
 
72
- return rewriteCodeLink(resolved, codeExtPattern);
72
+ // Decode percent-encoded characters so the resolved path can be matched
73
+ // against `validTargets`, which contains raw filesystem-derived paths.
74
+ // markdown-it's normalizeLink percent-encodes hrefs, so a link like
75
+ // `[x](/my notes.md)` arrives here as `/my%20notes.md`.
76
+ let decoded: string;
77
+ try {
78
+ decoded = normalizePathname(decodeURIComponent(resolved));
79
+ } catch {
80
+ decoded = resolved;
81
+ }
82
+
83
+ return rewriteCodeLink(decoded, codeExtPattern);
73
84
  }
74
85
 
75
86
  function getDirectoryIndexPath(pathname: string): string {
@@ -26,6 +26,7 @@ declare const __WEBSOCKET_PORT__: number;
26
26
  document.body.classList.add('loading-cursor');
27
27
  } else if (event.data === 'reload') {
28
28
  console.log('[watch-reload] Reloading page...');
29
+ window.history.scrollRestoration = 'auto';
29
30
  window.location.reload();
30
31
  }
31
32
  };