@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 +4 -1
- package/bin/tada.ts +7 -2
- package/bin/validators.ts +1 -1
- package/build/build-manifest.ts +1 -1
- package/build/columns-plugin.ts +27 -1
- package/build/deflist-id-plugin.ts +2 -2
- package/build/source-model.ts +19 -4
- package/build/templates.ts +7 -6
- package/build/utils/code.ts +13 -16
- package/build/utils/jdi-runner/LiterateRunner.java +9 -1
- package/build/validate-config-links.ts +35 -2
- package/build/watch/engine.ts +20 -6
- package/build/watch/fs-commit.ts +91 -26
- package/build/watch/validation.ts +9 -2
- package/package.json +1 -2
- package/src/navigate/index.ts +13 -2
- package/src/search/index.ts +23 -2
- package/src/timezone/index.ts +29 -3
- package/src/timezone/time-format.ts +4 -0
- package/src/toc/index.ts +4 -3
- package/src/trace/index.ts +11 -1
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
|
-
|
|
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
|
-
|
|
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
package/build/build-manifest.ts
CHANGED
package/build/columns-plugin.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import
|
|
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
|
}
|
package/build/source-model.ts
CHANGED
|
@@ -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
|
|
385
|
-
|
|
386
|
-
)
|
|
387
|
-
|
|
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
|
}
|
package/build/templates.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
package/build/utils/code.ts
CHANGED
|
@@ -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 (
|
|
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 ->
|
|
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 =
|
|
100
|
-
|
|
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
|
}
|
package/build/watch/engine.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
183
|
+
watcher: dependencies.watch(target.path, {
|
|
170
184
|
ignoreInitial: true,
|
|
171
185
|
atomic: true,
|
|
172
186
|
awaitWriteFinish: { stabilityThreshold: 100 },
|
package/build/watch/fs-commit.ts
CHANGED
|
@@ -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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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}
|
|
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.
|
|
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",
|
package/src/navigate/index.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/search/index.ts
CHANGED
|
@@ -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,
|
package/src/timezone/index.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
}
|
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
|
-
|
|
130
|
-
break;
|
|
131
|
+
bestLine = codeLines[i];
|
|
131
132
|
}
|
|
132
133
|
}
|
|
133
134
|
|
package/src/trace/index.ts
CHANGED
|
@@ -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 = {
|
|
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
|
|