@abreen/tada 1.17.1 → 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 (101) hide show
  1. package/README.md +37 -6
  2. package/bin/tada.ts +16 -7
  3. package/bin/validators.ts +33 -0
  4. package/build/branding.ts +41 -0
  5. package/build/build-manifest.ts +9 -4
  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/config-loader.ts +3 -3
  10. package/build/custom-fonts.ts +8 -5
  11. package/build/generate-favicon.ts +7 -6
  12. package/build/generate-fonts.ts +8 -15
  13. package/build/generate-katex-assets.ts +19 -17
  14. package/build/generate-web-app-manifest.ts +3 -11
  15. package/build/lodash-template.ts +14 -0
  16. package/build/output-publication.ts +238 -0
  17. package/build/pagefind.ts +49 -21
  18. package/build/pdf-text.ts +41 -1
  19. package/build/pipeline.ts +95 -101
  20. package/build/site-assets.ts +46 -0
  21. package/build/site-build.ts +267 -0
  22. package/build/source-model.ts +223 -244
  23. package/build/source-records.ts +69 -81
  24. package/build/template-globals.ts +3 -0
  25. package/build/templates.ts +5 -2
  26. package/build/toc-plugin.ts +21 -21
  27. package/build/types.d.ts +38 -38
  28. package/build/util.ts +0 -4
  29. package/build/utils/code.ts +38 -101
  30. package/build/utils/final-html.ts +171 -47
  31. package/build/utils/literate-java.ts +11 -7
  32. package/build/utils/markdown-partials.ts +4 -3
  33. package/build/utils/markdown.ts +6 -17
  34. package/build/utils/paths.ts +3 -0
  35. package/build/utils/plain-text.ts +18 -0
  36. package/build/utils/render.ts +197 -154
  37. package/build/utils/shiki-highlighter.ts +11 -0
  38. package/build/utils/source-template.ts +2 -2
  39. package/build/utils/trace-core.ts +8 -83
  40. package/build/utils/trace.ts +38 -95
  41. package/build/validate-config-links.ts +5 -5
  42. package/build/watch/build-incremental.ts +46 -103
  43. package/build/watch/compiler.ts +158 -37
  44. package/build/watch/engine.ts +207 -176
  45. package/build/watch/index.ts +9 -2
  46. package/build/watch/planner.ts +88 -144
  47. package/build/watch/runtime.ts +21 -34
  48. package/build/watch/types.d.ts +32 -64
  49. package/package.json +4 -4
  50. package/schema/site.schema.json +18 -2
  51. package/src/_content.scss +447 -0
  52. package/src/_fonts.scss +90 -0
  53. package/src/_layout.scss +50 -35
  54. package/src/appearance-picker/{style.scss → _index.scss} +1 -1
  55. package/src/code/{style.scss → _index.scss} +61 -0
  56. package/src/header/{_base.scss → _index.scss} +98 -6
  57. package/src/index.ts +1 -17
  58. package/src/navigate/{style.scss → _index.scss} +20 -0
  59. package/src/navigate/eligible.ts +0 -40
  60. package/src/navigate/index.ts +38 -4
  61. package/src/navigate/lifecycle.ts +19 -2
  62. package/src/navigate/runtime.ts +20 -4
  63. package/src/page-update/index.ts +33 -20
  64. package/src/print/{style.scss → _index.scss} +3 -1
  65. package/src/search/index.ts +7 -0
  66. package/src/slides/{style.scss → _index.scss} +5 -0
  67. package/src/style.scss +24 -536
  68. package/src/toc/index.ts +2 -2
  69. package/templates/_author.html +2 -2
  70. package/templates/_download.html +1 -1
  71. package/templates/_footer.html +1 -1
  72. package/templates/_heading.html +3 -3
  73. package/templates/_nav.html +2 -2
  74. package/templates/_page-bottom.html +6 -0
  75. package/templates/_top.html +18 -14
  76. package/templates/code.html +22 -21
  77. package/templates/default.html +12 -11
  78. package/templates/literate.html +6 -5
  79. package/build/copy.ts +0 -82
  80. package/build/generate-content-assets.ts +0 -121
  81. package/build/utils/content-files.ts +0 -170
  82. package/build/watch/assets.ts +0 -103
  83. package/build/watch/build-full.ts +0 -97
  84. package/build/watch/build-helpers.ts +0 -66
  85. package/build/watch/build-result.ts +0 -19
  86. package/build/watch/fs-commit.ts +0 -218
  87. package/build/watch/mutations.ts +0 -73
  88. package/build/watch/snapshot.ts +0 -201
  89. package/build/watch/validation.ts +0 -57
  90. package/src/code.scss +0 -60
  91. package/src/critical.scss +0 -5
  92. package/src/header/style.scss +0 -91
  93. /package/src/{literate.scss → _literate.scss} +0 -0
  94. /package/src/{material-symbols.scss → _material-symbols.scss} +0 -0
  95. /package/src/anchor/{style.scss → _index.scss} +0 -0
  96. /package/src/page-update/{style.scss → _index.scss} +0 -0
  97. /package/src/question/{style.scss → _index.scss} +0 -0
  98. /package/src/search/{style.scss → _index.scss} +0 -0
  99. /package/src/timezone/{style.scss → _index.scss} +0 -0
  100. /package/src/toc/{style.scss → _index.scss} +0 -0
  101. /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';
@@ -360,104 +360,42 @@ function escapeHtml(text: string): string {
360
360
  .replace(/>/g, '>');
361
361
  }
362
362
 
363
- function createCodeLine(document: Document): HTMLSpanElement {
364
- const line = document.createElement('span');
365
- line.className = 'code-line';
366
- return line;
363
+ interface HastNode {
364
+ type: string;
365
+ value?: string;
366
+ children?: HastNode[];
367
367
  }
368
368
 
369
- function cloneOpenElements(
370
- openElements: Node[],
371
- line: HTMLSpanElement,
372
- ): Node[] {
373
- const containers: Node[] = [line];
374
-
375
- for (const openElement of openElements) {
376
- const clone = openElement.cloneNode(false);
377
- containers[containers.length - 1].appendChild(clone);
378
- containers.push(clone);
369
+ function hasText(node: HastNode): boolean {
370
+ if (node.type === 'text') {
371
+ return Boolean(node.value);
379
372
  }
380
-
381
- return containers;
373
+ return node.children?.some(hasText) ?? false;
382
374
  }
383
375
 
384
- function splitHighlightedHtmlIntoLines(
385
- highlightedHtml: string,
386
- lineCount: number,
387
- ): string[] {
388
- const fragment = JSDOM.fragment(`<code>${highlightedHtml}</code>`);
389
- const codeEl = fragment.firstChild as HTMLElement;
390
- const document = codeEl.ownerDocument;
391
- const lines: HTMLSpanElement[] = [];
392
- const openElements: Node[] = [];
393
- let currentLine = createCodeLine(document);
394
- let currentContainers: Node[] = [currentLine];
395
- let currentLineHasContent = false;
396
-
397
- function finishCurrentLine(): void {
398
- if (!currentLineHasContent) {
399
- currentContainers[currentContainers.length - 1].appendChild(
400
- document.createTextNode('\u00A0'),
401
- );
402
- }
403
- lines.push(currentLine);
404
- currentLine = createCodeLine(document);
405
- currentContainers = cloneOpenElements(openElements, currentLine);
406
- currentLineHasContent = false;
407
- }
408
-
409
- function visit(node: Node): void {
410
- if (node.nodeType === 3) {
411
- const parts = (node.textContent || '').split('\n');
412
- for (let i = 0; i < parts.length; i++) {
413
- if (parts[i].length > 0) {
414
- currentContainers[currentContainers.length - 1].appendChild(
415
- document.createTextNode(parts[i]),
416
- );
417
- currentLineHasContent = true;
418
- }
419
- if (i < parts.length - 1) {
420
- finishCurrentLine();
421
- }
422
- }
423
- return;
424
- }
425
-
426
- if (node.nodeType !== 1) {
427
- return;
428
- }
429
-
430
- const clone = node.cloneNode(false);
431
- currentContainers[currentContainers.length - 1].appendChild(clone);
432
- openElements.push(node);
433
- currentContainers.push(clone);
434
-
435
- for (const child of Array.from(node.childNodes)) {
436
- visit(child);
437
- }
438
-
439
- currentContainers.pop();
440
- openElements.pop();
441
- }
442
-
443
- for (const child of Array.from(codeEl.childNodes)) {
444
- 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');
445
387
  }
446
388
 
447
- if (currentLineHasContent || lines.length < lineCount) {
448
- if (!currentLineHasContent) {
449
- currentContainers[currentContainers.length - 1].appendChild(
450
- document.createTextNode('\u00A0'),
451
- );
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;`);
452
396
  }
453
- lines.push(currentLine);
454
- }
455
-
456
- while (lines.length < lineCount) {
457
- lines.push(createCodeLine(document));
458
397
  }
459
-
460
- return lines.map(line => line.innerHTML);
398
+ return lines;
461
399
  }
462
400
 
463
401
  export function renderCodeSegment(
@@ -466,20 +404,19 @@ export function renderCodeSegment(
466
404
  lang: string,
467
405
  { linkLineNumbers = true }: { linkLineNumbers?: boolean } = {},
468
406
  ): string {
469
- const source = lines.join('\n');
470
- 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[];
471
413
 
472
414
  try {
473
- const html = highlightCode(source, lang);
474
- const fragment = JSDOM.fragment(html);
475
- const inner = (fragment.querySelector('code') as HTMLElement).innerHTML;
476
- lineHtml = splitHighlightedHtmlIntoLines(inner, lines.length);
415
+ const highlighted = highlightLines(sourceLines.join('\n'), lang);
416
+ lineHtml = sourceLines.map((_, i) => highlighted[i] ?? '');
477
417
  } catch (err: unknown) {
478
418
  log.error`Failed to highlight code block: ${(err as Error).message}`;
479
- }
480
-
481
- if (!lineHtml) {
482
- lineHtml = lines.map(line => escapeHtml(line));
419
+ lineHtml = sourceLines.map(line => escapeHtml(line));
483
420
  }
484
421
 
485
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
  }
@@ -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(
@@ -7,7 +7,6 @@ import markdownItAnchor from 'markdown-it-anchor';
7
7
  import markdownItFootnote from 'markdown-it-footnote';
8
8
  import markdownItDeflist from 'markdown-it-deflist';
9
9
  import markdownItContainer from 'markdown-it-container';
10
- import textToId, { deduplicateId } from '../text-to-id';
11
10
  import { highlightCode } from './shiki-highlighter';
12
11
  import { isBundledLanguage, isPlainTextLanguage } from '../site-variables';
13
12
  import headingSubtitlePlugin from '../heading-subtitle-plugin';
@@ -28,10 +27,11 @@ interface CreateMarkdownOptions {
28
27
  filePath?: string;
29
28
  slides?: boolean;
30
29
  validatorOptions?: Record<string, unknown>;
31
- literateJavaOutputPaths?: Set<string>;
30
+ literateJavaOutputPaths?: ReadonlySet<string>;
32
31
  sourceUrlPath?: string;
33
- validTargets?: Set<string>;
32
+ validTargets?: ReadonlySet<string>;
34
33
  templateParams?: Record<string, unknown>;
34
+ preserveHtmlComments?: boolean;
35
35
  dependencyCollector?: RenderDependencyCollector;
36
36
  }
37
37
 
@@ -102,6 +102,7 @@ export function createMarkdown(
102
102
  markdown.use(markdownPartialsPlugin, {
103
103
  filePath,
104
104
  templateParams: options.templateParams,
105
+ preserveHtmlComments: options.preserveHtmlComments,
105
106
  dependencyCollector: options.dependencyCollector,
106
107
  });
107
108
  }
@@ -458,18 +459,12 @@ export function createMarkdown(
458
459
  state.tokens = transformedTokens;
459
460
  });
460
461
 
461
- const usedIds = new Map<string, number>();
462
462
  markdown.use(markdownItContainer, 'alert', {
463
463
  marker: '!',
464
464
  validate: function (params: string) {
465
465
  return ALERT_PATTERN.test(params.trim());
466
466
  },
467
- render: function (
468
- tokens: Token[],
469
- idx: number,
470
- _options: unknown,
471
- env: Record<string, unknown>,
472
- ) {
467
+ render: function (tokens: Token[], idx: number) {
473
468
  const matches = tokens[idx].info.trim().match(ALERT_PATTERN);
474
469
 
475
470
  if (tokens[idx].nesting === 1) {
@@ -483,13 +478,7 @@ export function createMarkdown(
483
478
  const displayTitle = title
484
479
  ? markdown.utils.escapeHtml(curlyQuote(title))
485
480
  : capitalize(type || '');
486
- const baseId = textToId(title || type || '');
487
- const titleId = deduplicateId(usedIds, baseId);
488
-
489
- if (!env.alertIds) {
490
- env.alertIds = [];
491
- }
492
- (env.alertIds as string[]).push(titleId);
481
+ const titleId = tokens[idx].attrGet('id')!;
493
482
 
494
483
  let html = `<div class="${classNames.join(' ')}">`;
495
484
  html += `<p class="title" id="${titleId}">${displayTitle}</p>\n`;
@@ -2,6 +2,9 @@ import path from 'path';
2
2
  import { globals } from '../globals';
3
3
  import type { SiteVariables } from '../types';
4
4
 
5
+ /** The search index directory inside the output directory */
6
+ export const SEARCH_INDEX_DIR = 'pagefind';
7
+
5
8
  export function getPackageDir(): string {
6
9
  return path.resolve(import.meta.dir, '..', '..');
7
10
  }
@@ -0,0 +1,18 @@
1
+ import { decodeHTML } from 'entities';
2
+
3
+ /**
4
+ * Converts rendered inline HTML (such as a front matter title rendered as
5
+ * Markdown) into the plain text a reader sees: tags and comments are removed
6
+ * by an HTML parser, and character references are decoded exactly once.
7
+ */
8
+ export function htmlToPlainText(html: string): string {
9
+ let text = '';
10
+ new HTMLRewriter()
11
+ .onDocument({
12
+ text(chunk) {
13
+ text += chunk.text;
14
+ },
15
+ })
16
+ .transform(html);
17
+ return decodeHTML(text);
18
+ }