@abreen/tada 1.17.0 → 1.17.1

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
@@ -135,6 +135,9 @@ files in the `dist/` directory.
135
135
  ### `tada watch`
136
136
 
137
137
  Start a development web server, watch for changes and rebuild automatically.
138
+ Exactly one `site.dev.yaml`, `site.dev.yml`, or `site.dev.json` must exist
139
+ before starting; otherwise, correct the configuration files and restart the
140
+ command. Once watching, build errors can be fixed without restarting.
138
141
 
139
142
  ### `tada clean`
140
143
 
@@ -267,7 +270,7 @@ vars:
267
270
  | `symbol` | Text (1-5 chars) displayed in header (also used as the favicon symbol) |
268
271
  | `themeColor` | Theme color for the site (e.g., `"tomato"`, `"#c04040"`, `"hsl(195 70% 40%)"`) |
269
272
  | `tintHue` | *Optional*, hue (0-360) for background and foreground tinting (default `20`) |
270
- | `tintAmount` | *Optional*, percentage (0-100) of tint to apply (default `100`) |
273
+ | `tintAmount` | *Optional*, percentage (0-100) of tint to apply to the neutral palette (default `100`); link saturation stays fixed |
271
274
  | `defaultFont` | *Optional*, initial font pairing: `sans` or `serif` (default `sans`) |
272
275
  | `defaultContrast` | *Optional*, initial neutral-palette contrast: `standard` or `high` (default `standard`) |
273
276
  | `fontOverrides` | *Optional*, custom serif and serif-monospace WOFF2 faces from `public/`; each configured family requires `regular` and may include styled faces, OpenType `features`, and font-specific `tuning` |
package/bin/tada.ts CHANGED
@@ -536,7 +536,9 @@ function diffCommand(args: string[]): void {
536
536
 
537
537
  if (totalChanges === 0) {
538
538
  console.log('\nNo changes between builds.');
539
- return;
539
+ if (copyIdx === -1) {
540
+ return;
541
+ }
540
542
  }
541
543
 
542
544
  if (diff.added.length > 0) {
@@ -559,7 +561,9 @@ function diffCommand(args: string[]): void {
559
561
  }
560
562
 
561
563
  const noun = (n: number) => (n === 1 ? 'file' : 'files');
562
- console.log(`\nTotal: ${totalChanges} ${noun(totalChanges)} differ`);
564
+ if (totalChanges > 0) {
565
+ console.log(`\nTotal: ${totalChanges} ${noun(totalChanges)} differ`);
566
+ }
563
567
 
564
568
  if (copyIdx !== -1) {
565
569
  const outDirArg = args[copyIdx + 1];
@@ -570,6 +574,7 @@ function diffCommand(args: string[]): void {
570
574
 
571
575
  const newDistDir = path.join(prodBase, `v${newVer}`);
572
576
  const resolvedOutDir = path.resolve(projectDir, outDirArg);
577
+ fs.mkdirSync(resolvedOutDir, { recursive: true });
573
578
  copyChangedFiles(diff, newDistDir, resolvedOutDir);
574
579
 
575
580
  // Always include pagefind/ (excluded from manifest but needed for search)
package/bin/validators.ts CHANGED
@@ -83,7 +83,7 @@ export function createSiteConfig({
83
83
  extensionToShikiLanguage,
84
84
  shikiLanguages,
85
85
  themeColor,
86
- tintHue: Number(tintHue),
86
+ tintHue: Number(String(tintHue).replace(/deg$/, '')),
87
87
  tintAmount: Number(tintAmount),
88
88
  vars: { foobar: 123 },
89
89
  };
@@ -85,7 +85,7 @@ export async function walkAndHash(
85
85
  if (EXCLUDED_DIRS.has(topLevel)) {
86
86
  continue;
87
87
  }
88
- if (EXCLUDED_FILES.has(entry.name)) {
88
+ if (EXCLUDED_FILES.has(rel)) {
89
89
  continue;
90
90
  }
91
91
 
@@ -1,9 +1,14 @@
1
- import type MarkdownIt from 'markdown-it';
1
+ import MarkdownIt from 'markdown-it';
2
+ import type Token from 'markdown-it/lib/token.mjs';
2
3
  import type StateBlock from 'markdown-it/lib/rules_block/state_block.mjs';
3
4
 
4
5
  const MARKER_CHAR = 0x2b; // '+'
5
6
  const MIN_MARKERS = 3;
6
7
 
8
+ // A separate block parser avoids invoking column rules recursively or mutating
9
+ // the document's tokens and plugin environment while locating literal code.
10
+ const codeContextParser = new MarkdownIt('commonmark');
11
+
7
12
  function isColumnsFence(state: StateBlock, line: number): boolean {
8
13
  const start = state.bMarks[line] + state.tShift[line];
9
14
  const max = state.eMarks[line];
@@ -46,7 +51,28 @@ function columnsRule(
46
51
  let separatorLine = -1;
47
52
  let closeLine = -1;
48
53
 
54
+ // Parse block context, including lists and blockquotes, before looking for
55
+ // delimiters. Token maps keep code fences and their contents literal.
56
+ const contextTokens: Token[] = [];
57
+ codeContextParser.block.parse(
58
+ state.getLines(startLine + 1, endLine, state.blkIndent, false),
59
+ codeContextParser,
60
+ {},
61
+ contextTokens,
62
+ );
63
+ const codeLines = new Set<number>();
64
+ for (const token of contextTokens) {
65
+ if ((token.type === 'fence' || token.type === 'code_block') && token.map) {
66
+ for (let line = token.map[0]; line < token.map[1]; line++) {
67
+ codeLines.add(startLine + 1 + line);
68
+ }
69
+ }
70
+ }
71
+
49
72
  for (let line = startLine + 1; line < endLine; line++) {
73
+ if (codeLines.has(line)) {
74
+ continue;
75
+ }
50
76
  if (!isColumnsFence(state, line)) {
51
77
  continue;
52
78
  }
@@ -24,11 +24,11 @@ export default function deflistIdPlugin(md: MarkdownIt): void {
24
24
  break;
25
25
  }
26
26
 
27
- if (t.type === 'text') {
27
+ if (t.type === 'text' || t.type === 'code_inline') {
28
28
  termText += t.content;
29
29
  } else if (t.type === 'inline' && t.children) {
30
30
  for (const child of t.children) {
31
- if (child.type === 'text') {
31
+ if (child.type === 'text' || child.type === 'code_inline') {
32
32
  termText += child.content;
33
33
  }
34
34
  }
@@ -376,13 +376,28 @@ export function updateProjectScan(
376
376
  addPublicSourceToScan({ scan, filePath: sourcePath });
377
377
  }
378
378
 
379
+ // Rebuild the selected owners from the complete source inventory. Removing
380
+ // either producer of a formerly conflicting output must retain its survivor.
381
+ scan.contentOwners.clear();
382
+ for (const sourcePath of scan.contentFiles) {
383
+ for (const outputPath of scan.sourceOutputPaths.get(sourcePath) || []) {
384
+ scan.contentOwners.set(outputPath, sourcePath);
385
+ }
386
+ }
379
387
  scan.validTargets = collectValidTargets(scan.sourceTargetPaths);
380
388
  return scan;
381
389
  }
382
390
 
383
391
  export function assertNoOutputPathConflicts(scan: TadaProjectScan): string[] {
384
- const conflicts = [...scan.contentOwners.keys()].filter(relPath =>
385
- scan.publicOwners.has(relPath),
386
- );
387
- return conflicts.sort();
392
+ const owners = new Set<string>();
393
+ const conflicts = new Set<string>();
394
+ for (const outputs of scan.sourceOutputPaths.values()) {
395
+ for (const outputPath of outputs) {
396
+ if (owners.has(outputPath)) {
397
+ conflicts.add(outputPath);
398
+ }
399
+ owners.add(outputPath);
400
+ }
401
+ }
402
+ return [...conflicts].sort();
388
403
  }
@@ -53,7 +53,7 @@ export function getConfigFileName(name: ProjectConfigName): string | undefined {
53
53
  export function render(
54
54
  fileName: string,
55
55
  params?: Record<string, unknown> | null,
56
- ): string | undefined {
56
+ ): string {
57
57
  if (params != null) {
58
58
  // Allow the template to call render(), it will use our params
59
59
  params.render = (otherFileName: string) => render(otherFileName, params);
@@ -68,16 +68,17 @@ export function render(
68
68
  } catch (err) {
69
69
  if (errorStack == null) {
70
70
  errorStack = renderStack.slice();
71
-
72
- if (renderStack.length > 1) {
73
- throw err;
74
- }
75
- } else if (renderStack.length === 1) {
71
+ }
72
+ if (renderStack.length === 1) {
76
73
  const topItem = errorStack[errorStack.length - 1];
77
74
  throw new Error(`Render error in ${topItem}: ${err}`, { cause: err });
78
75
  }
76
+ throw err;
79
77
  } finally {
80
78
  renderStack.pop();
79
+ if (renderStack.length === 0) {
80
+ errorStack = null;
81
+ }
81
82
  }
82
83
  }
83
84
 
@@ -122,16 +122,7 @@ const JAVA_TYPE_DECLARATION_NODES = new Set([
122
122
  'recordDeclaration',
123
123
  ]);
124
124
 
125
- function extractJavaMethodMeta(
126
- methodNode: CstNode,
127
- requireBody = true,
128
- ): MethodMeta | null {
129
- const methodBody = methodNode.children?.methodBody?.[0];
130
- const hasBody = Boolean(methodBody?.children?.block?.length);
131
- if (requireBody && !hasBody) {
132
- return null;
133
- }
134
-
125
+ function extractJavaMethodMeta(methodNode: CstNode): MethodMeta | null {
135
126
  const methodHeader = methodNode.children?.methodHeader?.[0];
136
127
  const methodDeclarator = methodHeader?.children?.methodDeclarator?.[0];
137
128
  const identifier = methodDeclarator?.children?.Identifier?.[0];
@@ -293,16 +284,15 @@ export function extractJavaMethodToc(sourceCode: string): JavaTocEntry[] {
293
284
  return;
294
285
  }
295
286
 
296
- if (node.name === 'methodDeclaration' && typeDepth <= 1) {
287
+ if (
288
+ (node.name === 'methodDeclaration' ||
289
+ node.name === 'interfaceMethodDeclaration') &&
290
+ typeDepth <= 1
291
+ ) {
297
292
  const method = extractJavaMethodMeta(node);
298
293
  if (method) {
299
294
  callables.push({ ...method, kind: 'method' });
300
295
  }
301
- } else if (node.name === 'interfaceMethodDeclaration' && typeDepth <= 1) {
302
- const method = extractJavaMethodMeta(node, false);
303
- if (method) {
304
- callables.push({ ...method, kind: 'method' });
305
- }
306
296
  } else if (node.name === 'constructorDeclaration' && typeDepth === 1) {
307
297
  const constructor = extractJavaConstructorMeta(node);
308
298
  if (constructor) {
@@ -325,6 +315,13 @@ export function extractJavaMethodToc(sourceCode: string): JavaTocEntry[] {
325
315
  for (const value of Object.values(children)) {
326
316
  for (const child of value) {
327
317
  if (child && child.name) {
318
+ // Anonymous classes have a classBody without a type declaration.
319
+ if (
320
+ node.name === 'unqualifiedClassInstanceCreationExpression' &&
321
+ child.name === 'classBody'
322
+ ) {
323
+ continue;
324
+ }
328
325
  visit(child, nextTypeDepth);
329
326
  }
330
327
  }
@@ -244,7 +244,15 @@ public class LiterateRunner {
244
244
  case '\n' -> sb.append("\\n");
245
245
  case '\r' -> sb.append("\\r");
246
246
  case '\t' -> sb.append("\\t");
247
- default -> sb.append(c);
247
+ default -> {
248
+ if (c < 0x20) {
249
+ sb.append("\\u00");
250
+ sb.append(Character.forDigit(c >> 4, 16));
251
+ sb.append(Character.forDigit(c & 0xf, 16));
252
+ } else {
253
+ sb.append(c);
254
+ }
255
+ }
248
256
  }
249
257
  }
250
258
  sb.append('"');
@@ -70,6 +70,21 @@ function getInternalHrefTarget(href: string): string {
70
70
  }
71
71
  }
72
72
 
73
+ function validateDirectoryLink(
74
+ href: string,
75
+ resolvedTarget: string,
76
+ validTargets: Set<string>,
77
+ context: string,
78
+ ): string | null {
79
+ const indexPath = normalizeOutputPath(
80
+ path.posix.join(resolvedTarget, 'index.html'),
81
+ );
82
+ if (validTargets.has(indexPath)) {
83
+ return `${context}: directory link must reference index.html explicitly: "${href}" (resolved to "${resolvedTarget}", expected "${indexPath}")`;
84
+ }
85
+ return null;
86
+ }
87
+
73
88
  export function validateNavLinks(
74
89
  navData: unknown,
75
90
  validTargets: Set<string>,
@@ -96,8 +111,16 @@ export function validateNavLinks(
96
111
  continue;
97
112
  }
98
113
 
99
- const normalized = normalizeOutputPath(link.internal);
100
- if (!validTargets.has(normalized)) {
114
+ const normalized = getInternalHrefTarget(link.internal);
115
+ const directoryError = validateDirectoryLink(
116
+ link.internal,
117
+ normalized,
118
+ validTargets,
119
+ `${fileName}: internal link in section "${section.title}"`,
120
+ );
121
+ if (directoryError) {
122
+ errors.push(directoryError);
123
+ } else if (!validTargets.has(normalized)) {
101
124
  errors.push(
102
125
  `${fileName}: broken internal link in section "${section.title}": "${link.internal}"`,
103
126
  );
@@ -200,6 +223,16 @@ export function validateParentLink(
200
223
  return null;
201
224
  }
202
225
 
226
+ const directoryError = validateDirectoryLink(
227
+ parent as string,
228
+ resolvedTarget,
229
+ validTargets,
230
+ `${filePath}: parent link`,
231
+ );
232
+ if (directoryError) {
233
+ return directoryError;
234
+ }
235
+
203
236
  if (!validTargets.has(resolvedTarget)) {
204
237
  return `${filePath}: broken parent link: "${parent}"`;
205
238
  }
@@ -52,6 +52,7 @@ function toDiagnostics(error: unknown): { message: string }[] {
52
52
 
53
53
  export async function runWatchEngine(
54
54
  options: WatchEngineOptions,
55
+ dependencies = { watch: chokidar.watch, applyCommitPlan },
55
56
  ): Promise<void> {
56
57
  const debounceMs = options.debounceMs ?? 300;
57
58
  let snapshot: TadaSnapshot | undefined;
@@ -71,7 +72,24 @@ export async function runWatchEngine(
71
72
  async function runBuild(batch?: ChangeBatch): Promise<CompilerBuildResult> {
72
73
  await emit({ kind: 'build-started', batch });
73
74
  try {
74
- return await options.compiler.build(snapshot, batch);
75
+ const outcome = await options.compiler.build(snapshot, batch);
76
+ if (outcome.ok) {
77
+ try {
78
+ dependencies.applyCommitPlan(outcome.commit);
79
+ } catch (error) {
80
+ // The compiler may have mutated cached state before publication failed.
81
+ // Rebuild from sources on the next change instead of reusing that state.
82
+ snapshot = undefined;
83
+ return {
84
+ ok: false,
85
+ diagnostics: toDiagnostics(error).map(diagnostic => ({
86
+ message: `Failed to publish build output: ${diagnostic.message}`,
87
+ })),
88
+ };
89
+ }
90
+ snapshot = outcome.snapshot;
91
+ }
92
+ return outcome;
75
93
  } catch (error) {
76
94
  return { ok: false, diagnostics: toDiagnostics(error) };
77
95
  }
@@ -110,8 +128,6 @@ export async function runWatchEngine(
110
128
  return;
111
129
  }
112
130
 
113
- applyCommitPlan(outcome.commit);
114
- snapshot = outcome.snapshot;
115
131
  uncommitted = new Map();
116
132
  finalSuccess = { batch, result: outcome };
117
133
 
@@ -157,8 +173,6 @@ export async function runWatchEngine(
157
173
  diagnostics: startupOutcome.diagnostics,
158
174
  });
159
175
  } else {
160
- applyCommitPlan(startupOutcome.commit);
161
- snapshot = startupOutcome.snapshot;
162
176
  await emit({ kind: 'build-succeeded', meta: startupOutcome.meta });
163
177
  }
164
178
 
@@ -166,7 +180,7 @@ export async function runWatchEngine(
166
180
  .getWatchTargets()
167
181
  .map(target => ({
168
182
  target,
169
- watcher: chokidar.watch(target.path, {
183
+ watcher: dependencies.watch(target.path, {
170
184
  ignoreInitial: true,
171
185
  atomic: true,
172
186
  awaitWriteFinish: { stabilityThreshold: 100 },
@@ -4,7 +4,6 @@ import { globals, type Globals } from '../globals';
4
4
  import type {
5
5
  ApplyMutationsCommitPlan,
6
6
  CommitPlan,
7
- FileMutation,
8
7
  ReplaceRootCommitPlan,
9
8
  } from './types';
10
9
 
@@ -77,45 +76,111 @@ function pruneEmptyDirs(rootDir: string, filePath: string): void {
77
76
  }
78
77
  }
79
78
 
80
- function applyFileMutation(
81
- rootDir: string,
82
- mutation: FileMutation,
83
- stagedRoot: string,
84
- globals: CommitPlanGlobals,
85
- ): void {
86
- const targetPath = path.join(rootDir, mutation.path);
87
- const stagedPath = path.join(stagedRoot, mutation.path);
88
-
89
- if (mutation.kind === 'write') {
90
- ensureParentDir(targetPath);
91
- renameWithRetry(stagedPath, targetPath, globals);
92
- return;
93
- }
94
-
95
- fs.rmSync(targetPath, { force: true });
96
- pruneEmptyDirs(rootDir, targetPath);
79
+ interface MutationJournalEntry {
80
+ targetPath: string;
81
+ backupPath?: string;
97
82
  }
98
83
 
99
84
  function applyMutations(
100
85
  plan: ApplyMutationsCommitPlan,
101
86
  globals: CommitPlanGlobals,
102
87
  ): void {
103
- const stagedRoot = fs.mkdtempSync(path.join(plan.rootDir, '.watch-stage-'));
88
+ const transactionRoot = fs.mkdtempSync(
89
+ path.join(plan.rootDir, '.watch-stage-'),
90
+ );
91
+ const journal: MutationJournalEntry[] = [];
92
+ const createdDirs: string[] = [];
104
93
  try {
105
- for (const mutation of plan.mutations) {
94
+ // Index staging paths so file/directory transitions cannot collide in staging.
95
+ for (const [index, mutation] of plan.mutations.entries()) {
106
96
  if (mutation.kind === 'write') {
107
- const stagedPath = path.join(stagedRoot, mutation.path);
108
- ensureParentDir(stagedPath);
109
- fs.writeFileSync(stagedPath, mutation.content);
97
+ fs.writeFileSync(
98
+ path.join(transactionRoot, `write-${index}`),
99
+ mutation.content,
100
+ );
110
101
  }
111
102
  }
112
103
 
104
+ for (const [index, mutation] of plan.mutations.entries()) {
105
+ const targetPath = path.resolve(plan.rootDir, mutation.path);
106
+ const existing = fs.lstatSync(targetPath, { throwIfNoEntry: false });
107
+ if (existing?.isDirectory()) {
108
+ throw new Error(
109
+ `Cannot publish file mutation over directory: ${targetPath}`,
110
+ );
111
+ }
112
+ const backupPath = existing
113
+ ? path.join(transactionRoot, `backup-${index}`)
114
+ : undefined;
115
+ if (backupPath) {
116
+ renameWithRetry(targetPath, backupPath, globals);
117
+ }
118
+ journal.push({ targetPath, backupPath });
119
+ if (mutation.kind === 'write') {
120
+ // Track missing parents without relying on mkdir's returned path spelling.
121
+ let dir = path.dirname(targetPath);
122
+ while (!fs.existsSync(dir)) {
123
+ createdDirs.push(dir);
124
+ const parent = path.dirname(dir);
125
+ if (parent === dir) {
126
+ break;
127
+ }
128
+ dir = parent;
129
+ }
130
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
131
+ renameWithRetry(
132
+ path.join(transactionRoot, `write-${index}`),
133
+ targetPath,
134
+ globals,
135
+ );
136
+ }
137
+ }
113
138
  for (const mutation of plan.mutations) {
114
- applyFileMutation(plan.rootDir, mutation, stagedRoot, globals);
139
+ if (mutation.kind === 'delete') {
140
+ pruneEmptyDirs(plan.rootDir, path.resolve(plan.rootDir, mutation.path));
141
+ }
142
+ }
143
+ } catch (publicationError) {
144
+ const rollbackErrors: unknown[] = [];
145
+ const removeCreatedDirs = () => {
146
+ for (const dir of [...new Set(createdDirs)].sort(
147
+ (a, b) => b.length - a.length,
148
+ )) {
149
+ try {
150
+ fs.rmdirSync(dir);
151
+ } catch (error) {
152
+ const code = (error as NodeJS.ErrnoException).code;
153
+ if (code !== 'ENOENT' && code !== 'ENOTEMPTY' && code !== 'ENOTDIR') {
154
+ rollbackErrors.push(error);
155
+ }
156
+ }
157
+ }
158
+ };
159
+ for (const entry of journal.toReversed()) {
160
+ try {
161
+ removeCreatedDirs();
162
+ fs.rmSync(entry.targetPath, { force: true });
163
+ if (entry.backupPath) {
164
+ ensureParentDir(entry.targetPath);
165
+ renameWithRetry(entry.backupPath, entry.targetPath, globals);
166
+ }
167
+ } catch (error) {
168
+ rollbackErrors.push(error);
169
+ }
170
+ }
171
+ removeCreatedDirs();
172
+ if (rollbackErrors.length > 0) {
173
+ // Keep any unrestored originals available for manual recovery.
174
+ throw new AggregateError(
175
+ [publicationError, ...rollbackErrors],
176
+ `Failed to publish and restore output; recovery files remain in ${transactionRoot}`,
177
+ { cause: publicationError },
178
+ );
115
179
  }
116
- } finally {
117
- removeDirIfExists(stagedRoot);
180
+ removeDirIfExists(transactionRoot);
181
+ throw publicationError;
118
182
  }
183
+ removeDirIfExists(transactionRoot);
119
184
  }
120
185
 
121
186
  function replaceRoot(
@@ -1,3 +1,4 @@
1
+ import path from 'path';
1
2
  import { B } from '../colors';
2
3
  import { makeLogger } from '../log';
3
4
  import { config, getConfigFileName } from '../templates';
@@ -29,11 +30,17 @@ export function validateConfig(
29
30
  return diagnostics;
30
31
  }
31
32
  for (const relPath of conflicts) {
32
- log.error`content/${B`${relPath}`} conflicts with public/${B`${relPath}`}`;
33
+ const sources = [...scan.sourceOutputPaths]
34
+ .filter(([, outputs]) => outputs.has(relPath))
35
+ .map(([sourcePath]) =>
36
+ path.relative(path.dirname(scan.contentDir), sourcePath),
37
+ )
38
+ .sort();
39
+ log.error`${sources.join(' conflicts with ')}: same output path ${B`${relPath}`}`;
33
40
  }
34
41
  const noun = conflicts.length === 1 ? 'file' : 'files';
35
42
  diagnostics.push({
36
- message: `${conflicts.length} ${noun} in content/ and public/ have the same path`,
43
+ message: `${conflicts.length} output ${noun} ${conflicts.length === 1 ? 'has' : 'have'} multiple sources with the same path`,
37
44
  });
38
45
  return diagnostics;
39
46
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abreen/tada",
3
- "version": "1.17.0",
3
+ "version": "1.17.1",
4
4
  "type": "module",
5
5
  "description": "A static site generator",
6
6
  "license": "MIT",
@@ -100,7 +100,6 @@
100
100
  "devDependencies": {
101
101
  "@eslint/js": "^10.0.1",
102
102
  "@playwright/test": "^1.59.1",
103
- "@saintedlama/lcov-parse": "^1.0.1",
104
103
  "@types/bun": "^1.3.12",
105
104
  "@types/culori": "^4.0.1",
106
105
  "@types/d3-flextree": "^2.1.4",
@@ -28,7 +28,7 @@ function shouldIgnoreClick(
28
28
  return true;
29
29
  }
30
30
 
31
- if (anchor.target) {
31
+ if (anchor.target || anchor.hasAttribute('download')) {
32
32
  return true;
33
33
  }
34
34
 
@@ -68,7 +68,7 @@ export default function mountNavigate(window: Window): () => void {
68
68
  return;
69
69
  }
70
70
 
71
- // Same-page hash links: handle locally so we can clear search and
71
+ // Same-page links: handle locally so we can clear search and
72
72
  // close the header without a full SPA fetch.
73
73
  const url = new URL(anchor.href);
74
74
  if (
@@ -84,6 +84,17 @@ export default function mountNavigate(window: Window): () => void {
84
84
  } else {
85
85
  globals.setLocationHash(window, url.hash);
86
86
  }
87
+ } else {
88
+ saveScrollPosition(window);
89
+ if (window.location.hash) {
90
+ const state = window.history.state;
91
+ globals.setLocationHash(window, '');
92
+ // Native fragment clearing leaves a trailing #. Keep the authored
93
+ // URL and the current SPA history state on the new entry.
94
+ window.history.replaceState(state, '', url.href);
95
+ }
96
+ window.scrollTo({ top: 0 });
97
+ saveScrollPosition(window);
87
98
  }
88
99
  return;
89
100
  }
@@ -281,6 +281,7 @@ export default (window: Window) => {
281
281
 
282
282
  let pagefindLoadPromise: Promise<boolean> | null = null;
283
283
  let pagefindLoadGeneration = 0;
284
+ let disposed = false;
284
285
 
285
286
  function pagefindModulePath(generation: number) {
286
287
  const path = applyBasePath('/pagefind/pagefind.js');
@@ -291,6 +292,9 @@ export default (window: Window) => {
291
292
  }
292
293
 
293
294
  async function loadPagefind(): Promise<boolean> {
295
+ if (disposed) {
296
+ return false;
297
+ }
294
298
  if (pagefind) {
295
299
  return true;
296
300
  }
@@ -317,7 +321,18 @@ export default (window: Window) => {
317
321
  }
318
322
  pagefind = loadedPagefind;
319
323
  return true;
320
- })();
324
+ })().catch(err => {
325
+ if (generation === pagefindLoadGeneration) {
326
+ // Failed dynamic imports are cached by URL. Retry only on a later
327
+ // interaction, with a fresh URL, sharing one pending load.
328
+ pagefindLoadGeneration += 1;
329
+ console.log(`failed to load Pagefind: ${err}`);
330
+ if (state.showResults) {
331
+ render(input!, resultsContainer, state, false);
332
+ }
333
+ }
334
+ return false;
335
+ });
321
336
 
322
337
  const promise = pagefindLoadPromise;
323
338
  try {
@@ -343,6 +358,9 @@ export default (window: Window) => {
343
358
  render(input!, resultsContainer, state, true);
344
359
  }
345
360
  const query = state.value;
361
+ if (!(await loadPagefind()) || updateId !== latestUpdateId) {
362
+ return;
363
+ }
346
364
  const nextState = await doSearch(query, window);
347
365
  if (updateId !== latestUpdateId) {
348
366
  return;
@@ -370,7 +388,7 @@ export default (window: Window) => {
370
388
  function loadPagefindAndUpdate() {
371
389
  loadPagefind()
372
390
  .then(loadedCurrentPagefind => {
373
- if (loadedCurrentPagefind && state.showResults) {
391
+ if (loadedCurrentPagefind && !disposed && state.showResults) {
374
392
  queueUpdate();
375
393
  }
376
394
  })
@@ -601,6 +619,9 @@ export default (window: Window) => {
601
619
  input.disabled = false;
602
620
 
603
621
  return () => {
622
+ disposed = true;
623
+ pagefindLoadGeneration += 1;
624
+ invalidateUpdates();
604
625
  window.removeEventListener(
605
626
  PAGE_UPDATE_REFRESH_EVENT,
606
627
  handlePageUpdateRefresh,
@@ -4,6 +4,7 @@ import {
4
4
  normalizeHM,
5
5
  to12Hour,
6
6
  to12HourMarkup,
7
+ to24Hour,
7
8
  type PeriodStyle,
8
9
  } from './time-format';
9
10
 
@@ -149,6 +150,23 @@ export default (window: Window) => {
149
150
  periodStyles.set(el, detectPeriodStyle(el.textContent ?? ''));
150
151
  });
151
152
  const pagePeriodStyle = dominantPeriodStyle(periodStyles);
153
+ const twentyFourHourTimes = new Set<HTMLTimeElement>();
154
+ for (const [el, style] of periodStyles) {
155
+ const rangeEnd = getRangeEnd(el);
156
+ const inheritsPeriod =
157
+ rangeEnd !== null && periodStyles.get(rangeEnd) != null;
158
+ const authoredTime = (el.textContent ?? '').trim();
159
+ // A periodless 12-hour prefix may inherit its range's final AM/PM.
160
+ // Otherwise text matching datetime is an authored 24-hour time.
161
+ if (
162
+ style === null &&
163
+ !inheritsPeriod &&
164
+ /^\d{1,2}:\d{2}$/.test(authoredTime) &&
165
+ parseHHMM(authoredTime) === parseHHMM(el.getAttribute('datetime') ?? '')
166
+ ) {
167
+ twentyFourHourTimes.add(el);
168
+ }
169
+ }
152
170
 
153
171
  function updateTimes(targetTz: string) {
154
172
  const target = timezones.find(t => t.value === targetTz) || defaultTz;
@@ -187,11 +205,12 @@ export default (window: Window) => {
187
205
  }
188
206
  }
189
207
 
208
+ const is24Hour = twentyFourHourTimes.has(el);
190
209
  const originalStyle = periodStyles.get(el) ?? null;
191
210
  const originalIsPm = Math.floor(baseMinutes / 60) >= 12;
192
211
  const periodChanged = originalIsPm !== h >= 12 || dayShift !== 0;
193
212
  let style =
194
- originalStyle === null && periodChanged
213
+ !is24Hour && originalStyle === null && periodChanged
195
214
  ? pagePeriodStyle
196
215
  : originalStyle;
197
216
 
@@ -201,6 +220,7 @@ export default (window: Window) => {
201
220
  ? parseHHMM(rangeEndDatetime)
202
221
  : NaN;
203
222
  if (
223
+ !is24Hour &&
204
224
  originalStyle === null &&
205
225
  rangeEnd !== null &&
206
226
  !isNaN(rangeEndMinutes)
@@ -216,14 +236,20 @@ export default (window: Window) => {
216
236
  isDefault || style === null || rangeEnd !== null
217
237
  ? ''
218
238
  : ` <span class="small-caps">${target.abbreviation}</span>`;
219
- el.innerHTML = to12HourMarkup(h, m, style) + timeZone + suffix;
239
+ el.innerHTML =
240
+ (is24Hour ? to24Hour(h, m) : to12HourMarkup(h, m, style)) +
241
+ timeZone +
242
+ suffix;
220
243
 
221
244
  if (isDefault) {
222
245
  el.classList.remove('is-modified');
223
246
  el.title = '';
224
247
  } else {
225
248
  el.classList.add('is-modified');
226
- el.title = `${to12Hour(...normalizeHM(baseMinutes), style ?? pagePeriodStyle)} ${defaultTz.abbreviation}`;
249
+ const originalTime = is24Hour
250
+ ? to24Hour(...normalizeHM(baseMinutes))
251
+ : to12Hour(...normalizeHM(baseMinutes), style ?? pagePeriodStyle);
252
+ el.title = `${originalTime} ${defaultTz.abbreviation}`;
227
253
  }
228
254
  });
229
255
  }
@@ -22,6 +22,10 @@ function pad(n: number | string) {
22
22
  return String(n).padStart(2, '0');
23
23
  }
24
24
 
25
+ export function to24Hour(h: number, m: number) {
26
+ return `${pad(h)}:${pad(m)}`;
27
+ }
28
+
25
29
  export function to12Hour(
26
30
  h: number,
27
31
  m: number,
package/src/toc/index.ts CHANGED
@@ -123,11 +123,12 @@ export default (window: Window) => {
123
123
  }
124
124
 
125
125
  let best = 0;
126
+ let bestLine = -1;
127
+ // TOC display groups (such as fields and methods) are not source order.
126
128
  for (let i = 0; i < codeLines.length; i++) {
127
- if (codeLines[i] <= line) {
129
+ if (codeLines[i] <= line && codeLines[i] >= bestLine) {
128
130
  best = i;
129
- } else {
130
- break;
131
+ bestLine = codeLines[i];
131
132
  }
132
133
  }
133
134
 
@@ -5,6 +5,7 @@ interface WidgetState {
5
5
  manifest: TraceManifest;
6
6
  chunks: Map<number, TraceChunkEntry[]>;
7
7
  currentStep: number;
8
+ navigationRequest: number;
8
9
  }
9
10
 
10
11
  interface TraceResizeState {
@@ -54,6 +55,7 @@ async function goToStep(
54
55
  stepIndex: number,
55
56
  doc: Document,
56
57
  ): Promise<void> {
58
+ const request = ++state.navigationRequest;
57
59
  stepIndex = Math.max(0, Math.min(stepIndex, state.manifest.totalSteps - 1));
58
60
  const lastChunk = Math.floor(stepIndex / state.manifest.chunkSize);
59
61
  const loads: Promise<void>[] = [];
@@ -61,6 +63,9 @@ async function goToStep(
61
63
  loads.push(loadChunk(state, manifestUrl, i));
62
64
  }
63
65
  await Promise.all(loads);
66
+ if (request !== state.navigationRequest) {
67
+ return;
68
+ }
64
69
  state.currentStep = stepIndex;
65
70
  renderWidgetState(state, elements, doc);
66
71
  }
@@ -492,7 +497,12 @@ async function initWidget(root: HTMLElement, doc: Document): Promise<void> {
492
497
  }
493
498
  const manifest: TraceManifest = await res.json();
494
499
 
495
- const state: WidgetState = { manifest, chunks: new Map(), currentStep: 0 };
500
+ const state: WidgetState = {
501
+ manifest,
502
+ chunks: new Map(),
503
+ currentStep: 0,
504
+ navigationRequest: 0,
505
+ };
496
506
 
497
507
  await loadChunk(state, manifestUrl, 0);
498
508