@abreen/tada 1.0.1 → 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 -147
  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
package/README.md CHANGED
@@ -11,12 +11,12 @@ A static site generator. The successor to Presto.
11
11
  * Renders headings, alert boxes, and `<hr>` elements
12
12
  * Highlights the heading currently being viewed
13
13
  - Built-in search powered by [Pagefind][pagefind]
14
- * Only pages reachable by links on the site appear in search results
14
+ * Only pages in `content/` reachable from `/index.html` are indexed
15
15
  - Generated HTML pages for source code
16
16
  * Automatic code highlighting, clickable line numbers
17
17
  * Dynamic table of contents for each method/function
18
18
  * Converts new Markdown comment syntax ([added in Java 23][jep467]) to HTML
19
- * Indexed by Pagefind
19
+ * Indexed by Pagefind (classes, interfaces, methods, and fields)
20
20
  - PDF files are copied into `dist/`
21
21
  - Text of each PDF page is extracted using [mutool][mutool] and indexed
22
22
  - External link handling (special visual treatment for external links)
@@ -69,14 +69,14 @@ Visit [http://localhost:8080/index.html](http://localhost:8080/index.html).
69
69
  ### `tada init <dirname>`
70
70
 
71
71
  Create a new Tada site in a new directory. Prompts for:
72
- - **Site title** --- displayed in the header and `<title>` tag
73
- - **Symbol** --- short text (1-5 uppercase characters) shown in the logo and favicon
74
- - **Theme color** --- HSL color, e.g. `hsl(195 70% 40%)`
75
- - **Background tint hue** --- hue (0-360) for background/foreground tinting (defaults to `20`)
76
- - **Background tint amount** --- percentage (0-100) of tint to apply (defaults to `100`)
77
- - **Default time zone** --- for `<datetime>` elements (defaults to your system zone)
78
- - **Production base URL** --- e.g. `https://example.edu`
79
- - **Production base path** --- e.g. `/cs101` (defaults to `/`)
72
+ - **Site title**: displayed in the header and `<title>` tag
73
+ - **Symbol**: short text (1-5 uppercase characters) shown in the logo and favicon
74
+ - **Theme color**: HSL color, e.g. `hsl(195 70% 40%)`
75
+ - **Background tint hue**: hue (0-360) for background/foreground tinting (defaults to `20`)
76
+ - **Background tint amount**: percentage (0-100) of tint to apply (defaults to `100`)
77
+ - **Default time zone**: for `<time>` elements (defaults to your system zone)
78
+ - **Production base URL**: e.g. `https://example.edu`
79
+ - **Production base path**: e.g. `/cs101` (defaults to `/`)
80
80
 
81
81
  Pass `--default` to use the default values for all options without being
82
82
  prompted.
@@ -84,7 +84,7 @@ prompted.
84
84
 
85
85
  ### `tada dev`
86
86
 
87
- Build the site for local development (using `config/site.dev.json`)
87
+ Build the site for local development (using `site.dev.json`)
88
88
  into the `dist/` directory.
89
89
 
90
90
 
@@ -106,7 +106,7 @@ Remove the `dist/` directory.
106
106
 
107
107
  ### `tada prod`
108
108
 
109
- Build the site for production (uses `config/site.prod.json`).
109
+ Build the site for production (uses `site.prod.json`).
110
110
 
111
111
 
112
112
  ## Prerequisites
@@ -125,10 +125,10 @@ Build the site for production (uses `config/site.prod.json`).
125
125
 
126
126
  Build-time site config lives in:
127
127
 
128
- - `config/site.dev.json` (used by `tada dev` / `tada watch`)
129
- - `config/site.prod.json` (used by `tada prod`)
130
- - `config/nav.json` (navigation structure)
131
- - `config/authors.json` (author data)
128
+ - `site.dev.json` (used by `tada dev` / `tada watch`)
129
+ - `site.prod.json` (used by `tada prod`)
130
+ - `nav.json` (navigation structure)
131
+ - `authors.json` (author data)
132
132
 
133
133
  Example site configuration JSON file:
134
134
 
@@ -156,7 +156,7 @@ Example site configuration JSON file:
156
156
  | `title` | Title for the whole site (also used to derive `titlePostfix`) |
157
157
  | `titlePostfix` | *Optional*, the string to append to each page's `title` |
158
158
  | `symbol` | Text (1-5 chars) displayed in header (also used as the favicon symbol) |
159
- | `themeColor` | HSL theme color for the site, e.g. `"hsl(195 70% 40%)"` |
159
+ | `themeColor` | Theme color for the site (e.g., `"tomato"`, `"#c04040"`, `"hsl(195 70% 40%)"`) |
160
160
  | `tintHue` | *Optional*, hue (0-360) for background and foreground tinting (default `20`) |
161
161
  | `tintAmount` | *Optional*, percentage (0-100) of tint to apply (default `100`) |
162
162
  | `faviconSymbol` | *Optional*, the text to use instead of `symbol` in the favicon |
@@ -167,7 +167,7 @@ Example site configuration JSON file:
167
167
  | `basePath` | URL prefix for deployment under a subpath (e.g., `"/cs101"`), use `"/"` at root |
168
168
  | `internalDomains` | Domain names treated as internal by link processing (not marked external) |
169
169
  | `codeLanguages` | Map file extension to Shiki language (e.g., `"java": "java"`) |
170
- | `faviconColor` | *Optional*, HSL color override for favicon (defaults to `themeColor`) |
170
+ | `faviconColor` | *Optional*, background color for favicon (defaults to `themeColor`) |
171
171
  | `faviconFontWeight` | *Optional*, font weight used for favicon text (default `700`) |
172
172
  | `vars` | Arbitrary key/value variables exposed to templates/content (e.g., `<%= staffEmail %>`) |
173
173
 
@@ -239,12 +239,12 @@ TADA_LOG_LEVEL=debug tada dev
239
239
  ## Content
240
240
 
241
241
  Site content lives in the `content/` directory. Markdown is converted to HTML.
242
- HTML files should contain front matter and are also built, but not processed
243
- like Markdown files are.
242
+ HTML files should contain front matter and are also built.
244
243
 
245
- PDF, `.txt`, ZIP, images, and other kinds of files are copied into `dist/`
246
- in the same locations. All files in `public/` are copied directly into `dist/`
247
- with zero processing.
244
+ Any other kinds of files are copied into `dist/` in the same locations.
245
+
246
+ All files in `public/` are copied directly into `dist/` with zero processing.
247
+ Files in `public/` are **not** included in the search index.
248
248
 
249
249
 
250
250
  ### Front matter fields
@@ -256,31 +256,27 @@ list of variables parsed using the [`front-matter`][front-matter] library).
256
256
  |-------|-------------|
257
257
  | `title` (required) | Page title (`<title>` tag and page heading) |
258
258
  | `skip` | Set to `true` to skip building this page completely |
259
- | `author` | Author handle (e.g. `jsmith`) resolved to a full object via `config/authors.json` |
259
+ | `author` | Author handle (e.g. `jsmith`) resolved to a full object via `authors.json` |
260
260
  | `description` | Meta description for the page |
261
261
  | `toc` | Set to `true` to show a table of contents |
262
262
  | `parent` & `parentLabel` | URL and label for a breadcrumb link displayed above the title |
263
263
  | `published` | Year, month, and day of publishing (e.g, `2025-09-09`) |
264
264
 
265
+ You may also add arbitrary fields in a page's front matter, and access them
266
+ using Lodash syntax (see below).
267
+
265
268
 
266
269
  ### Variable substitution
267
270
 
268
271
  Plain text content (e.g., HTML and Markdown) are processed using [Lodash
269
272
  templates][lodash].
270
273
 
271
- - Site config values are available under `site` (e.g., `site.title`, `site.symbol`)
272
- - Page variables (from front matter) are available under `page` (e.g., `page.author`)
274
+ - Site config values are available under `site` (e.g., `site.title`)
275
+ - Page variables (from front matter) are available under `page`
273
276
  - Custom variables from the `"vars"` property of the config are available
274
277
  without any prefix (e.g., `<%= staffEmail %>`)
275
278
 
276
279
 
277
- ## Templates
278
-
279
- HTML page layouts are internal to the Tada package. You don't need to modify
280
- them; they are designed to work with the client-side components and styles
281
- bundled in the package.
282
-
283
-
284
280
 
285
281
  [inter]: https://fonts.google.com/specimen/Inter
286
282
  [lodash]: https://lodash.info/doc/template
package/bin/tada.ts ADDED
@@ -0,0 +1,356 @@
1
+ #!/usr/bin/env bun
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { execSync } from 'child_process';
5
+ import packageJson from '../package.json' with { type: 'json' };
6
+ import {
7
+ validateSymbol,
8
+ validateColor,
9
+ validateHue,
10
+ validateUrl,
11
+ validateBasePath,
12
+ createSiteConfig,
13
+ } from './validators.js';
14
+
15
+ const { version } = packageJson;
16
+
17
+ const SYSTEM_TIME_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
18
+
19
+ const packageDir = path.resolve(__dirname, '..');
20
+
21
+ function requireSiteConfig(env: string): void {
22
+ const configPath = path.resolve(process.cwd(), `site.${env}.json`);
23
+ if (!fs.existsSync(configPath)) {
24
+ console.error(`Error: Missing config file: site.${env}.json`);
25
+ process.exit(1);
26
+ }
27
+ }
28
+
29
+ const COMMANDS = {
30
+ init: null,
31
+ dev: 'Build the site for development',
32
+ prod: 'Build the site for production',
33
+ watch: 'Watch for changes and rebuild',
34
+ serve: 'Start a local development server',
35
+ clean: null,
36
+ };
37
+
38
+ interface InitQuestion {
39
+ prompt: string;
40
+ defaultValue: string;
41
+ validate: (value: string) => string | null;
42
+ }
43
+
44
+ const INIT_QUESTIONS: Record<string, InitQuestion> = {
45
+ title: {
46
+ prompt: 'Site title',
47
+ defaultValue: 'Introduction to Computer Science',
48
+ validate: (v: string) => (v ? null : 'Title is required'),
49
+ },
50
+ symbol: {
51
+ prompt: 'Logo symbol (1-5 uppercase chars)',
52
+ defaultValue: 'CS 0',
53
+ validate: validateSymbol,
54
+ },
55
+ themeColor: {
56
+ prompt: 'Theme color',
57
+ defaultValue: 'hsl(195 70% 40%)',
58
+ validate: validateColor,
59
+ },
60
+ tintHue: {
61
+ prompt: 'Background tint hue (0-360)',
62
+ defaultValue: '20',
63
+ validate: validateHue,
64
+ },
65
+ tintAmount: {
66
+ prompt: 'Background tint amount (0-100)',
67
+ defaultValue: '100',
68
+ validate: (v: string) => {
69
+ const n = Number(v);
70
+ if (!Number.isInteger(n) || n < 0 || n > 100) {
71
+ return 'Must be an integer from 0 to 100';
72
+ }
73
+ return null;
74
+ },
75
+ },
76
+ defaultTimeZone: {
77
+ prompt: 'Default time zone',
78
+ defaultValue: SYSTEM_TIME_ZONE,
79
+ validate: (v: string) => (v ? null : 'Time zone is required'),
80
+ },
81
+ prodBase: {
82
+ prompt: 'Production base URL',
83
+ defaultValue: 'https://example.edu',
84
+ validate: validateUrl,
85
+ },
86
+ prodBasePath: {
87
+ prompt: 'Production base path',
88
+ defaultValue: '/',
89
+ validate: validateBasePath,
90
+ },
91
+ };
92
+
93
+ function printUsage() {
94
+ console.log(`tada v${version}\n`);
95
+ console.log('Usage: tada <command>\n');
96
+ console.log('Commands:');
97
+ for (const [cmd, desc] of Object.entries(COMMANDS)) {
98
+ if (cmd === 'init') {
99
+ console.log(' init <dirname> Initialize a new site');
100
+ console.log(' [--default] (Use defaults for all options)');
101
+ console.log(
102
+ ' [--bare] (Create a minimal site with one page)',
103
+ );
104
+ continue;
105
+ } else if (cmd === 'clean') {
106
+ console.log(' clean Remove the dist/ directory');
107
+ continue;
108
+ }
109
+ console.log(` ${cmd.padEnd(18)} ${desc}`);
110
+ }
111
+ }
112
+
113
+ function run(cmd: string): void {
114
+ execSync(cmd, { cwd: process.cwd(), stdio: 'inherit' });
115
+ }
116
+
117
+ function copyDirRecursive(src: string, dest: string): void {
118
+ fs.mkdirSync(dest, { recursive: true });
119
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
120
+ const srcPath = path.join(src, entry.name);
121
+ const destPath = path.join(dest, entry.name);
122
+ if (entry.isDirectory()) {
123
+ copyDirRecursive(srcPath, destPath);
124
+ } else {
125
+ fs.copyFileSync(srcPath, destPath);
126
+ }
127
+ }
128
+ }
129
+
130
+ async function initCommand(args: string[]): Promise<void> {
131
+ const useDefaults = args.includes('--default');
132
+ const bare = args.includes('--bare');
133
+ const dirname = args.filter(a => !a.startsWith('--'))[0];
134
+
135
+ if (!dirname) {
136
+ console.error('Error: Provide a name for the new directory');
137
+ console.log('Usage: tada init <dirname> [--default] [--bare]');
138
+ process.exit(1);
139
+ }
140
+
141
+ const projectDir = path.resolve(process.cwd(), dirname);
142
+ if (fs.existsSync(projectDir)) {
143
+ console.error(`Error: "${dirname}" already exists`);
144
+ process.exit(1);
145
+ }
146
+
147
+ const message = `Creating a new Tada site in ${projectDir}`;
148
+ if (useDefaults && bare) {
149
+ console.log(message + ' using default config (bare)');
150
+ } else if (useDefaults) {
151
+ console.log(message + ' using default config');
152
+ } else if (bare) {
153
+ console.log(message + ' (bare)');
154
+ } else {
155
+ console.log(message);
156
+ }
157
+
158
+ const config: Record<string, string> = {};
159
+
160
+ if (useDefaults) {
161
+ for (const [key, { defaultValue }] of Object.entries(INIT_QUESTIONS)) {
162
+ config[key] = defaultValue;
163
+ }
164
+ } else {
165
+ const questions = Object.entries(INIT_QUESTIONS);
166
+ let qi = 0;
167
+ let { prompt, defaultValue, validate } = questions[qi][1];
168
+ let suffix = defaultValue != null ? ` (default: ${defaultValue})` : '';
169
+ process.stdout.write(`${prompt}${suffix}? `);
170
+
171
+ for await (const line of console) {
172
+ const value = line.trim() || defaultValue || '';
173
+
174
+ if (validate) {
175
+ const error = validate(value);
176
+ if (error) {
177
+ console.error(`Error: ${error}`);
178
+ process.stdout.write(`${prompt}${suffix}? `);
179
+ continue;
180
+ }
181
+ }
182
+
183
+ config[questions[qi][0]] = value;
184
+ qi++;
185
+
186
+ if (qi >= questions.length) {
187
+ break;
188
+ }
189
+
190
+ ({ prompt, defaultValue, validate } = questions[qi][1]);
191
+
192
+ suffix = defaultValue != null ? ` (default: ${defaultValue})` : '';
193
+
194
+ process.stdout.write(`${prompt}${suffix}? `);
195
+ }
196
+ }
197
+
198
+ const {
199
+ title,
200
+ symbol,
201
+ themeColor,
202
+ tintHue,
203
+ tintAmount,
204
+ defaultTimeZone,
205
+ prodBase,
206
+ prodBasePath,
207
+ } = config;
208
+
209
+ // Derive internal domain from production base URL
210
+ const prodDomain = new URL(prodBase).hostname;
211
+
212
+ // Create project directory
213
+ fs.mkdirSync(projectDir);
214
+
215
+ // Generate site configs
216
+ const devConfig = createSiteConfig({
217
+ title,
218
+ symbol,
219
+ themeColor,
220
+ tintHue,
221
+ tintAmount,
222
+ defaultTimeZone,
223
+ base: 'http://localhost:8080',
224
+ basePath: '/',
225
+ internalDomains: ['localhost'],
226
+ });
227
+
228
+ const prodConfig = createSiteConfig({
229
+ title,
230
+ symbol,
231
+ themeColor,
232
+ tintHue,
233
+ tintAmount,
234
+ defaultTimeZone,
235
+ base: prodBase,
236
+ basePath: prodBasePath,
237
+ internalDomains: [prodDomain],
238
+ });
239
+
240
+ fs.writeFileSync(
241
+ path.join(projectDir, 'site.dev.json'),
242
+ JSON.stringify(devConfig, null, 2) + '\n',
243
+ );
244
+
245
+ fs.writeFileSync(
246
+ path.join(projectDir, 'site.prod.json'),
247
+ JSON.stringify(prodConfig, null, 2) + '\n',
248
+ );
249
+
250
+ if (bare) {
251
+ fs.mkdirSync(path.join(projectDir, 'content'), { recursive: true });
252
+ fs.writeFileSync(
253
+ path.join(projectDir, 'content', 'index.md'),
254
+ 'title: Home\n\nWelcome to your new site.\n',
255
+ );
256
+ fs.mkdirSync(path.join(projectDir, 'public'), { recursive: true });
257
+ fs.writeFileSync(
258
+ path.join(projectDir, 'nav.json'),
259
+ JSON.stringify(
260
+ [
261
+ {
262
+ title: 'Navigation',
263
+ links: [{ text: 'Home', internal: '/index.html' }],
264
+ },
265
+ ],
266
+ null,
267
+ 2,
268
+ ) + '\n',
269
+ );
270
+ } else {
271
+ // Copy nav and authors data files to the project root
272
+ fs.copyFileSync(
273
+ path.join(packageDir, 'config/nav.json'),
274
+ path.join(projectDir, 'nav.json'),
275
+ );
276
+ fs.copyFileSync(
277
+ path.join(packageDir, 'config/authors.json'),
278
+ path.join(projectDir, 'authors.json'),
279
+ );
280
+
281
+ // Copy content/ and public/ from the package
282
+ copyDirRecursive(
283
+ path.join(packageDir, 'content'),
284
+ path.join(projectDir, 'content'),
285
+ );
286
+ copyDirRecursive(
287
+ path.join(packageDir, 'public'),
288
+ path.join(projectDir, 'public'),
289
+ );
290
+ }
291
+
292
+ console.log(`\nGenerated a new site in ${projectDir}`);
293
+ console.log(`\nNext steps:`);
294
+ console.log(` cd ${dirname}`);
295
+ console.log(` tada dev`);
296
+ console.log(` tada serve`);
297
+ }
298
+
299
+ /*
300
+ * Start of script
301
+ */
302
+
303
+ const command = process.argv[2];
304
+
305
+ switch (command) {
306
+ case 'init':
307
+ initCommand(process.argv.slice(3));
308
+ break;
309
+
310
+ case 'dev':
311
+ requireSiteConfig('dev');
312
+ run(`bun ${path.join(packageDir, 'build/pipeline.ts')} dev`);
313
+ break;
314
+
315
+ case 'prod':
316
+ requireSiteConfig('prod');
317
+ run(`bun ${path.join(packageDir, 'build/pipeline.ts')} prod`);
318
+ break;
319
+
320
+ case 'watch': {
321
+ requireSiteConfig('dev');
322
+ const watchArgs = process.argv.slice(3).join(' ');
323
+ run(
324
+ `bun ${path.join(packageDir, 'build/watch.ts')}${watchArgs ? ' ' + watchArgs : ''}`,
325
+ );
326
+ break;
327
+ }
328
+
329
+ case 'serve': {
330
+ const serveArgs = process.argv.slice(3).join(' ');
331
+ run(
332
+ `bun ${path.join(packageDir, 'build/serve.ts')}${serveArgs ? ' ' + serveArgs : ''}`,
333
+ );
334
+ break;
335
+ }
336
+
337
+ case 'clean':
338
+ fs.rmSync(path.resolve(process.cwd(), 'dist'), {
339
+ recursive: true,
340
+ force: true,
341
+ });
342
+ console.log('Cleaned dist/');
343
+ break;
344
+
345
+ case '--version':
346
+ case '-v':
347
+ console.log(`tada v${version}`);
348
+ break;
349
+
350
+ default:
351
+ printUsage();
352
+ if (command && command !== '--help' && command !== '-h') {
353
+ process.exit(1);
354
+ }
355
+ break;
356
+ }
@@ -0,0 +1,204 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { SiteConfigInput } from '../build/types.js';
3
+ import {
4
+ validateSymbol,
5
+ validateColor,
6
+ validateHue,
7
+ validateUrl,
8
+ validateBasePath,
9
+ createSiteConfig,
10
+ } from './validators.js';
11
+
12
+ describe('validateSymbol', () => {
13
+ test('accepts a single uppercase letter', () => {
14
+ expect(validateSymbol('A')).toBeNull();
15
+ });
16
+
17
+ test('accepts digits and uppercase letters up to 5 chars', () => {
18
+ expect(validateSymbol('CS 0')).toBeNull();
19
+ expect(validateSymbol('CS101')).toBeNull();
20
+ expect(validateSymbol('AB-CD')).toBeNull();
21
+ });
22
+
23
+ test('rejects empty string', () => {
24
+ expect(validateSymbol('')).not.toBeNull();
25
+ });
26
+
27
+ test('rejects value longer than 5 chars', () => {
28
+ expect(validateSymbol('TOOLONG')).not.toBeNull();
29
+ });
30
+
31
+ test('rejects lowercase letters', () => {
32
+ expect(validateSymbol('abc')).not.toBeNull();
33
+ });
34
+
35
+ test('rejects special characters', () => {
36
+ expect(validateSymbol('A@B')).not.toBeNull();
37
+ expect(validateSymbol('A.B')).not.toBeNull();
38
+ });
39
+ });
40
+
41
+ describe('validateColor', () => {
42
+ test('accepts CSS color names', () => {
43
+ expect(validateColor('red')).toBeNull();
44
+ expect(validateColor('tomato')).toBeNull();
45
+ expect(validateColor('blue')).toBeNull();
46
+ });
47
+
48
+ test('accepts hex colors', () => {
49
+ expect(validateColor('#f00')).toBeNull();
50
+ expect(validateColor('#ff0000')).toBeNull();
51
+ expect(validateColor('#c04040')).toBeNull();
52
+ });
53
+
54
+ test('accepts HSL colors', () => {
55
+ expect(validateColor('hsl(195 70% 40%)')).toBeNull();
56
+ expect(validateColor('hsl(195, 70%, 40%)')).toBeNull();
57
+ expect(validateColor('hsl(195deg 70% 40%)')).toBeNull();
58
+ });
59
+
60
+ test('accepts RGB colors', () => {
61
+ expect(validateColor('rgb(0 0 0)')).toBeNull();
62
+ expect(validateColor('rgb(255, 99, 71)')).toBeNull();
63
+ });
64
+
65
+ test('rejects empty string', () => {
66
+ expect(validateColor('')).not.toBeNull();
67
+ });
68
+
69
+ test('rejects invalid color strings', () => {
70
+ expect(validateColor('notacolor')).not.toBeNull();
71
+ expect(validateColor('hsl 195 70% 40%')).not.toBeNull();
72
+ });
73
+ });
74
+
75
+ describe('validateHue', () => {
76
+ test('accepts integer', () => {
77
+ expect(validateHue('0')).toBeNull();
78
+ expect(validateHue('180')).toBeNull();
79
+ expect(validateHue('360')).toBeNull();
80
+ });
81
+
82
+ test('accepts integer with deg suffix', () => {
83
+ expect(validateHue('90deg')).toBeNull();
84
+ expect(validateHue('360deg')).toBeNull();
85
+ });
86
+
87
+ test('rejects empty string', () => {
88
+ expect(validateHue('')).not.toBeNull();
89
+ });
90
+
91
+ test('rejects out of range', () => {
92
+ expect(validateHue('-1')).not.toBeNull();
93
+ expect(validateHue('361')).not.toBeNull();
94
+ });
95
+
96
+ test('rejects non-integer', () => {
97
+ expect(validateHue('1.5')).not.toBeNull();
98
+ expect(validateHue('abc')).not.toBeNull();
99
+ });
100
+ });
101
+
102
+ describe('validateUrl', () => {
103
+ test('accepts https URL', () => {
104
+ expect(validateUrl('https://example.edu')).toBeNull();
105
+ });
106
+
107
+ test('accepts http URL', () => {
108
+ expect(validateUrl('http://localhost')).toBeNull();
109
+ });
110
+
111
+ test('accepts URL with port', () => {
112
+ expect(validateUrl('http://localhost:8080')).toBeNull();
113
+ });
114
+
115
+ test('rejects empty string', () => {
116
+ expect(validateUrl('')).not.toBeNull();
117
+ });
118
+
119
+ test('rejects trailing slash', () => {
120
+ expect(validateUrl('https://example.edu/')).not.toBeNull();
121
+ });
122
+
123
+ test('rejects URL with path', () => {
124
+ expect(validateUrl('https://example.edu/foo')).not.toBeNull();
125
+ });
126
+
127
+ test('rejects non-http scheme', () => {
128
+ expect(validateUrl('ftp://example.edu')).not.toBeNull();
129
+ });
130
+
131
+ test('rejects bare domain', () => {
132
+ expect(validateUrl('example.edu')).not.toBeNull();
133
+ });
134
+ });
135
+
136
+ describe('validateBasePath', () => {
137
+ test('accepts root path', () => {
138
+ expect(validateBasePath('/')).toBeNull();
139
+ });
140
+
141
+ test('accepts simple path segment', () => {
142
+ expect(validateBasePath('/foo')).toBeNull();
143
+ expect(validateBasePath('/foo-bar')).toBeNull();
144
+ expect(validateBasePath('/cs101')).toBeNull();
145
+ });
146
+
147
+ test('rejects path without leading slash', () => {
148
+ expect(validateBasePath('foo')).not.toBeNull();
149
+ });
150
+
151
+ test('rejects trailing slash', () => {
152
+ expect(validateBasePath('/foo/')).not.toBeNull();
153
+ });
154
+
155
+ test('rejects spaces', () => {
156
+ expect(validateBasePath('/foo bar')).not.toBeNull();
157
+ });
158
+
159
+ test('rejects nested paths', () => {
160
+ expect(validateBasePath('/foo/bar')).not.toBeNull();
161
+ });
162
+ });
163
+
164
+ describe('createSiteConfig', () => {
165
+ const base: SiteConfigInput = {
166
+ title: 'Test Site',
167
+ symbol: 'TS',
168
+ themeColor: 'hsl(200 50% 40%)',
169
+ tintHue: '20',
170
+ tintAmount: '100',
171
+ defaultTimeZone: 'America/New_York',
172
+ base: 'https://example.edu',
173
+ basePath: '/',
174
+ internalDomains: ['example.edu'],
175
+ };
176
+
177
+ test('returns an object with all expected keys', () => {
178
+ const config = createSiteConfig(base);
179
+ expect(config).toMatchObject({
180
+ title: 'Test Site',
181
+ symbol: 'TS',
182
+ themeColor: 'hsl(200 50% 40%)',
183
+ base: 'https://example.edu',
184
+ basePath: '/',
185
+ internalDomains: ['example.edu'],
186
+ defaultTimeZone: 'America/New_York',
187
+ features: { search: true, code: true, favicon: true },
188
+ codeLanguages: { java: 'java', py: 'python' },
189
+ vars: {},
190
+ });
191
+ });
192
+
193
+ test('coerces tintHue and tintAmount to numbers', () => {
194
+ const config = createSiteConfig(base);
195
+ expect(config.tintHue).toBe(20);
196
+ expect(config.tintAmount).toBe(100);
197
+ });
198
+
199
+ test('coerces numeric string tintHue correctly', () => {
200
+ const config = createSiteConfig({ ...base, tintHue: '0', tintAmount: '0' });
201
+ expect(config.tintHue).toBe(0);
202
+ expect(config.tintAmount).toBe(0);
203
+ });
204
+ });