@abreen/tada 1.0.2 → 1.1.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 (120) hide show
  1. package/README.md +29 -33
  2. package/bin/tada.ts +356 -0
  3. package/bin/validators.test.ts +204 -0
  4. package/bin/validators.ts +83 -0
  5. package/{webpack/apply-base-path-plugin.js → build/apply-base-path-plugin.ts} +16 -7
  6. package/build/bundle.ts +117 -0
  7. package/{webpack/code.test.js → build/code.test.ts} +6 -7
  8. package/build/colors.ts +25 -0
  9. package/build/content-watch.ts +107 -0
  10. package/build/copy.ts +118 -0
  11. package/{webpack/deflist-id-plugin.js → build/deflist-id-plugin.ts} +7 -6
  12. package/{webpack/external-links-plugin.js → build/external-links-plugin.ts} +14 -5
  13. package/build/features.ts +11 -0
  14. package/build/generate-content-assets.ts +315 -0
  15. package/build/generate-favicon.ts +165 -0
  16. package/build/generate-fonts.ts +31 -0
  17. package/{webpack/generate-manifest-plugin.js → build/generate-manifest.ts} +29 -36
  18. package/build/globals.test.ts +101 -0
  19. package/{webpack/globals.js → build/globals.ts} +28 -13
  20. package/{webpack/heading-subtitle-plugin.js → build/heading-subtitle-plugin.ts} +4 -2
  21. package/build/json-schema.test.ts +57 -0
  22. package/build/json-schema.ts +33 -0
  23. package/build/log.test.ts +111 -0
  24. package/build/log.ts +167 -0
  25. package/{webpack/markdown-plugins.test.js → build/markdown-plugins.test.ts} +94 -9
  26. package/{webpack/pagefind-plugin.test.js → build/pagefind.test.ts} +74 -13
  27. package/build/pagefind.ts +339 -0
  28. package/{webpack/pdf-text.js → build/pdf-text.ts} +47 -27
  29. package/build/pipeline.ts +93 -0
  30. package/{webpack/reachability.test.js → build/reachability.test.ts} +3 -3
  31. package/{webpack/reachability.js → build/reachability.ts} +77 -34
  32. package/build/serve.ts +112 -0
  33. package/{webpack/site-variables.js → build/site-variables.ts} +22 -15
  34. package/{webpack → build}/site.schema.json +3 -10
  35. package/{webpack/templates.js → build/templates.ts} +35 -33
  36. package/{webpack/text-to-id.js → build/text-to-id.ts} +2 -2
  37. package/build/toc-plugin.test.ts +105 -0
  38. package/{webpack/toc-plugin.js → build/toc-plugin.ts} +32 -13
  39. package/build/types.ts +172 -0
  40. package/build/util.ts +26 -0
  41. package/{webpack/utils/code.js → build/utils/code.ts} +119 -60
  42. package/{webpack/utils/content-files.js → build/utils/content-files.ts} +40 -35
  43. package/build/utils/derive-theme.test.ts +111 -0
  44. package/build/utils/derive-theme.ts +85 -0
  45. package/build/utils/file-types.test.ts +61 -0
  46. package/build/utils/file-types.ts +13 -0
  47. package/build/utils/front-matter.test.ts +80 -0
  48. package/{webpack/utils/front-matter.js → build/utils/front-matter.ts} +22 -9
  49. package/{webpack → build}/utils/jdi-runner/LiterateRunner.java +1 -1
  50. package/{webpack/utils/literate-java.js → build/utils/literate-java.ts} +63 -34
  51. package/{webpack/utils/markdown.js → build/utils/markdown.ts} +94 -49
  52. package/build/utils/paths.test.ts +91 -0
  53. package/{webpack/utils/paths.js → build/utils/paths.ts} +14 -22
  54. package/{webpack/utils/render.js → build/utils/render.ts} +188 -123
  55. package/build/utils/shiki-highlighter.ts +29 -0
  56. package/build/validate-internal-links-plugin.test.ts +106 -0
  57. package/{webpack/validate-internal-links-plugin.js → build/validate-internal-links-plugin.ts} +47 -20
  58. package/{webpack/watch-reachability-state.test.js → build/watch-reachability-state.test.ts} +8 -8
  59. package/{webpack/watch-reachability-state.js → build/watch-reachability-state.ts} +63 -24
  60. package/{webpack/watch-reload-client.js → build/watch-reload-client.ts} +3 -1
  61. package/build/watch.ts +573 -0
  62. package/content/index.md +9 -3
  63. package/content/markdown.md +2 -1
  64. package/content/problem_sets/index.html +14 -0
  65. package/fonts/google-sans-code/woff2/GoogleSansCodeVariable-Italic.woff2 +0 -0
  66. package/fonts/google-sans-code/woff2/GoogleSansCodeVariable.woff2 +0 -0
  67. package/fonts/inter/woff2/InterVariable-Italic.woff2 +0 -0
  68. package/fonts/inter/woff2/InterVariable.woff2 +0 -0
  69. package/package.json +28 -19
  70. package/src/_alerts.scss +92 -0
  71. package/src/_base.scss +106 -0
  72. package/src/{layout.scss → _layout.scss} +0 -2
  73. package/src/anchor/style.scss +1 -9
  74. package/src/code/index.ts +3 -3
  75. package/src/code.scss +1 -1
  76. package/src/critical.scss +5 -0
  77. package/src/header/_base.scss +129 -0
  78. package/src/header/style.scss +3 -131
  79. package/src/index.ts +1 -2
  80. package/src/question/style.scss +1 -1
  81. package/src/search/index.ts +36 -15
  82. package/src/search/style.scss +9 -15
  83. package/src/style.scss +6 -269
  84. package/src/toc/style.scss +5 -39
  85. package/src/util.ts +8 -5
  86. package/templates/_theme.scss +38 -14
  87. package/tsconfig.json +10 -6
  88. package/types/file-system-access.d.ts +5 -0
  89. package/types/markdown-it-plugins.d.ts +11 -0
  90. package/types/untyped-modules.d.ts +40 -0
  91. package/bin/tada.js +0 -361
  92. package/content/problem_sets/index.md +0 -6
  93. package/webpack/build-state.js +0 -97
  94. package/webpack/colors.js +0 -15
  95. package/webpack/config.base.js +0 -151
  96. package/webpack/config.dev.js +0 -23
  97. package/webpack/config.prod.js +0 -32
  98. package/webpack/content-watch-plugin.js +0 -153
  99. package/webpack/features.js +0 -5
  100. package/webpack/generate-content-assets-plugin.js +0 -308
  101. package/webpack/generate-favicon-plugin.js +0 -198
  102. package/webpack/generate-fonts-plugin.js +0 -69
  103. package/webpack/json-schema.js +0 -19
  104. package/webpack/log.js +0 -143
  105. package/webpack/pagefind-plugin.js +0 -379
  106. package/webpack/print-flair-plugin.js +0 -22
  107. package/webpack/serve.js +0 -104
  108. package/webpack/util.js +0 -49
  109. package/webpack/utils/define-plugin.js +0 -20
  110. package/webpack/utils/file-types.js +0 -26
  111. package/webpack/utils/parse-hsl.js +0 -8
  112. package/webpack/utils/shiki-highlighter.js +0 -26
  113. package/webpack/watch.js +0 -166
  114. /package/{webpack → build}/flair.json +0 -0
  115. /package/{webpack → build}/utils/jdi-runner/LiterateRunner.class +0 -0
  116. /package/fonts/google-sans-code/{GoogleSansCodeVariable-Italic.ttf → ttf/GoogleSansCodeVariable-Italic.ttf} +0 -0
  117. /package/fonts/google-sans-code/{GoogleSansCodeVariable.ttf → ttf/GoogleSansCodeVariable.ttf} +0 -0
  118. /package/fonts/inter/{InterVariable-Italic.ttf → ttf/InterVariable-Italic.ttf} +0 -0
  119. /package/fonts/inter/{InterVariable.ttf → ttf/InterVariable.ttf} +0 -0
  120. /package/types/{dev.ts → dev.d.ts} +0 -0
@@ -1,20 +1,51 @@
1
- const MarkdownIt = require('markdown-it');
2
- const { parse: parseJava } = require('java-parser');
3
- const { JSDOM } = require('jsdom');
4
- const { makeLogger } = require('../log');
5
- const { getHighlighter } = require('./shiki-highlighter');
1
+ import MarkdownIt from 'markdown-it';
2
+ import { parse as parseJava } from 'java-parser';
3
+ import { JSDOM } from 'jsdom';
4
+ import { makeLogger } from '../log.js';
5
+ import { getHighlighter } from './shiki-highlighter.js';
6
+ import externalLinksPlugin from '../external-links-plugin.js';
7
+ import applyBasePathPlugin from '../apply-base-path-plugin.js';
8
+ import type { JavaTocEntry, SiteVariables } from '../types.js';
9
+
10
+ interface CstNode {
11
+ name?: string;
12
+ image?: string;
13
+ startLine?: number;
14
+ startOffset?: number;
15
+ children?: Record<string, CstNode[]>;
16
+ }
17
+
18
+ interface MethodMeta {
19
+ baseName: string;
20
+ line: number;
21
+ params: string[];
22
+ }
23
+
24
+ interface FieldMeta {
25
+ name: string;
26
+ line: number;
27
+ }
28
+
29
+ interface CodeSegment {
30
+ type: string | null;
31
+ lines: string[];
32
+ startLine: number;
33
+ }
6
34
 
7
35
  const log = makeLogger(__filename);
8
36
 
9
37
  const PROSE_LINE = /^\s*\/\/\/(\s|$)/;
10
38
 
11
- function createCodeMarkdown(siteVariables, options = {}) {
39
+ function createCodeMarkdown(
40
+ siteVariables: SiteVariables,
41
+ options: Record<string, unknown> = {},
42
+ ): MarkdownIt {
12
43
  return new MarkdownIt({ html: true, typographer: true })
13
- .use(require('../external-links-plugin'), siteVariables)
14
- .use(require('../apply-base-path-plugin'), siteVariables, options);
44
+ .use(externalLinksPlugin, siteVariables)
45
+ .use(applyBasePathPlugin, siteVariables, options);
15
46
  }
16
47
 
17
- const KIND_LABELS = {
48
+ const KIND_LABELS: Record<string, string> = {
18
49
  constructor: 'Constructor',
19
50
  field: 'Field',
20
51
  method: 'Method',
@@ -27,7 +58,10 @@ const JAVA_TYPE_DECLARATION_NODES = new Set([
27
58
  'recordDeclaration',
28
59
  ]);
29
60
 
30
- function extractJavaMethodMeta(methodNode, requireBody = true) {
61
+ function extractJavaMethodMeta(
62
+ methodNode: CstNode,
63
+ requireBody = true,
64
+ ): MethodMeta | null {
31
65
  const methodBody = methodNode.children?.methodBody?.[0];
32
66
  const hasBody = Boolean(methodBody?.children?.block?.length);
33
67
  if (requireBody && !hasBody) {
@@ -50,7 +84,9 @@ function extractJavaMethodMeta(methodNode, requireBody = true) {
50
84
  };
51
85
  }
52
86
 
53
- function extractJavaConstructorMeta(constructorNode) {
87
+ function extractJavaConstructorMeta(
88
+ constructorNode: CstNode,
89
+ ): MethodMeta | null {
54
90
  const constructorDeclarator =
55
91
  constructorNode.children?.constructorDeclarator?.[0];
56
92
  const identifier =
@@ -69,7 +105,9 @@ function extractJavaConstructorMeta(constructorNode) {
69
105
  };
70
106
  }
71
107
 
72
- function extractParameterNames(formalParameterListNode) {
108
+ function extractParameterNames(
109
+ formalParameterListNode: CstNode | undefined,
110
+ ): string[] {
73
111
  if (!formalParameterListNode) {
74
112
  return [];
75
113
  }
@@ -77,7 +115,7 @@ function extractParameterNames(formalParameterListNode) {
77
115
  const formalParameters =
78
116
  formalParameterListNode.children?.formalParameter || [];
79
117
  return formalParameters
80
- .map(parameterNode => {
118
+ .map((parameterNode: CstNode) => {
81
119
  const regularParameter =
82
120
  parameterNode.children?.variableParaRegularParameter?.[0];
83
121
  if (regularParameter) {
@@ -92,16 +130,19 @@ function extractParameterNames(formalParameterListNode) {
92
130
  parameterNode.children?.variableArityParameter?.[0];
93
131
  return varArgParameter?.children?.Identifier?.[0]?.image || null;
94
132
  })
95
- .filter(Boolean);
133
+ .filter(Boolean) as string[];
96
134
  }
97
135
 
98
- function formatCallableName(baseName, parameterNames) {
136
+ function formatCallableName(
137
+ baseName: string,
138
+ parameterNames: string[],
139
+ ): string {
99
140
  return `${baseName}(${parameterNames.join(', ')})`;
100
141
  }
101
142
 
102
- function collectTokensInOrder(node) {
103
- const tokens = [];
104
- function collect(n) {
143
+ function collectTokensInOrder(node: CstNode): CstNode[] {
144
+ const tokens: CstNode[] = [];
145
+ function collect(n: CstNode | undefined): void {
105
146
  if (!n) {
106
147
  return;
107
148
  }
@@ -119,17 +160,17 @@ function collectTokensInOrder(node) {
119
160
  }
120
161
  }
121
162
  collect(node);
122
- tokens.sort((a, b) => a.startOffset - b.startOffset);
163
+ tokens.sort((a, b) => (a.startOffset ?? 0) - (b.startOffset ?? 0));
123
164
  return tokens;
124
165
  }
125
166
 
126
- function buildTypeString(unannTypeNode) {
167
+ function buildTypeString(unannTypeNode: CstNode): string {
127
168
  return collectTokensInOrder(unannTypeNode)
128
169
  .map(t => (t.image === ',' ? ', ' : t.image))
129
170
  .join('');
130
171
  }
131
172
 
132
- function extractJavaFieldMetas(fieldNode) {
173
+ function extractJavaFieldMetas(fieldNode: CstNode): FieldMeta[] {
133
174
  const unannType = fieldNode.children?.unannType?.[0];
134
175
  if (!unannType) {
135
176
  return [];
@@ -142,7 +183,7 @@ function extractJavaFieldMetas(fieldNode) {
142
183
  return [];
143
184
  }
144
185
 
145
- const results = [];
186
+ const results: FieldMeta[] = [];
146
187
  for (const declarator of variableDeclaratorList.children
147
188
  ?.variableDeclarator || []) {
148
189
  const declaratorId = declarator.children?.variableDeclaratorId?.[0];
@@ -151,7 +192,7 @@ function extractJavaFieldMetas(fieldNode) {
151
192
  continue;
152
193
  }
153
194
 
154
- const dimsNode = declaratorId.children?.dims?.[0];
195
+ const dimsNode = declaratorId?.children?.dims?.[0];
155
196
  const dimsStr = dimsNode
156
197
  ? collectTokensInOrder(dimsNode)
157
198
  .map(t => t.image)
@@ -166,18 +207,24 @@ function extractJavaFieldMetas(fieldNode) {
166
207
  return results;
167
208
  }
168
209
 
169
- function extractJavaMethodToc(sourceCode) {
170
- let cst;
210
+ export function extractJavaMethodToc(sourceCode: string): JavaTocEntry[] {
211
+ let cst: CstNode;
171
212
  try {
172
- cst = parseJava(sourceCode);
173
- } catch (err) {
174
- log.error`Failed to parse Java source for TOC: ${err.message}`;
213
+ cst = parseJava(sourceCode) as CstNode;
214
+ } catch (err: unknown) {
215
+ log.error`Failed to parse Java source for TOC: ${(err as Error).message}`;
175
216
  return [];
176
217
  }
177
218
 
178
- const callables = [];
219
+ const callables: Array<{
220
+ kind: 'method' | 'constructor' | 'field';
221
+ baseName?: string;
222
+ name?: string;
223
+ line: number;
224
+ params?: string[];
225
+ }> = [];
179
226
 
180
- function visit(node, typeDepth) {
227
+ function visit(node: CstNode, typeDepth: number): void {
181
228
  if (!node || !node.name) {
182
229
  return;
183
230
  }
@@ -225,36 +272,44 @@ function extractJavaMethodToc(sourceCode) {
225
272
  return callables.map(callable => {
226
273
  const label = KIND_LABELS[callable.kind] ?? 'Member';
227
274
  if (callable.name !== undefined) {
228
- return { ...callable, label };
275
+ return {
276
+ kind: callable.kind,
277
+ label,
278
+ name: callable.name,
279
+ line: callable.line,
280
+ };
229
281
  }
230
282
  return {
231
283
  kind: callable.kind,
232
284
  label,
233
- name: formatCallableName(callable.baseName, callable.params),
285
+ name: formatCallableName(callable.baseName!, callable.params!),
234
286
  line: callable.line,
235
287
  };
236
288
  });
237
289
  }
238
290
 
239
- function escapeAttr(str) {
291
+ function escapeAttr(str: string): string {
240
292
  return str.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
241
293
  }
242
294
 
243
- function escapeHtml(text) {
295
+ function escapeHtml(text: string): string {
244
296
  return text
245
297
  .replace(/&/g, '&amp;')
246
298
  .replace(/</g, '&lt;')
247
299
  .replace(/>/g, '&gt;');
248
300
  }
249
301
 
250
- function createCodeLine(document) {
302
+ function createCodeLine(document: Document): HTMLSpanElement {
251
303
  const line = document.createElement('span');
252
304
  line.className = 'code-line';
253
305
  return line;
254
306
  }
255
307
 
256
- function cloneOpenElements(openElements, line) {
257
- const containers = [line];
308
+ function cloneOpenElements(
309
+ openElements: Node[],
310
+ line: HTMLSpanElement,
311
+ ): Node[] {
312
+ const containers: Node[] = [line];
258
313
 
259
314
  for (const openElement of openElements) {
260
315
  const clone = openElement.cloneNode(false);
@@ -265,17 +320,20 @@ function cloneOpenElements(openElements, line) {
265
320
  return containers;
266
321
  }
267
322
 
268
- function splitHighlightedHtmlIntoLines(highlightedHtml, lineCount) {
323
+ function splitHighlightedHtmlIntoLines(
324
+ highlightedHtml: string,
325
+ lineCount: number,
326
+ ): string[] {
269
327
  const fragment = JSDOM.fragment(`<code>${highlightedHtml}</code>`);
270
- const codeEl = fragment.firstChild;
328
+ const codeEl = fragment.firstChild as HTMLElement;
271
329
  const document = codeEl.ownerDocument;
272
- const lines = [];
273
- const openElements = [];
330
+ const lines: HTMLSpanElement[] = [];
331
+ const openElements: Node[] = [];
274
332
  let currentLine = createCodeLine(document);
275
- let currentContainers = [currentLine];
333
+ let currentContainers: Node[] = [currentLine];
276
334
  let currentLineHasContent = false;
277
335
 
278
- function finishCurrentLine() {
336
+ function finishCurrentLine(): void {
279
337
  if (!currentLineHasContent) {
280
338
  currentContainers[currentContainers.length - 1].appendChild(
281
339
  document.createTextNode('\u00A0'),
@@ -287,7 +345,7 @@ function splitHighlightedHtmlIntoLines(highlightedHtml, lineCount) {
287
345
  currentLineHasContent = false;
288
346
  }
289
347
 
290
- function visit(node) {
348
+ function visit(node: Node): void {
291
349
  if (node.nodeType === 3) {
292
350
  const parts = (node.textContent || '').split('\n');
293
351
  for (let i = 0; i < parts.length; i++) {
@@ -341,9 +399,13 @@ function splitHighlightedHtmlIntoLines(highlightedHtml, lineCount) {
341
399
  return lines.map(line => line.innerHTML);
342
400
  }
343
401
 
344
- function renderCodeSegment(lines, startLine, lang) {
402
+ export function renderCodeSegment(
403
+ lines: string[],
404
+ startLine: number,
405
+ lang: string,
406
+ ): string {
345
407
  const source = lines.join('\n');
346
- let lineHtml;
408
+ let lineHtml: string[] | undefined;
347
409
 
348
410
  try {
349
411
  const highlighter = getHighlighter();
@@ -353,10 +415,10 @@ function renderCodeSegment(lines, startLine, lang) {
353
415
  defaultColor: false,
354
416
  });
355
417
  const fragment = JSDOM.fragment(html);
356
- const inner = fragment.querySelector('code').innerHTML;
418
+ const inner = (fragment.querySelector('code') as HTMLElement).innerHTML;
357
419
  lineHtml = splitHighlightedHtmlIntoLines(inner, lines.length);
358
- } catch (err) {
359
- log.error`Failed to highlight code block: ${err.message}`;
420
+ } catch (err: unknown) {
421
+ log.error`Failed to highlight code block: ${(err as Error).message}`;
360
422
  }
361
423
 
362
424
  if (!lineHtml) {
@@ -371,7 +433,11 @@ function renderCodeSegment(lines, startLine, lang) {
371
433
  return `<pre>${rows.join('')}</pre>`;
372
434
  }
373
435
 
374
- function renderCodeWithComments(sourceCode, lang, siteVariables) {
436
+ export function renderCodeWithComments(
437
+ sourceCode: string,
438
+ lang: string,
439
+ siteVariables: SiteVariables,
440
+ ): string {
375
441
  const md = createCodeMarkdown(siteVariables);
376
442
  const lines = sourceCode.split('\n');
377
443
  if (lines[lines.length - 1] === '') {
@@ -379,9 +445,9 @@ function renderCodeWithComments(sourceCode, lang, siteVariables) {
379
445
  }
380
446
 
381
447
  // Group lines into segments
382
- const segments = [];
383
- let currentType = null;
384
- let currentLines = [];
448
+ const segments: CodeSegment[] = [];
449
+ let currentType: string | null = null;
450
+ let currentLines: string[] = [];
385
451
  let currentStart = 1;
386
452
 
387
453
  for (let i = 0; i < lines.length; i++) {
@@ -430,10 +496,3 @@ function renderCodeWithComments(sourceCode, lang, siteVariables) {
430
496
  })
431
497
  .join('');
432
498
  }
433
-
434
- module.exports = {
435
- createCodeMarkdown,
436
- extractJavaMethodToc,
437
- renderCodeSegment,
438
- renderCodeWithComments,
439
- };
@@ -1,14 +1,14 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const { parseFrontMatterAndContent } = require('./front-matter');
4
- const {
5
- PUBLIC_ASSET_EXTENSIONS,
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { parseFrontMatterAndContent } from './front-matter.js';
4
+ import {
5
+ getProcessedExtensions,
6
6
  extensionIsMarkdown,
7
7
  isLiterateJava,
8
- } = require('./file-types');
9
- const { getPublicDir, normalizeOutputPath } = require('./paths');
8
+ } from './file-types.js';
9
+ import { getPublicDir, normalizeOutputPath } from './paths.js';
10
10
 
11
- function walkFiles(dir) {
11
+ function walkFiles(dir: string): string[] {
12
12
  return fs.readdirSync(dir).flatMap(file => {
13
13
  const fullPath = path.join(dir, file);
14
14
  if (fs.statSync(fullPath).isDirectory()) {
@@ -18,8 +18,11 @@ function walkFiles(dir) {
18
18
  });
19
19
  }
20
20
 
21
- function getContentFiles(contentDir, codeExtensions) {
22
- const extensions = ['md', 'html', 'pdf', ...codeExtensions];
21
+ export function getContentFiles(
22
+ contentDir: string,
23
+ codeExtensions: string[],
24
+ ): string[] {
25
+ const extensions = ['md', 'html', ...codeExtensions];
23
26
  const pattern = new RegExp(`\\.(${extensions.join('|')})$`);
24
27
 
25
28
  return walkFiles(contentDir).filter(filePath => {
@@ -27,7 +30,7 @@ function getContentFiles(contentDir, codeExtensions) {
27
30
  });
28
31
  }
29
32
 
30
- function shouldSkipContentFile(filePath) {
33
+ export function shouldSkipContentFile(filePath: string): boolean {
31
34
  const ext = path.extname(filePath).toLowerCase();
32
35
  if (!(extensionIsMarkdown(ext) || ext === '.html')) {
33
36
  return false;
@@ -38,13 +41,19 @@ function shouldSkipContentFile(filePath) {
38
41
  return pageVariables?.skip === true;
39
42
  }
40
43
 
41
- function getBuildContentFiles(contentDir, codeExtensions) {
44
+ export function getBuildContentFiles(
45
+ contentDir: string,
46
+ codeExtensions: string[],
47
+ ): string[] {
42
48
  return getContentFiles(contentDir, codeExtensions).filter(
43
49
  filePath => !shouldSkipContentFile(filePath),
44
50
  );
45
51
  }
46
52
 
47
- function addGeneratedRouteAliases(pathSet, outputPath) {
53
+ function addGeneratedRouteAliases(
54
+ pathSet: Set<string>,
55
+ outputPath: string,
56
+ ): void {
48
57
  const normalizedPath = normalizeOutputPath(outputPath);
49
58
  pathSet.add(normalizedPath);
50
59
 
@@ -59,7 +68,7 @@ function addGeneratedRouteAliases(pathSet, outputPath) {
59
68
  }
60
69
  }
61
70
 
62
- function getPublicFiles(publicDir) {
71
+ function getPublicFiles(publicDir: string): string[] {
63
72
  if (!fs.existsSync(publicDir)) {
64
73
  return [];
65
74
  }
@@ -67,7 +76,10 @@ function getPublicFiles(publicDir) {
67
76
  return walkFiles(publicDir);
68
77
  }
69
78
 
70
- function getFilesByExtensions(rootDir, extensions) {
79
+ export function getFilesByExtensions(
80
+ rootDir: string,
81
+ extensions: string[],
82
+ ): string[] {
71
83
  if (!fs.existsSync(rootDir)) {
72
84
  return [];
73
85
  }
@@ -80,12 +92,15 @@ function getFilesByExtensions(rootDir, extensions) {
80
92
  });
81
93
  }
82
94
 
83
- function getValidInternalTargets(contentDir, contentFiles, codeExtensions) {
84
- const targets = new Set();
95
+ export function getValidInternalTargets(
96
+ contentDir: string,
97
+ contentFiles: string[],
98
+ codeExtensions: string[],
99
+ ): Set<string> {
100
+ const targets = new Set<string>();
85
101
  const codeExtensionSet = new Set(
86
102
  codeExtensions.map(ext => ext.toLowerCase()),
87
103
  );
88
- const contentAssetExtensionSet = new Set(PUBLIC_ASSET_EXTENSIONS);
89
104
 
90
105
  for (const filePath of contentFiles) {
91
106
  const relPath = path.relative(contentDir, filePath);
@@ -110,21 +125,19 @@ function getValidInternalTargets(contentDir, contentFiles, codeExtensions) {
110
125
  );
111
126
  } else if (extensionIsMarkdown(ext) || ext === '.html') {
112
127
  addGeneratedRouteAliases(targets, `/${subPath}.html`);
113
- } else if (ext === '.pdf') {
114
- targets.add(normalizeOutputPath(`/${relPath}`));
115
128
  } else if (codeExtensionSet.has(ext.slice(1))) {
116
129
  addGeneratedRouteAliases(targets, `/${subPath}.html`);
117
130
  targets.add(normalizeOutputPath(`/${relPath}`));
118
- } else if (contentAssetExtensionSet.has(ext.slice(1))) {
119
- targets.add(normalizeOutputPath(`/${relPath}`));
120
131
  }
121
132
  }
122
133
 
123
- // Include non-page assets in content/ that are copied directly to dist/.
124
- for (const filePath of getFilesByExtensions(
125
- contentDir,
126
- PUBLIC_ASSET_EXTENSIONS,
127
- )) {
134
+ // Include non-processed assets in content/ that are copied directly to dist/.
135
+ const processedExtSet = new Set(getProcessedExtensions(codeExtensions));
136
+ for (const filePath of walkFiles(contentDir)) {
137
+ const ext = path.extname(filePath).slice(1).toLowerCase();
138
+ if (processedExtSet.has(ext)) {
139
+ continue;
140
+ }
128
141
  const relPath = path.relative(contentDir, filePath);
129
142
  targets.add(normalizeOutputPath(`/${relPath}`));
130
143
  }
@@ -137,11 +150,3 @@ function getValidInternalTargets(contentDir, contentFiles, codeExtensions) {
137
150
 
138
151
  return targets;
139
152
  }
140
-
141
- module.exports = {
142
- extensionIsMarkdown,
143
- getBuildContentFiles,
144
- getContentFiles,
145
- getValidInternalTargets,
146
- shouldSkipContentFile,
147
- };
@@ -0,0 +1,111 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { parse, oklch } from 'culori';
3
+ import { deriveTheme, getTextOnColor } from './derive-theme.js';
4
+
5
+ function getL(hex: string): number {
6
+ return oklch(parse(hex))!.l;
7
+ }
8
+
9
+ describe('deriveTheme', () => {
10
+ test('returns valid hex strings for all outputs', () => {
11
+ const result = deriveTheme('steelblue');
12
+ for (const value of Object.values(result)) {
13
+ expect(value).toMatch(/^#[0-9a-f]{3,6}$/);
14
+ }
15
+ });
16
+
17
+ test('light theme color lightness is in range', () => {
18
+ for (const color of [
19
+ 'cornsilk',
20
+ 'navy',
21
+ 'steelblue',
22
+ 'tomato',
23
+ '#000',
24
+ '#fff',
25
+ ]) {
26
+ const { themeColorLight } = deriveTheme(color);
27
+ const l = getL(themeColorLight);
28
+ expect(l).toBeGreaterThanOrEqual(0.34);
29
+ expect(l).toBeLessThanOrEqual(0.63);
30
+ }
31
+ });
32
+
33
+ test('dark theme color lightness is in range', () => {
34
+ for (const color of [
35
+ 'cornsilk',
36
+ 'navy',
37
+ 'steelblue',
38
+ 'tomato',
39
+ '#000',
40
+ '#fff',
41
+ ]) {
42
+ const { themeColorDark } = deriveTheme(color);
43
+ const l = getL(themeColorDark);
44
+ expect(l).toBeGreaterThanOrEqual(0.54);
45
+ expect(l).toBeLessThanOrEqual(0.81);
46
+ }
47
+ });
48
+
49
+ test('light text color lightness is in range', () => {
50
+ for (const color of ['cornsilk', 'navy', 'steelblue', 'tomato', 'lime']) {
51
+ const { themeColorTextLight } = deriveTheme(color);
52
+ const l = getL(themeColorTextLight);
53
+ expect(l).toBeGreaterThanOrEqual(0.34);
54
+ expect(l).toBeLessThanOrEqual(0.51);
55
+ }
56
+ });
57
+
58
+ test('dark text color lightness is in range', () => {
59
+ for (const color of ['cornsilk', 'navy', 'steelblue', 'tomato', 'lime']) {
60
+ const { themeColorTextDark } = deriveTheme(color);
61
+ const l = getL(themeColorTextDark);
62
+ expect(l).toBeGreaterThanOrEqual(0.69);
63
+ expect(l).toBeLessThanOrEqual(0.83);
64
+ }
65
+ });
66
+
67
+ test('white text on dark colors', () => {
68
+ expect(deriveTheme('navy').textOnThemeLight).toBe('#fff');
69
+ expect(deriveTheme('#000').textOnThemeLight).toBe('#fff');
70
+ expect(deriveTheme('hsl(351 70% 40%)').textOnThemeLight).toBe('#fff');
71
+ });
72
+
73
+ test('black text on bright colors', () => {
74
+ expect(deriveTheme('lime').textOnThemeLight).toBe('#000');
75
+ });
76
+
77
+ test('white text on saturated and mid-tone colors', () => {
78
+ expect(deriveTheme('steelblue').textOnThemeLight).toBe('#fff');
79
+ expect(deriveTheme('teal').textOnThemeLight).toBe('#fff');
80
+ expect(deriveTheme('tomato').textOnThemeLight).toBe('#fff');
81
+ expect(deriveTheme('cornsilk').textOnThemeLight).toBe('#fff');
82
+ expect(deriveTheme('hsl(195 70% 40%)').textOnThemeLight).toBe('#fff');
83
+ });
84
+
85
+ test('steelblue produces values close to original', () => {
86
+ const result = deriveTheme('steelblue');
87
+ // steelblue OKLCH L is ~0.588 which is within light theme range,
88
+ // so it should be unchanged (or very close)
89
+ expect(result.themeColorLight).toBe('#4682b4');
90
+ });
91
+
92
+ test('throws on invalid color', () => {
93
+ expect(() => deriveTheme('notacolor')).toThrow('Invalid color');
94
+ });
95
+ });
96
+
97
+ describe('getTextOnColor', () => {
98
+ test('returns white for dark colors', () => {
99
+ expect(getTextOnColor('navy')).toBe('#fff');
100
+ expect(getTextOnColor('#000')).toBe('#fff');
101
+ });
102
+
103
+ test('returns black for bright colors', () => {
104
+ expect(getTextOnColor('cornsilk')).toBe('#000');
105
+ expect(getTextOnColor('#fff')).toBe('#000');
106
+ });
107
+
108
+ test('throws on invalid color', () => {
109
+ expect(() => getTextOnColor('notacolor')).toThrow('Invalid color');
110
+ });
111
+ });
@@ -0,0 +1,85 @@
1
+ import { parse, oklch, rgb, toGamut, formatHex } from 'culori';
2
+ import type { Oklch, Rgb } from 'culori';
3
+ import type { DerivedTheme } from '../types.js';
4
+
5
+ // OKLCH lightness range for the theme color used as backgrounds/outlines
6
+ const LIGHT_THEME_L_MIN = 0.35;
7
+ const LIGHT_THEME_L_MAX = 0.62;
8
+ const DARK_THEME_L_MIN = 0.55;
9
+ const DARK_THEME_L_MAX = 0.8;
10
+
11
+ // OKLCH lightness range for theme-derived text on page backgrounds
12
+ const LIGHT_TEXT_L_MIN = 0.35;
13
+ const LIGHT_TEXT_L_MAX = 0.5;
14
+ const DARK_TEXT_L_MIN = 0.7;
15
+ const DARK_TEXT_L_MAX = 0.82;
16
+
17
+ function clamp(v: number, min: number, max: number): number {
18
+ return Math.min(Math.max(v, min), max);
19
+ }
20
+
21
+ function linearize(c: number): number {
22
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
23
+ }
24
+
25
+ function relativeLuminance({ r, g, b }: Rgb): number {
26
+ return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
27
+ }
28
+
29
+ function contrastRatio(l1: number, l2: number): number {
30
+ return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
31
+ }
32
+
33
+ // Pick black or white text for use on the given background color.
34
+ // Prefer white unless black has substantially better contrast (1.5x),
35
+ // which gives white text on saturated mid-tone colors like steelblue and tomato.
36
+ function pickTextColor(bgHex: string): '#fff' | '#000' {
37
+ const bgRgb = rgb(parse(bgHex)) as Rgb;
38
+ const bgLum = relativeLuminance(bgRgb);
39
+ const whiteContrast = contrastRatio(1, bgLum);
40
+ const blackContrast = contrastRatio(bgLum, 0);
41
+ return blackContrast > whiteContrast * 1.9 ? '#000' : '#fff';
42
+ }
43
+
44
+ function toHex(oklchColor: Oklch): string {
45
+ return formatHex(toGamut('rgb', 'oklch')(oklchColor));
46
+ }
47
+
48
+ export function deriveTheme(cssColor: string): DerivedTheme {
49
+ const parsed = parse(cssColor);
50
+ if (!parsed) {
51
+ throw new Error(`Invalid color: ${cssColor}`);
52
+ }
53
+
54
+ const base = oklch(parsed) as Oklch;
55
+ const l = base.l;
56
+ const c = base.c || 0;
57
+ const h = base.h;
58
+
59
+ const lightL = clamp(l, LIGHT_THEME_L_MIN, LIGHT_THEME_L_MAX);
60
+ const darkL = clamp(l, DARK_THEME_L_MIN, DARK_THEME_L_MAX);
61
+
62
+ const textLightL = clamp(l, LIGHT_TEXT_L_MIN, LIGHT_TEXT_L_MAX);
63
+ const textDarkL = clamp(l, DARK_TEXT_L_MIN, DARK_TEXT_L_MAX);
64
+
65
+ const themeColorLight = toHex({ mode: 'oklch', l: lightL, c, h });
66
+ const themeColorDark = toHex({ mode: 'oklch', l: darkL, c, h });
67
+
68
+ return {
69
+ themeColorLight,
70
+ themeColorDark,
71
+ themeColorTextLight: toHex({ mode: 'oklch', l: textLightL, c, h }),
72
+ themeColorTextDark: toHex({ mode: 'oklch', l: textDarkL, c, h }),
73
+ textOnThemeLight: pickTextColor(themeColorLight),
74
+ textOnThemeDark: pickTextColor(themeColorDark),
75
+ };
76
+ }
77
+
78
+ export function getTextOnColor(cssColor: string): '#fff' | '#000' {
79
+ const parsed = parse(cssColor);
80
+ if (!parsed) {
81
+ throw new Error(`Invalid color: ${cssColor}`);
82
+ }
83
+ const gamutMapped = formatHex(toGamut('rgb', 'oklch')(parsed));
84
+ return pickTextColor(gamutMapped);
85
+ }