@abreen/tada 1.4.1 → 1.5.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 -2
- package/bin/tada.ts +81 -57
- package/build/apply-base-path-plugin.ts +22 -6
- package/build/code.test.ts +108 -1
- package/build/colors.test.ts +66 -0
- package/build/columns-plugin.ts +116 -0
- package/build/content-watch.ts +30 -21
- package/build/copy.ts +1 -1
- package/build/external-links-plugin.ts +1 -1
- package/build/generate-content-assets.ts +48 -6
- package/build/generate-favicon.ts +1 -1
- package/build/generate-fonts.ts +1 -1
- package/build/generate-katex-assets.ts +1 -1
- package/build/log.test.ts +7 -1
- package/build/log.ts +4 -1
- package/build/markdown-plugins.test.ts +65 -3
- package/build/pagefind.ts +1 -1
- package/build/pipeline.ts +4 -20
- package/build/serve.test.ts +59 -0
- package/build/serve.ts +29 -63
- package/build/templates.ts +2 -2
- package/build/toc-plugin.test.ts +40 -25
- package/build/toc-plugin.ts +9 -2
- package/build/{types.ts → types.d.ts} +88 -1
- package/build/util.ts +1 -1
- package/build/utils/code.ts +76 -3
- package/build/utils/content-files.test.ts +56 -0
- package/build/utils/content-files.ts +15 -11
- package/build/utils/file-types.test.ts +18 -0
- package/build/utils/file-types.ts +4 -0
- package/build/utils/include.test.ts +117 -0
- package/build/utils/include.ts +53 -0
- package/build/utils/jdi-runner/TraceRunner.java +416 -0
- package/build/utils/literate-java.ts +2 -2
- package/build/utils/markdown.ts +32 -14
- package/build/utils/paths.ts +1 -1
- package/build/utils/render.ts +102 -11
- package/build/utils/shiki-highlighter.ts +1 -1
- package/build/utils/trace-layout.test.ts +570 -0
- package/build/utils/trace-layout.ts +848 -0
- package/build/utils/trace-svg.test.ts +425 -0
- package/build/utils/trace-svg.ts +905 -0
- package/build/utils/trace.test.ts +169 -0
- package/build/utils/trace.ts +382 -0
- package/build/validate-internal-links-plugin.test.ts +30 -2
- package/build/validate-internal-links-plugin.ts +3 -3
- package/build/watch-reload-client.ts +4 -20
- package/build/watch.ts +436 -448
- package/init/content/index.md +10 -5
- package/init/content/labs/00/index.md +1 -1
- package/init/content/labs/01/SearchTreeDemo.java +31 -0
- package/init/content/labs/01/TraceDemo.java +22 -0
- package/init/content/labs/01/index.md +27 -0
- package/init/content/labs/index.md +2 -0
- package/init/content/lectures/01/Rectangle.java +2 -0
- package/init/content/lectures/01/index.md +2 -1
- package/init/content/lectures/01/rectangle.py +45 -0
- package/init/content/lectures/02/_pr1.md +5 -0
- package/init/content/lectures/02/index.md +11 -0
- package/init/content/lectures/02/subdir/_foobar.html +3 -0
- package/init/content/lectures/02/subdir/_pr2.md +6 -0
- package/init/content/lectures/index.md +3 -0
- package/init/content/markdown.md +30 -0
- package/package.json +21 -4
- package/src/_base.scss +13 -2
- package/src/_layout.scss +6 -5
- package/src/anchor/index.test.ts +64 -0
- package/src/code/index.test.ts +71 -0
- package/src/code/style.scss +23 -0
- package/src/code.scss +5 -3
- package/src/header/_base.scss +48 -9
- package/src/header/index.test.ts +89 -0
- package/src/header/index.ts +1 -1
- package/src/header/style.scss +1 -5
- package/src/index.ts +18 -20
- package/src/literate/style.scss +15 -7
- package/src/navigate/eligible.test.ts +101 -0
- package/src/navigate/eligible.ts +62 -0
- package/src/navigate/index.test.ts +82 -0
- package/src/navigate/index.ts +365 -0
- package/src/navigate/lifecycle.test.ts +26 -0
- package/src/navigate/lifecycle.ts +65 -0
- package/src/navigate/style.scss +61 -0
- package/src/print/index.test.ts +71 -0
- package/src/question/index.test.ts +82 -0
- package/src/question/style.scss +12 -0
- package/src/search/index.ts +38 -14
- package/src/search/style.scss +23 -14
- package/src/style.scss +25 -12
- package/src/timezone/index.test.ts +301 -1
- package/src/timezone/index.ts +68 -56
- package/src/toc/index.test.ts +237 -0
- package/src/toc/index.ts +29 -69
- package/src/toc/style.scss +17 -0
- package/src/top/index.test.ts +46 -0
- package/src/top/index.ts +2 -2
- package/src/trace/index.test.ts +554 -0
- package/src/trace/index.ts +258 -0
- package/src/trace/style.scss +210 -0
- package/src/trace/types.d.ts +47 -0
- package/src/util.test.ts +135 -0
- package/templates/_top.html +23 -22
- package/types/jsdom.d.ts +9 -0
package/build/toc-plugin.ts
CHANGED
|
@@ -17,6 +17,7 @@ interface AlertItem {
|
|
|
17
17
|
kind: 'alert';
|
|
18
18
|
type: string;
|
|
19
19
|
title: string;
|
|
20
|
+
id?: string;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
type TocItem = HeadingItem | DinkusItem | AlertItem;
|
|
@@ -103,12 +104,16 @@ export function tocPlugin(md: MarkdownIt): void {
|
|
|
103
104
|
});
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
export function generateTocHtml(
|
|
107
|
+
export function generateTocHtml(
|
|
108
|
+
tocItems: TocItem[],
|
|
109
|
+
alertIds: string[],
|
|
110
|
+
): string {
|
|
107
111
|
if (!tocItems || tocItems.length === 0) {
|
|
108
112
|
return '';
|
|
109
113
|
}
|
|
110
114
|
|
|
111
115
|
let lastHeadingLevel = 1;
|
|
116
|
+
let alertIdx = 0;
|
|
112
117
|
const parts = ['<ol>'];
|
|
113
118
|
|
|
114
119
|
for (const item of tocItems) {
|
|
@@ -128,9 +133,11 @@ export function generateTocHtml(tocItems: TocItem[]): string {
|
|
|
128
133
|
|
|
129
134
|
if (item.kind === 'alert') {
|
|
130
135
|
const level = lastHeadingLevel + 1;
|
|
136
|
+
const id = alertIds[alertIdx++];
|
|
137
|
+
const href = `#${id}`;
|
|
131
138
|
parts.push(
|
|
132
139
|
`<li class="alert-item level${level} ${item.type}">` +
|
|
133
|
-
`<a href="
|
|
140
|
+
`<a href="${href}">${item.title}</a></li>`,
|
|
134
141
|
);
|
|
135
142
|
}
|
|
136
143
|
}
|
|
@@ -40,7 +40,8 @@ export interface ContentRenderOptions {
|
|
|
40
40
|
/** State passed from watch mode to control incremental rebuilds */
|
|
41
41
|
export interface WatchState {
|
|
42
42
|
changedContentFiles?: Set<string>;
|
|
43
|
-
|
|
43
|
+
jsonDataChanged?: boolean;
|
|
44
|
+
partialsChanged?: boolean;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
/** Result from ContentRenderer.processContent() */
|
|
@@ -78,6 +79,17 @@ export interface RenderPlainTextOptions {
|
|
|
78
79
|
siteVariables: SiteVariables;
|
|
79
80
|
validInternalTargets: Set<string>;
|
|
80
81
|
assetFiles: string[];
|
|
82
|
+
literateJavaOutputPaths?: Set<string>;
|
|
83
|
+
traceCache?: Map<
|
|
84
|
+
string,
|
|
85
|
+
{
|
|
86
|
+
manifestUrl: string;
|
|
87
|
+
highlightedSource: string;
|
|
88
|
+
totalSteps: number;
|
|
89
|
+
mtime: number;
|
|
90
|
+
}
|
|
91
|
+
>;
|
|
92
|
+
javacAvailable?: boolean;
|
|
81
93
|
}
|
|
82
94
|
|
|
83
95
|
/** Options for code page asset rendering */
|
|
@@ -97,12 +109,15 @@ export interface RenderLiterateJavaOptions {
|
|
|
97
109
|
siteVariables: SiteVariables;
|
|
98
110
|
assetFiles: string[];
|
|
99
111
|
skipExecution?: boolean;
|
|
112
|
+
validInternalTargets: Set<string>;
|
|
113
|
+
literateJavaOutputPaths?: Set<string>;
|
|
100
114
|
}
|
|
101
115
|
|
|
102
116
|
/** Options for copied content asset rendering */
|
|
103
117
|
export interface RenderCopiedContentOptions {
|
|
104
118
|
filePath: string;
|
|
105
119
|
contentDir: string;
|
|
120
|
+
siteVariables: SiteVariables;
|
|
106
121
|
}
|
|
107
122
|
|
|
108
123
|
/** Front matter parse result (from parseFrontMatterAndContent) */
|
|
@@ -166,6 +181,78 @@ export interface LiterateRunnerEntry {
|
|
|
166
181
|
output: string;
|
|
167
182
|
}
|
|
168
183
|
|
|
184
|
+
/** A single execution step captured by TraceRunner */
|
|
185
|
+
export interface TraceStep {
|
|
186
|
+
line: number;
|
|
187
|
+
file: string;
|
|
188
|
+
stack: TraceStackFrame[];
|
|
189
|
+
heap: Record<string, TraceHeapObject>;
|
|
190
|
+
stdout: string;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface TraceStackFrame {
|
|
194
|
+
method: string;
|
|
195
|
+
class: string;
|
|
196
|
+
line?: number;
|
|
197
|
+
locals: Record<string, TraceValue>;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export type TraceValue =
|
|
201
|
+
| {
|
|
202
|
+
type: 'int' | 'long' | 'short' | 'byte' | 'float' | 'double';
|
|
203
|
+
value: number;
|
|
204
|
+
}
|
|
205
|
+
| { type: 'boolean'; value: boolean }
|
|
206
|
+
| { type: 'char' | 'String'; value: string }
|
|
207
|
+
| { type: 'null' }
|
|
208
|
+
| { type: 'ref'; id: string }
|
|
209
|
+
| { type: 'unknown' }
|
|
210
|
+
| { type: 'uninitialized' }
|
|
211
|
+
| { type: 'truncated'; remaining: number };
|
|
212
|
+
|
|
213
|
+
export type TraceHeapObject =
|
|
214
|
+
| { type: string; elements: TraceValue[] }
|
|
215
|
+
| { type: string; fields: Record<string, TraceValue> }
|
|
216
|
+
| { type: string; value: string };
|
|
217
|
+
|
|
218
|
+
/** Manifest written alongside trace chunks */
|
|
219
|
+
export interface TraceManifest {
|
|
220
|
+
totalSteps: number;
|
|
221
|
+
chunkSize: number;
|
|
222
|
+
sourceFile: string;
|
|
223
|
+
source: string;
|
|
224
|
+
lineToSteps: Record<number, number[]>;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Position and size of a heap object in the precomputed layout. */
|
|
228
|
+
export interface TraceObjectLayout {
|
|
229
|
+
x: number;
|
|
230
|
+
y: number;
|
|
231
|
+
width: number;
|
|
232
|
+
height: number;
|
|
233
|
+
/** For field objects, x-offset where value boxes start. */
|
|
234
|
+
valueX?: number;
|
|
235
|
+
/** For arrays, width of each cell. */
|
|
236
|
+
cellWidth?: number;
|
|
237
|
+
/** Field names that are structural children in the tree layout. */
|
|
238
|
+
childFields?: string[];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** A precomputed layout for the entire trace (all steps). */
|
|
242
|
+
export interface TraceLayout {
|
|
243
|
+
/** Stable position for every heap object that ever exists. */
|
|
244
|
+
objects: Record<string, TraceObjectLayout>;
|
|
245
|
+
/** Fields to ignore for layout on each class (from @trace-ignore). */
|
|
246
|
+
ignoreFields: Record<string, string[]>;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** A single entry in a chunk file (new format with precomputed SVG). */
|
|
250
|
+
export interface TraceChunkEntry {
|
|
251
|
+
line: number;
|
|
252
|
+
stdout: string;
|
|
253
|
+
svg: string;
|
|
254
|
+
}
|
|
255
|
+
|
|
169
256
|
/** Site config creation input (used by bin/validators) */
|
|
170
257
|
export interface SiteConfigInput {
|
|
171
258
|
title: string;
|
package/build/util.ts
CHANGED
|
@@ -5,7 +5,7 @@ export {
|
|
|
5
5
|
getValidInternalTargets,
|
|
6
6
|
shouldSkipContentFile,
|
|
7
7
|
} from './utils/content-files';
|
|
8
|
-
export { extensionIsMarkdown } from './utils/file-types';
|
|
8
|
+
export { extensionIsMarkdown, isPartial } from './utils/file-types';
|
|
9
9
|
export { createMarkdown } from './utils/markdown';
|
|
10
10
|
export {
|
|
11
11
|
createApplyBasePath,
|
package/build/utils/code.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import MarkdownIt from 'markdown-it';
|
|
2
|
+
import path from 'path';
|
|
2
3
|
import { parse as parseJava } from 'java-parser';
|
|
3
4
|
import { JSDOM } from 'jsdom';
|
|
4
5
|
import { makeLogger } from '../log';
|
|
5
6
|
import { getHighlighter } from './shiki-highlighter';
|
|
6
7
|
import externalLinksPlugin from '../external-links-plugin';
|
|
7
8
|
import applyBasePathPlugin from '../apply-base-path-plugin';
|
|
9
|
+
import { createApplyBasePath } from './paths';
|
|
8
10
|
import type { JavaTocEntry, SiteVariables } from '../types';
|
|
9
11
|
|
|
10
12
|
interface CstNode {
|
|
@@ -32,7 +34,7 @@ interface CodeSegment {
|
|
|
32
34
|
startLine: number;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
const log = makeLogger(
|
|
37
|
+
const log = makeLogger(import.meta.url);
|
|
36
38
|
|
|
37
39
|
const PROSE_LINE = /^\s*\/\/\/(\s|$)/;
|
|
38
40
|
|
|
@@ -45,6 +47,69 @@ function createCodeMarkdown(
|
|
|
45
47
|
.use(applyBasePathPlugin, siteVariables, options);
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
// Matches Markdown links: [text](url)
|
|
51
|
+
const MARKDOWN_LINK = /\[([^\]]*)\]\(([^)]+)\)/g;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Rewrites Markdown links in raw `///` comment lines so that relative and
|
|
55
|
+
* absolute paths become full URLs using `base + basePath`.
|
|
56
|
+
*/
|
|
57
|
+
export function rewriteProseLinks(
|
|
58
|
+
lines: string[],
|
|
59
|
+
siteVariables: SiteVariables,
|
|
60
|
+
pageDirPath: string,
|
|
61
|
+
): string[] {
|
|
62
|
+
const applyBasePath = createApplyBasePath(siteVariables);
|
|
63
|
+
const codeExtensions = Object.keys(siteVariables.codeLanguages ?? {});
|
|
64
|
+
|
|
65
|
+
function rewriteCodeExt(p: string): string {
|
|
66
|
+
for (const ext of codeExtensions) {
|
|
67
|
+
if (p.endsWith(`.${ext}`)) {
|
|
68
|
+
return p + '.html';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return p;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function rewriteHref(href: string): string {
|
|
75
|
+
// Separate pathname from query/fragment
|
|
76
|
+
const match = href.match(/^([^?#]*)(.*)$/);
|
|
77
|
+
const pathname = match ? match[1] : href;
|
|
78
|
+
const suffix = match ? match[2] : '';
|
|
79
|
+
|
|
80
|
+
// Leave external, protocol-relative, and anchor-only links unchanged
|
|
81
|
+
if (
|
|
82
|
+
/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(href) ||
|
|
83
|
+
href.startsWith('//') ||
|
|
84
|
+
href.startsWith('#')
|
|
85
|
+
) {
|
|
86
|
+
return href;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (pathname.startsWith('/')) {
|
|
90
|
+
// Absolute path: apply code ext rewriting, then base + basePath
|
|
91
|
+
return (
|
|
92
|
+
siteVariables.base + applyBasePath(rewriteCodeExt(pathname)) + suffix
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Relative path: resolve against page directory, then base + basePath
|
|
97
|
+
const resolved = path.posix.normalize(`/${pageDirPath}/${pathname}`);
|
|
98
|
+
return (
|
|
99
|
+
siteVariables.base + applyBasePath(rewriteCodeExt(resolved)) + suffix
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return lines.map(line => {
|
|
104
|
+
if (!PROSE_LINE.test(line)) {
|
|
105
|
+
return line;
|
|
106
|
+
}
|
|
107
|
+
return line.replace(MARKDOWN_LINK, (whole, text, href) => {
|
|
108
|
+
return `[${text}](${rewriteHref(href)})`;
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
48
113
|
const KIND_LABELS: Record<string, string> = {
|
|
49
114
|
constructor: 'Constructor',
|
|
50
115
|
field: 'Field',
|
|
@@ -403,6 +468,7 @@ export function renderCodeSegment(
|
|
|
403
468
|
lines: string[],
|
|
404
469
|
startLine: number,
|
|
405
470
|
lang: string,
|
|
471
|
+
{ linkLineNumbers = true }: { linkLineNumbers?: boolean } = {},
|
|
406
472
|
): string {
|
|
407
473
|
const source = lines.join('\n');
|
|
408
474
|
let lineHtml: string[] | undefined;
|
|
@@ -427,7 +493,10 @@ export function renderCodeSegment(
|
|
|
427
493
|
|
|
428
494
|
const rows = lineHtml.map((line, i) => {
|
|
429
495
|
const lineNumber = startLine + i;
|
|
430
|
-
|
|
496
|
+
const lineNumEl = linkLineNumbers
|
|
497
|
+
? `<a class="line-number" data-pagefind-ignore tabindex="-1" id="L${lineNumber}" href="#L${lineNumber}">${lineNumber}</a>`
|
|
498
|
+
: `<span class="line-number" data-pagefind-ignore data-line="${lineNumber}">${lineNumber}</span>`;
|
|
499
|
+
return `<span class="code-row">${lineNumEl}<code class="shiki language-${lang}">${line}</code></span>`;
|
|
431
500
|
});
|
|
432
501
|
|
|
433
502
|
return `<pre>${rows.join('')}</pre>`;
|
|
@@ -437,6 +506,7 @@ export function renderCodeWithComments(
|
|
|
437
506
|
sourceCode: string,
|
|
438
507
|
lang: string,
|
|
439
508
|
siteVariables: SiteVariables,
|
|
509
|
+
pageDirPath?: string,
|
|
440
510
|
): string {
|
|
441
511
|
const md = createCodeMarkdown(siteVariables);
|
|
442
512
|
const lines = sourceCode.split('\n');
|
|
@@ -490,7 +560,10 @@ export function renderCodeWithComments(
|
|
|
490
560
|
const prose = segment.lines
|
|
491
561
|
.map(line => line.replace(/^\s*\/\/\/(\s?)/, ''))
|
|
492
562
|
.join('\n');
|
|
493
|
-
const
|
|
563
|
+
const rewrittenLines = pageDirPath
|
|
564
|
+
? rewriteProseLinks(segment.lines, siteVariables, pageDirPath)
|
|
565
|
+
: segment.lines;
|
|
566
|
+
const source = escapeAttr(rewrittenLines.join('\n'));
|
|
494
567
|
return `<div class="code-prose" data-prose-source="${source}" style="--prose-indent: ${indent}ch"><div class="code-prose-gutter"></div><div class="code-prose-content">${md.render(prose)}</div></div>`;
|
|
495
568
|
}
|
|
496
569
|
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { getValidInternalTargets } from './content-files';
|
|
6
|
+
|
|
7
|
+
describe('getValidInternalTargets', () => {
|
|
8
|
+
let tempDir: string;
|
|
9
|
+
let originalCwd: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tada-targets-test-'));
|
|
13
|
+
fs.mkdirSync(path.join(tempDir, 'content'));
|
|
14
|
+
fs.mkdirSync(path.join(tempDir, 'public'), { recursive: true });
|
|
15
|
+
originalCwd = process.cwd();
|
|
16
|
+
process.chdir(tempDir);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
process.chdir(originalCwd);
|
|
21
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('includes public files as valid targets', () => {
|
|
25
|
+
fs.writeFileSync(path.join(tempDir, 'public', 'test.txt'), '');
|
|
26
|
+
|
|
27
|
+
const contentDir = path.join(tempDir, 'content');
|
|
28
|
+
const targets = getValidInternalTargets(contentDir, [], [], false);
|
|
29
|
+
|
|
30
|
+
expect(targets.has('/test.txt')).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('includes public index.html by exact path only', () => {
|
|
34
|
+
const coverageDir = path.join(tempDir, 'public', 'coverage');
|
|
35
|
+
fs.mkdirSync(coverageDir, { recursive: true });
|
|
36
|
+
fs.writeFileSync(path.join(coverageDir, 'index.html'), '');
|
|
37
|
+
|
|
38
|
+
const contentDir = path.join(tempDir, 'content');
|
|
39
|
+
const targets = getValidInternalTargets(contentDir, [], [], false);
|
|
40
|
+
|
|
41
|
+
expect(targets.has('/coverage/index.html')).toBe(true);
|
|
42
|
+
expect(targets.has('/coverage/')).toBe(false);
|
|
43
|
+
expect(targets.has('/coverage')).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('includes nested public files', () => {
|
|
47
|
+
const subDir = path.join(tempDir, 'public', 'assets');
|
|
48
|
+
fs.mkdirSync(subDir, { recursive: true });
|
|
49
|
+
fs.writeFileSync(path.join(subDir, 'style.css'), '');
|
|
50
|
+
|
|
51
|
+
const contentDir = path.join(tempDir, 'content');
|
|
52
|
+
const targets = getValidInternalTargets(contentDir, [], [], false);
|
|
53
|
+
|
|
54
|
+
expect(targets.has('/assets/style.css')).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
getProcessedExtensions,
|
|
6
6
|
extensionIsMarkdown,
|
|
7
7
|
isLiterateJava,
|
|
8
|
+
isPartial,
|
|
8
9
|
} from './file-types';
|
|
9
10
|
import { getPublicDir, normalizeOutputPath } from './paths';
|
|
10
11
|
|
|
@@ -46,7 +47,7 @@ export function getBuildContentFiles(
|
|
|
46
47
|
codeExtensions: string[],
|
|
47
48
|
): string[] {
|
|
48
49
|
return getContentFiles(contentDir, codeExtensions).filter(
|
|
49
|
-
filePath => !shouldSkipContentFile(filePath),
|
|
50
|
+
filePath => !isPartial(filePath) && !shouldSkipContentFile(filePath),
|
|
50
51
|
);
|
|
51
52
|
}
|
|
52
53
|
|
|
@@ -96,6 +97,7 @@ export function getValidInternalTargets(
|
|
|
96
97
|
contentDir: string,
|
|
97
98
|
contentFiles: string[],
|
|
98
99
|
codeExtensions: string[],
|
|
100
|
+
codeEnabled: boolean,
|
|
99
101
|
): Set<string> {
|
|
100
102
|
const targets = new Set<string>();
|
|
101
103
|
const codeExtensionSet = new Set(
|
|
@@ -103,6 +105,9 @@ export function getValidInternalTargets(
|
|
|
103
105
|
);
|
|
104
106
|
|
|
105
107
|
for (const filePath of contentFiles) {
|
|
108
|
+
if (isPartial(filePath)) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
106
111
|
const relPath = path.relative(contentDir, filePath);
|
|
107
112
|
const parsed = path.parse(relPath);
|
|
108
113
|
const ext = parsed.ext.toLowerCase();
|
|
@@ -112,21 +117,20 @@ export function getValidInternalTargets(
|
|
|
112
117
|
.join(path.posix.sep);
|
|
113
118
|
|
|
114
119
|
if (isLiterateJava(filePath)) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
120
|
+
// parsed.name is e.g. "VowelCounter.java"; the output page is
|
|
121
|
+
// VowelCounter.java.html and the source file is VowelCounter.java
|
|
122
|
+
const javaSubPath = path
|
|
123
|
+
.join(parsed.dir, parsed.name)
|
|
118
124
|
.split(path.sep)
|
|
119
125
|
.join(path.posix.sep);
|
|
120
|
-
addGeneratedRouteAliases(targets, `/${
|
|
121
|
-
targets.add(
|
|
122
|
-
normalizeOutputPath(
|
|
123
|
-
`/${path.join(parsed.dir, parsed.name).split(path.sep).join(path.posix.sep)}`,
|
|
124
|
-
),
|
|
125
|
-
);
|
|
126
|
+
addGeneratedRouteAliases(targets, `/${javaSubPath}.html`);
|
|
127
|
+
targets.add(normalizeOutputPath(`/${javaSubPath}`));
|
|
126
128
|
} else if (extensionIsMarkdown(ext) || ext === '.html') {
|
|
127
129
|
addGeneratedRouteAliases(targets, `/${subPath}.html`);
|
|
128
130
|
} else if (codeExtensionSet.has(ext.slice(1))) {
|
|
129
|
-
|
|
131
|
+
if (codeEnabled) {
|
|
132
|
+
addGeneratedRouteAliases(targets, `/${relPath}.html`);
|
|
133
|
+
}
|
|
130
134
|
targets.add(normalizeOutputPath(`/${relPath}`));
|
|
131
135
|
}
|
|
132
136
|
}
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
getProcessedExtensions,
|
|
4
4
|
extensionIsMarkdown,
|
|
5
5
|
isLiterateJava,
|
|
6
|
+
isPartial,
|
|
6
7
|
} from './file-types';
|
|
7
8
|
|
|
8
9
|
describe('getProcessedExtensions', () => {
|
|
@@ -38,6 +39,23 @@ describe('extensionIsMarkdown', () => {
|
|
|
38
39
|
});
|
|
39
40
|
});
|
|
40
41
|
|
|
42
|
+
describe('isPartial', () => {
|
|
43
|
+
test('returns true for files starting with _', () => {
|
|
44
|
+
expect(isPartial('_foo.md')).toBe(true);
|
|
45
|
+
expect(isPartial('/content/subdir/_bar.html')).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('returns false for regular files', () => {
|
|
49
|
+
expect(isPartial('index.md')).toBe(false);
|
|
50
|
+
expect(isPartial('/content/page.html')).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('handles paths with directories', () => {
|
|
54
|
+
expect(isPartial('/content/lectures/02/_pr1.md')).toBe(true);
|
|
55
|
+
expect(isPartial('/content/lectures/02/subdir/_foobar.html')).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
41
59
|
describe('isLiterateJava', () => {
|
|
42
60
|
test('returns true for .java.md files', () => {
|
|
43
61
|
expect(isLiterateJava('Example.java.md')).toBe(true);
|
|
@@ -8,6 +8,10 @@ export function extensionIsMarkdown(ext: string): boolean {
|
|
|
8
8
|
return ['.md', '.markdown'].includes(ext);
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
export function isPartial(filePath: string): boolean {
|
|
12
|
+
return path.basename(filePath).startsWith('_');
|
|
13
|
+
}
|
|
14
|
+
|
|
11
15
|
export function isLiterateJava(filePath: string): boolean {
|
|
12
16
|
return path.basename(filePath).toLowerCase().endsWith('.java.md');
|
|
13
17
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { createIncludeFunction } from './include';
|
|
6
|
+
|
|
7
|
+
describe('createIncludeFunction', () => {
|
|
8
|
+
let tempDir: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tada-include-test-'));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('includes a basic Markdown partial', () => {
|
|
19
|
+
const partialPath = path.join(tempDir, '_partial.md');
|
|
20
|
+
fs.writeFileSync(partialPath, 'Hello from partial');
|
|
21
|
+
|
|
22
|
+
const callerPath = path.join(tempDir, 'page.md');
|
|
23
|
+
const include = createIncludeFunction(callerPath, {});
|
|
24
|
+
expect(include('_partial.md')).toBe('Hello from partial');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('processes Lodash expressions in partials', () => {
|
|
28
|
+
const partialPath = path.join(tempDir, '_partial.md');
|
|
29
|
+
fs.writeFileSync(partialPath, 'Title: <%= page.title %>');
|
|
30
|
+
|
|
31
|
+
const callerPath = path.join(tempDir, 'page.md');
|
|
32
|
+
const params = { page: { title: 'Hello' } };
|
|
33
|
+
const include = createIncludeFunction(callerPath, params);
|
|
34
|
+
expect(include('_partial.md')).toBe('Title: Hello');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('includes an HTML partial with Lodash', () => {
|
|
38
|
+
const partialPath = path.join(tempDir, '_partial.html');
|
|
39
|
+
fs.writeFileSync(partialPath, '<p><%= site.title %></p>');
|
|
40
|
+
|
|
41
|
+
const callerPath = path.join(tempDir, 'page.md');
|
|
42
|
+
const params = { site: { title: 'My Site' } };
|
|
43
|
+
const include = createIncludeFunction(callerPath, params);
|
|
44
|
+
expect(include('_partial.html')).toBe('<p>My Site</p>');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('supports nested includes', () => {
|
|
48
|
+
const aPath = path.join(tempDir, '_a.md');
|
|
49
|
+
const bPath = path.join(tempDir, '_b.md');
|
|
50
|
+
fs.writeFileSync(aPath, 'A then <%= include("_b.md") %>');
|
|
51
|
+
fs.writeFileSync(bPath, 'B');
|
|
52
|
+
|
|
53
|
+
const callerPath = path.join(tempDir, 'page.md');
|
|
54
|
+
const include = createIncludeFunction(callerPath, {});
|
|
55
|
+
expect(include('_a.md')).toBe('A then B');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('resolves paths relative to the calling file', () => {
|
|
59
|
+
const subdir = path.join(tempDir, 'subdir');
|
|
60
|
+
fs.mkdirSync(subdir);
|
|
61
|
+
fs.writeFileSync(path.join(subdir, '_inner.md'), 'Inner content');
|
|
62
|
+
|
|
63
|
+
const callerPath = path.join(tempDir, 'page.md');
|
|
64
|
+
const include = createIncludeFunction(callerPath, {});
|
|
65
|
+
expect(include('subdir/_inner.md')).toBe('Inner content');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('nested includes resolve relative to the partial, not the caller', () => {
|
|
69
|
+
const subdir = path.join(tempDir, 'subdir');
|
|
70
|
+
fs.mkdirSync(subdir);
|
|
71
|
+
fs.writeFileSync(
|
|
72
|
+
path.join(subdir, '_a.md'),
|
|
73
|
+
'A then <%= include("_b.md") %>',
|
|
74
|
+
);
|
|
75
|
+
fs.writeFileSync(path.join(subdir, '_b.md'), 'B in subdir');
|
|
76
|
+
|
|
77
|
+
const callerPath = path.join(tempDir, 'page.md');
|
|
78
|
+
const include = createIncludeFunction(callerPath, {});
|
|
79
|
+
expect(include('subdir/_a.md')).toBe('A then B in subdir');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('throws when partial not found', () => {
|
|
83
|
+
const callerPath = path.join(tempDir, 'page.md');
|
|
84
|
+
const include = createIncludeFunction(callerPath, {});
|
|
85
|
+
expect(() => include('_missing.md')).toThrow('partial not found');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('throws when target does not start with _', () => {
|
|
89
|
+
const filePath = path.join(tempDir, 'notpartial.md');
|
|
90
|
+
fs.writeFileSync(filePath, 'content');
|
|
91
|
+
|
|
92
|
+
const callerPath = path.join(tempDir, 'page.md');
|
|
93
|
+
const include = createIncludeFunction(callerPath, {});
|
|
94
|
+
expect(() => include('notpartial.md')).toThrow('must start with "_"');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('throws when max depth exceeded', () => {
|
|
98
|
+
// Create a chain: _0.md includes _1.md includes _2.md ... up to 11
|
|
99
|
+
for (let i = 0; i <= 10; i++) {
|
|
100
|
+
const content = i < 10 ? `<%= include("_${i + 1}.md") %>` : 'end';
|
|
101
|
+
fs.writeFileSync(path.join(tempDir, `_${i}.md`), content);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const callerPath = path.join(tempDir, 'page.md');
|
|
105
|
+
const include = createIncludeFunction(callerPath, {});
|
|
106
|
+
expect(() => include('_0.md')).toThrow('maximum include depth');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('strips HTML comments from partials', () => {
|
|
110
|
+
const partialPath = path.join(tempDir, '_partial.md');
|
|
111
|
+
fs.writeFileSync(partialPath, 'before<!--- hidden --->after');
|
|
112
|
+
|
|
113
|
+
const callerPath = path.join(tempDir, 'page.md');
|
|
114
|
+
const include = createIncludeFunction(callerPath, {});
|
|
115
|
+
expect(include('_partial.md')).toBe('beforeafter');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import _ from 'lodash';
|
|
4
|
+
import { isPartial } from './file-types';
|
|
5
|
+
import { stripHtmlComments } from './render';
|
|
6
|
+
|
|
7
|
+
const MAX_INCLUDE_DEPTH = 10;
|
|
8
|
+
|
|
9
|
+
export function createIncludeFunction(
|
|
10
|
+
callerFilePath: string,
|
|
11
|
+
templateParams: Record<string, unknown>,
|
|
12
|
+
depth: number = 0,
|
|
13
|
+
): (relativePath: string) => string {
|
|
14
|
+
return (relativePath: string): string => {
|
|
15
|
+
const callerDir = path.dirname(callerFilePath);
|
|
16
|
+
const resolved = path.resolve(callerDir, relativePath);
|
|
17
|
+
|
|
18
|
+
if (!fs.existsSync(resolved)) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`${callerFilePath}: partial not found: ${relativePath} (resolved to ${resolved})`,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
if (!isPartial(resolved)) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`${callerFilePath}: include target must start with "_": ${relativePath}`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if (depth >= MAX_INCLUDE_DEPTH) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`${callerFilePath}: maximum include depth (${MAX_INCLUDE_DEPTH}) exceeded`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const raw = fs.readFileSync(resolved, 'utf-8');
|
|
35
|
+
const content = stripHtmlComments(raw);
|
|
36
|
+
|
|
37
|
+
const nestedInclude = createIncludeFunction(
|
|
38
|
+
resolved,
|
|
39
|
+
templateParams,
|
|
40
|
+
depth + 1,
|
|
41
|
+
);
|
|
42
|
+
const nestedParams = { ...templateParams, include: nestedInclude };
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
return _.template(content)(nestedParams);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`${resolved}: Lodash template error in partial: ${(err as Error).message}`,
|
|
49
|
+
{ cause: err },
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|