@abreen/tada 1.17.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +41 -7
  2. package/bin/tada.ts +23 -9
  3. package/bin/validators.ts +34 -1
  4. package/build/branding.ts +41 -0
  5. package/build/build-manifest.ts +10 -5
  6. package/build/{watch/compiler-types.d.ts → build-types.d.ts} +8 -5
  7. package/build/build-validation.ts +176 -0
  8. package/build/bundle.ts +75 -65
  9. package/build/columns-plugin.ts +27 -1
  10. package/build/config-loader.ts +3 -3
  11. package/build/custom-fonts.ts +8 -5
  12. package/build/deflist-id-plugin.ts +2 -2
  13. package/build/generate-favicon.ts +7 -6
  14. package/build/generate-fonts.ts +8 -15
  15. package/build/generate-katex-assets.ts +19 -17
  16. package/build/generate-web-app-manifest.ts +3 -11
  17. package/build/lodash-template.ts +14 -0
  18. package/build/output-publication.ts +238 -0
  19. package/build/pagefind.ts +49 -21
  20. package/build/pdf-text.ts +41 -1
  21. package/build/pipeline.ts +95 -101
  22. package/build/site-assets.ts +46 -0
  23. package/build/site-build.ts +267 -0
  24. package/build/source-model.ts +223 -229
  25. package/build/source-records.ts +69 -81
  26. package/build/template-globals.ts +3 -0
  27. package/build/templates.ts +12 -8
  28. package/build/toc-plugin.ts +21 -21
  29. package/build/types.d.ts +38 -38
  30. package/build/util.ts +0 -4
  31. package/build/utils/code.ts +51 -117
  32. package/build/utils/final-html.ts +171 -47
  33. package/build/utils/jdi-runner/LiterateRunner.java +9 -1
  34. package/build/utils/literate-java.ts +11 -7
  35. package/build/utils/markdown-partials.ts +4 -3
  36. package/build/utils/markdown.ts +6 -17
  37. package/build/utils/paths.ts +3 -0
  38. package/build/utils/plain-text.ts +18 -0
  39. package/build/utils/render.ts +197 -154
  40. package/build/utils/shiki-highlighter.ts +11 -0
  41. package/build/utils/source-template.ts +2 -2
  42. package/build/utils/trace-core.ts +8 -83
  43. package/build/utils/trace.ts +38 -95
  44. package/build/validate-config-links.ts +39 -6
  45. package/build/watch/build-incremental.ts +46 -103
  46. package/build/watch/compiler.ts +158 -37
  47. package/build/watch/engine.ts +207 -162
  48. package/build/watch/index.ts +9 -2
  49. package/build/watch/planner.ts +88 -144
  50. package/build/watch/runtime.ts +21 -34
  51. package/build/watch/types.d.ts +32 -64
  52. package/package.json +4 -5
  53. package/schema/site.schema.json +18 -2
  54. package/src/_content.scss +447 -0
  55. package/src/_fonts.scss +90 -0
  56. package/src/_layout.scss +50 -35
  57. package/src/appearance-picker/{style.scss → _index.scss} +1 -1
  58. package/src/code/{style.scss → _index.scss} +61 -0
  59. package/src/header/{_base.scss → _index.scss} +98 -6
  60. package/src/index.ts +1 -17
  61. package/src/navigate/{style.scss → _index.scss} +20 -0
  62. package/src/navigate/eligible.ts +0 -40
  63. package/src/navigate/index.ts +50 -5
  64. package/src/navigate/lifecycle.ts +19 -2
  65. package/src/navigate/runtime.ts +20 -4
  66. package/src/page-update/index.ts +33 -20
  67. package/src/print/{style.scss → _index.scss} +3 -1
  68. package/src/search/index.ts +30 -2
  69. package/src/slides/{style.scss → _index.scss} +5 -0
  70. package/src/style.scss +24 -536
  71. package/src/timezone/index.ts +29 -3
  72. package/src/timezone/time-format.ts +4 -0
  73. package/src/toc/index.ts +6 -5
  74. package/src/trace/index.ts +11 -1
  75. package/templates/_author.html +2 -2
  76. package/templates/_download.html +1 -1
  77. package/templates/_footer.html +1 -1
  78. package/templates/_heading.html +3 -3
  79. package/templates/_nav.html +2 -2
  80. package/templates/_page-bottom.html +6 -0
  81. package/templates/_top.html +18 -14
  82. package/templates/code.html +22 -21
  83. package/templates/default.html +12 -11
  84. package/templates/literate.html +6 -5
  85. package/build/copy.ts +0 -82
  86. package/build/generate-content-assets.ts +0 -121
  87. package/build/utils/content-files.ts +0 -170
  88. package/build/watch/assets.ts +0 -103
  89. package/build/watch/build-full.ts +0 -97
  90. package/build/watch/build-helpers.ts +0 -66
  91. package/build/watch/build-result.ts +0 -19
  92. package/build/watch/fs-commit.ts +0 -153
  93. package/build/watch/mutations.ts +0 -73
  94. package/build/watch/snapshot.ts +0 -201
  95. package/build/watch/validation.ts +0 -50
  96. package/src/code.scss +0 -60
  97. package/src/critical.scss +0 -5
  98. package/src/header/style.scss +0 -91
  99. /package/src/{literate.scss → _literate.scss} +0 -0
  100. /package/src/{material-symbols.scss → _material-symbols.scss} +0 -0
  101. /package/src/anchor/{style.scss → _index.scss} +0 -0
  102. /package/src/page-update/{style.scss → _index.scss} +0 -0
  103. /package/src/question/{style.scss → _index.scss} +0 -0
  104. /package/src/search/{style.scss → _index.scss} +0 -0
  105. /package/src/timezone/{style.scss → _index.scss} +0 -0
  106. /package/src/toc/{style.scss → _index.scss} +0 -0
  107. /package/src/trace/{style.scss → _index.scss} +0 -0
@@ -1,10 +1,10 @@
1
1
  import MarkdownIt from 'markdown-it';
2
2
  import path from 'path';
3
3
  import { parse as parseJava } from 'java-parser';
4
- import { JSDOM } from 'jsdom';
4
+ import { hastToHtml } from 'shiki';
5
5
  import { makeLogger } from '../log';
6
6
  import { getExtensionToShikiLanguage } from '../site-variables';
7
- import { highlightCode } from './shiki-highlighter';
7
+ import { highlightCodeToHast } from './shiki-highlighter';
8
8
  import externalLinksPlugin from '../external-links-plugin';
9
9
  import { createApplyBasePath } from './paths';
10
10
  import katexPlugin from './katex';
@@ -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
  }
@@ -363,104 +360,42 @@ function escapeHtml(text: string): string {
363
360
  .replace(/>/g, '&gt;');
364
361
  }
365
362
 
366
- function createCodeLine(document: Document): HTMLSpanElement {
367
- const line = document.createElement('span');
368
- line.className = 'code-line';
369
- return line;
363
+ interface HastNode {
364
+ type: string;
365
+ value?: string;
366
+ children?: HastNode[];
370
367
  }
371
368
 
372
- function cloneOpenElements(
373
- openElements: Node[],
374
- line: HTMLSpanElement,
375
- ): Node[] {
376
- const containers: Node[] = [line];
377
-
378
- for (const openElement of openElements) {
379
- const clone = openElement.cloneNode(false);
380
- containers[containers.length - 1].appendChild(clone);
381
- containers.push(clone);
369
+ function hasText(node: HastNode): boolean {
370
+ if (node.type === 'text') {
371
+ return Boolean(node.value);
382
372
  }
383
-
384
- return containers;
373
+ return node.children?.some(hasText) ?? false;
385
374
  }
386
375
 
387
- function splitHighlightedHtmlIntoLines(
388
- highlightedHtml: string,
389
- lineCount: number,
390
- ): string[] {
391
- const fragment = JSDOM.fragment(`<code>${highlightedHtml}</code>`);
392
- const codeEl = fragment.firstChild as HTMLElement;
393
- const document = codeEl.ownerDocument;
394
- const lines: HTMLSpanElement[] = [];
395
- const openElements: Node[] = [];
396
- let currentLine = createCodeLine(document);
397
- let currentContainers: Node[] = [currentLine];
398
- let currentLineHasContent = false;
399
-
400
- function finishCurrentLine(): void {
401
- if (!currentLineHasContent) {
402
- currentContainers[currentContainers.length - 1].appendChild(
403
- document.createTextNode('\u00A0'),
404
- );
405
- }
406
- lines.push(currentLine);
407
- currentLine = createCodeLine(document);
408
- currentContainers = cloneOpenElements(openElements, currentLine);
409
- currentLineHasContent = false;
410
- }
411
-
412
- function visit(node: Node): void {
413
- if (node.nodeType === 3) {
414
- const parts = (node.textContent || '').split('\n');
415
- for (let i = 0; i < parts.length; i++) {
416
- if (parts[i].length > 0) {
417
- currentContainers[currentContainers.length - 1].appendChild(
418
- document.createTextNode(parts[i]),
419
- );
420
- currentLineHasContent = true;
421
- }
422
- if (i < parts.length - 1) {
423
- finishCurrentLine();
424
- }
425
- }
426
- return;
427
- }
428
-
429
- if (node.nodeType !== 1) {
430
- return;
431
- }
432
-
433
- const clone = node.cloneNode(false);
434
- currentContainers[currentContainers.length - 1].appendChild(clone);
435
- openElements.push(node);
436
- currentContainers.push(clone);
437
-
438
- for (const child of Array.from(node.childNodes)) {
439
- visit(child);
440
- }
441
-
442
- currentContainers.pop();
443
- openElements.pop();
444
- }
445
-
446
- for (const child of Array.from(codeEl.childNodes)) {
447
- visit(child);
376
+ /**
377
+ * Returns the HTML of each highlighted line. Shiki tokenizes line by line, so
378
+ * each `span.line` is balanced on its own, even inside multi-line tokens such
379
+ * as block comments. Empty lines get a non-breaking space so that their row
380
+ * keeps its height.
381
+ */
382
+ function highlightLines(source: string, lang: string): string[] {
383
+ const pre = highlightCodeToHast(source, lang).children[0];
384
+ const code = pre?.type === 'element' ? pre.children[0] : undefined;
385
+ if (code?.type !== 'element') {
386
+ throw new Error('unexpected highlighter output');
448
387
  }
449
388
 
450
- if (currentLineHasContent || lines.length < lineCount) {
451
- if (!currentLineHasContent) {
452
- currentContainers[currentContainers.length - 1].appendChild(
453
- document.createTextNode('\u00A0'),
454
- );
389
+ const lines: string[] = [];
390
+ for (const line of code.children) {
391
+ if (line.type === 'element') {
392
+ const html = hastToHtml(line, {
393
+ characterReferences: { useNamedReferences: true },
394
+ });
395
+ lines.push(hasText(line) ? html : `${html}&nbsp;`);
455
396
  }
456
- lines.push(currentLine);
457
- }
458
-
459
- while (lines.length < lineCount) {
460
- lines.push(createCodeLine(document));
461
397
  }
462
-
463
- return lines.map(line => line.innerHTML);
398
+ return lines;
464
399
  }
465
400
 
466
401
  export function renderCodeSegment(
@@ -469,20 +404,19 @@ export function renderCodeSegment(
469
404
  lang: string,
470
405
  { linkLineNumbers = true }: { linkLineNumbers?: boolean } = {},
471
406
  ): string {
472
- const source = lines.join('\n');
473
- let lineHtml: string[] | undefined;
407
+ // Lines from CRLF sources end with CR. Shiki treats CRLF as a line break
408
+ // but would keep the last line's CR as text.
409
+ const sourceLines = lines.map(line =>
410
+ line.endsWith('\r') ? line.slice(0, -1) : line,
411
+ );
412
+ let lineHtml: string[];
474
413
 
475
414
  try {
476
- const html = highlightCode(source, lang);
477
- const fragment = JSDOM.fragment(html);
478
- const inner = (fragment.querySelector('code') as HTMLElement).innerHTML;
479
- lineHtml = splitHighlightedHtmlIntoLines(inner, lines.length);
415
+ const highlighted = highlightLines(sourceLines.join('\n'), lang);
416
+ lineHtml = sourceLines.map((_, i) => highlighted[i] ?? '');
480
417
  } catch (err: unknown) {
481
418
  log.error`Failed to highlight code block: ${(err as Error).message}`;
482
- }
483
-
484
- if (!lineHtml) {
485
- lineHtml = lines.map(line => escapeHtml(line));
419
+ lineHtml = sourceLines.map(line => escapeHtml(line));
486
420
  }
487
421
 
488
422
  const rows = lineHtml.map((line, i) => {
@@ -1,5 +1,5 @@
1
1
  import path from 'path';
2
- import { JSDOM } from 'jsdom';
2
+ import { decodeHTMLAttribute } from 'entities';
3
3
  import { getExtensionToShikiLanguage } from '../site-variables';
4
4
  import { createApplyBasePath, normalizeOutputPath } from './paths';
5
5
  import { isInternalLink } from './link';
@@ -14,8 +14,10 @@ interface FinalizeHtmlPageOptions {
14
14
  html: string;
15
15
  siteVariables: SiteVariables;
16
16
  sourceUrlPath: string;
17
- validInternalTargets: Set<string>;
18
- literateJavaOutputPaths?: Set<string>;
17
+ validInternalTargets: ReadonlySet<string>;
18
+ generatedPageTargets?: ReadonlySet<string>;
19
+ codePageSourceTargets?: ReadonlySet<string>;
20
+ literateJavaOutputPaths?: ReadonlySet<string>;
19
21
  dependencyCollector?: RenderDependencyCollector;
20
22
  }
21
23
 
@@ -24,6 +26,10 @@ interface FinalizedHtmlPage {
24
26
  analysis: HtmlOutputAnalysis;
25
27
  }
26
28
 
29
+ type RewriterElement = HTMLRewriterTypes.Element;
30
+
31
+ const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
32
+
27
33
  function splitHref(href: string): { pathname: string; suffix: string } {
28
34
  const match = href.match(/^([^?#]*)(.*)$/);
29
35
  return { pathname: match ? match[1] : href, suffix: match ? match[2] : '' };
@@ -85,16 +91,16 @@ function rewriteAbsoluteSrcWithBasePath(
85
91
  function resolveAnchorTarget({
86
92
  href,
87
93
  sourceUrlPath,
88
- validInternalTargets,
89
94
  codeExtensions,
95
+ codePageSourceTargets,
90
96
  literateJavaOutputPaths,
91
97
  skipCodeLinkRewrite,
92
98
  }: {
93
99
  href: string;
94
100
  sourceUrlPath: string;
95
- validInternalTargets: Set<string>;
96
101
  codeExtensions: string[];
97
- literateJavaOutputPaths?: Set<string>;
102
+ codePageSourceTargets?: ReadonlySet<string>;
103
+ literateJavaOutputPaths?: ReadonlySet<string>;
98
104
  skipCodeLinkRewrite: boolean;
99
105
  }): { finalHref: string; resolvedTarget: string | null } {
100
106
  if (!isInternalLink(href)) {
@@ -116,19 +122,38 @@ function resolveAnchorTarget({
116
122
  const htmlTarget = `${resolvedPath}.html`;
117
123
  if (literateJavaOutputPaths?.has(resolvedPath)) {
118
124
  resolvedTarget = resolvedPath;
119
- } else if (validInternalTargets.has(htmlTarget)) {
125
+ } else if (codePageSourceTargets?.has(resolvedPath)) {
120
126
  finalPathname = `${pathname}.html`;
121
127
  resolvedTarget = htmlTarget;
122
- } else if (validInternalTargets.has(resolvedPath)) {
123
- resolvedTarget = resolvedPath;
124
- } else {
125
- resolvedTarget = htmlTarget;
126
128
  }
127
129
  }
128
130
 
129
131
  return { finalHref: `${finalPathname}${suffix}`, resolvedTarget };
130
132
  }
131
133
 
134
+ // HTMLRewriter exposes attribute values exactly as authored, without decoding
135
+ // character references, so decode them the way an HTML parser would. It also
136
+ // reports empty values as null.
137
+ function getAttributeValue(
138
+ element: RewriterElement,
139
+ name: string,
140
+ ): string | null {
141
+ const value = element.getAttribute(name);
142
+ if (value === null) {
143
+ return element.hasAttribute(name) ? '' : null;
144
+ }
145
+ return value.includes('&') ? decodeHTMLAttribute(value) : value;
146
+ }
147
+
148
+ // HTMLRewriter writes new values verbatim apart from escaping double quotes.
149
+ function setAttributeValue(
150
+ element: RewriterElement,
151
+ name: string,
152
+ value: string,
153
+ ): void {
154
+ element.setAttribute(name, value.replaceAll('&', '&amp;'));
155
+ }
156
+
132
157
  export function finalizeHtmlPage({
133
158
  filePath,
134
159
  html,
@@ -136,31 +161,43 @@ export function finalizeHtmlPage({
136
161
  sourceUrlPath,
137
162
  validInternalTargets,
138
163
  literateJavaOutputPaths,
164
+ generatedPageTargets,
165
+ codePageSourceTargets,
139
166
  dependencyCollector,
140
167
  }: FinalizeHtmlPageOptions): FinalizedHtmlPage {
141
- const dom = new JSDOM(html);
142
- const document = dom.window.document;
143
168
  const applyBasePath = createApplyBasePath(siteVariables);
144
169
  const codeExtensions = Object.keys(
145
170
  getExtensionToShikiLanguage(siteVariables),
146
171
  );
147
172
  const analysis: HtmlOutputAnalysis = { outgoingTargets: new Set() };
173
+ // Content link targets are recorded before classification targets, matching
174
+ // the order of the original separate passes.
175
+ const classificationTargets: string[] = [];
148
176
 
149
- for (const element of document.querySelectorAll('[href]')) {
150
- const href = element.getAttribute('href');
151
- if (!href) {
152
- continue;
153
- }
177
+ const origin = new URL(siteVariables.base).origin;
178
+ // Source paths contain no query or fragment. Escape filesystem delimiters
179
+ // while preserving the percent-encoded segments supplied by code pages.
180
+ const sourcePathname = sourceUrlPath.replace(/[?#]/g, encodeURIComponent);
181
+ const sourceUrl = new URL(applyBasePath(sourcePathname), origin);
182
+ const basePath = (siteVariables.basePath || '/').replace(/\/$/, '');
154
183
 
155
- const isAnchor = element.tagName === 'A';
156
- const isContentAnchor = isAnchor && element.closest('main.body') !== null;
184
+ // Rewrites an href and returns its final value. Anchors inside `main.body`
185
+ // are page content: they are resolved, rewritten to code pages, and
186
+ // validated. Other hrefs only receive the base path.
187
+ function rewriteHref(
188
+ element: RewriterElement,
189
+ href: string,
190
+ isContent: boolean,
191
+ ): string {
192
+ const isAnchor =
193
+ element.tagName === 'a' && element.namespaceURI === HTML_NAMESPACE;
157
194
 
158
- if (isContentAnchor) {
195
+ if (isAnchor && isContent) {
159
196
  const { finalHref, resolvedTarget } = resolveAnchorTarget({
160
197
  href,
161
198
  sourceUrlPath,
162
- validInternalTargets,
163
199
  codeExtensions,
200
+ codePageSourceTargets,
164
201
  literateJavaOutputPaths,
165
202
  skipCodeLinkRewrite: element.hasAttribute('download'),
166
203
  });
@@ -169,13 +206,11 @@ export function finalizeHtmlPage({
169
206
  : finalHref;
170
207
 
171
208
  if (rewrittenHref !== href) {
172
- element.setAttribute('href', rewrittenHref);
209
+ setAttributeValue(element, 'href', rewrittenHref);
173
210
  }
174
211
 
175
212
  if (resolvedTarget) {
176
- if (isAnchor) {
177
- analysis.outgoingTargets.add(resolvedTarget);
178
- }
213
+ analysis.outgoingTargets.add(resolvedTarget);
179
214
 
180
215
  const directoryIndexPath = getDirectoryIndexPath(resolvedTarget);
181
216
  if (
@@ -196,7 +231,7 @@ export function finalizeHtmlPage({
196
231
  dependencyCollector?.internalTargets?.add(resolvedTarget);
197
232
  }
198
233
 
199
- continue;
234
+ return rewrittenHref;
200
235
  }
201
236
 
202
237
  if (isAnchor && isInternalLink(href)) {
@@ -209,31 +244,120 @@ export function finalizeHtmlPage({
209
244
 
210
245
  const rewrittenHref = rewriteAbsoluteHrefWithBasePath(href, applyBasePath);
211
246
  if (rewrittenHref !== href) {
212
- element.setAttribute('href', rewrittenHref);
247
+ setAttributeValue(element, 'href', rewrittenHref);
213
248
  }
249
+ return rewrittenHref;
214
250
  }
215
251
 
216
- for (const element of document.querySelectorAll('[src]')) {
217
- const src = element.getAttribute('src');
218
- if (!src) {
219
- continue;
252
+ // Recomputes the generated-page marker from an anchor's final href.
253
+ function classifyAnchor(anchor: RewriterElement, href: string | null): void {
254
+ if (anchor.hasAttribute('data-tada-page')) {
255
+ anchor.removeAttribute('data-tada-page');
220
256
  }
221
-
222
- const rewrittenSrc = rewriteAbsoluteSrcWithBasePath(src, applyBasePath);
223
- if (rewrittenSrc !== src) {
224
- element.setAttribute('src', rewrittenSrc);
257
+ if (href === null) {
258
+ return;
259
+ }
260
+ let url: URL;
261
+ try {
262
+ url = new URL(href, sourceUrl);
263
+ } catch {
264
+ return;
265
+ }
266
+ if (url.origin !== origin) {
267
+ return;
268
+ }
269
+ if (
270
+ basePath &&
271
+ url.pathname !== basePath &&
272
+ !url.pathname.startsWith(`${basePath}/`)
273
+ ) {
274
+ return;
275
+ }
276
+ let target = url.pathname.slice(basePath.length) || '/';
277
+ try {
278
+ target = decodeURIComponent(target);
279
+ } catch {
280
+ // Preserve malformed encodings for classification as authored.
281
+ }
282
+ target = normalizeOutputPath(target);
283
+ if (generatedPageTargets) {
284
+ classificationTargets.push(target);
285
+ }
286
+ if (
287
+ generatedPageTargets?.has(target) &&
288
+ !anchor.hasAttribute('target') &&
289
+ !anchor.hasAttribute('download')
290
+ ) {
291
+ anchor.setAttribute('data-tada-page', '');
225
292
  }
226
293
  }
227
294
 
228
- const doctype = document.doctype;
229
- const doctypePrefix = doctype
230
- ? `<!DOCTYPE ${doctype.name}${
231
- doctype.publicId ? ` PUBLIC "${doctype.publicId}"` : ''
232
- }${doctype.systemId ? ` "${doctype.systemId}"` : ''}>`
233
- : '';
234
-
235
- return {
236
- html: `${doctypePrefix}${document.documentElement.outerHTML}`,
237
- analysis,
238
- };
295
+ // HTMLRewriter edits the original markup in place. It reads `<noscript>`
296
+ // contents as raw text, so a nested rewriter parses them as markup (as a
297
+ // parser with scripting disabled would), inheriting whether the element is
298
+ // inside `main.body`.
299
+ function rewrite(input: string, initialContentDepth: number): string {
300
+ let contentDepth = initialContentDepth;
301
+ let noscriptSource = '';
302
+
303
+ return new HTMLRewriter()
304
+ .on('main.body', {
305
+ element(element) {
306
+ contentDepth++;
307
+ element.onEndTag(() => {
308
+ contentDepth--;
309
+ });
310
+ },
311
+ })
312
+ .on('[href]', {
313
+ element(element) {
314
+ const href = getAttributeValue(element, 'href');
315
+ const finalHref = href
316
+ ? rewriteHref(element, href, contentDepth > 0)
317
+ : href;
318
+ if (element.tagName === 'a') {
319
+ classifyAnchor(element, finalHref);
320
+ }
321
+ },
322
+ })
323
+ .on('a:not([href])', {
324
+ element(element) {
325
+ classifyAnchor(element, null);
326
+ },
327
+ })
328
+ .on('[src]', {
329
+ element(element) {
330
+ const src = getAttributeValue(element, 'src');
331
+ if (!src) {
332
+ return;
333
+ }
334
+ const rewrittenSrc = rewriteAbsoluteSrcWithBasePath(
335
+ src,
336
+ applyBasePath,
337
+ );
338
+ if (rewrittenSrc !== src) {
339
+ setAttributeValue(element, 'src', rewrittenSrc);
340
+ }
341
+ },
342
+ })
343
+ .on('noscript', {
344
+ text(chunk) {
345
+ noscriptSource += chunk.text;
346
+ if (!chunk.lastInTextNode) {
347
+ chunk.remove();
348
+ return;
349
+ }
350
+ chunk.replace(rewrite(noscriptSource, contentDepth), { html: true });
351
+ noscriptSource = '';
352
+ },
353
+ })
354
+ .transform(input);
355
+ }
356
+
357
+ const finalizedHtml = rewrite(html, 0);
358
+ for (const target of classificationTargets) {
359
+ dependencyCollector?.internalTargets?.add(target);
360
+ }
361
+
362
+ return { html: finalizedHtml, analysis };
239
363
  }
@@ -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('"');
@@ -1,4 +1,5 @@
1
1
  import fs from 'fs';
2
+ import type Token from 'markdown-it/lib/token.mjs';
2
3
  import os from 'os';
3
4
  import path from 'path';
4
5
  import { execFileSync } from 'child_process';
@@ -63,6 +64,15 @@ export function parseLiterateJava(
63
64
  });
64
65
  const tokens = md.parse(content, {});
65
66
 
67
+ return { pageVariables, content, ...extractLiterateJavaCode(tokens) };
68
+ }
69
+
70
+ export function extractLiterateJavaCode(
71
+ tokens: Token[],
72
+ ): Pick<
73
+ LiterateJavaParseResult,
74
+ 'javaSource' | 'codeBlocks' | 'visibleBlockIndices'
75
+ > {
66
76
  const codeBlocks: LiterateCodeBlock[] = [];
67
77
  let javaLine = 1;
68
78
 
@@ -98,13 +108,7 @@ export function parseLiterateJava(
98
108
  const hiddenCount = codeBlocks.length - visibleBlockIndices.length;
99
109
  log.debug`Parsed ${codeBlocks.length} code block(s) (${hiddenCount} hidden), ${javaSource.split('\n').length} Java line(s)`;
100
110
 
101
- return {
102
- pageVariables,
103
- content,
104
- javaSource,
105
- codeBlocks,
106
- visibleBlockIndices,
107
- };
111
+ return { javaSource, codeBlocks, visibleBlockIndices };
108
112
  }
109
113
 
110
114
  export function hasMainMethod(javaSource: string): boolean {
@@ -1,6 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import _ from 'lodash';
3
+ import { compileTemplate } from '../lodash-template';
4
4
  import type MarkdownIt from 'markdown-it';
5
5
  import type StateCore from 'markdown-it/lib/rules_core/state_core.mjs';
6
6
  import type StateBlock from 'markdown-it/lib/rules_block/state_block.mjs';
@@ -26,6 +26,7 @@ interface MarkdownPartialsEnv {
26
26
  interface MarkdownPartialsOptions {
27
27
  filePath: string;
28
28
  templateParams: Record<string, unknown>;
29
+ preserveHtmlComments?: boolean;
29
30
  dependencyCollector?: RenderDependencyCollector;
30
31
  }
31
32
 
@@ -77,12 +78,12 @@ function renderPartialContent(
77
78
  }
78
79
 
79
80
  const raw = fs.readFileSync(resolvedPath, 'utf-8');
80
- const content = stripHtmlComments(raw);
81
+ const content = options.preserveHtmlComments ? raw : stripHtmlComments(raw);
81
82
 
82
83
  try {
83
84
  return {
84
85
  resolvedPath,
85
- content: _.template(content)(options.templateParams),
86
+ content: compileTemplate(content)(options.templateParams),
86
87
  };
87
88
  } catch (err) {
88
89
  throw new Error(