@abreen/tada 1.11.2 → 1.13.2

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 (80) hide show
  1. package/README.md +65 -64
  2. package/bin/tada.ts +61 -66
  3. package/build/build-manifest.ts +14 -3
  4. package/build/bundle.ts +0 -1
  5. package/build/config-files.ts +183 -0
  6. package/build/config-loader.ts +159 -0
  7. package/build/copy.ts +0 -83
  8. package/build/generate-content-assets.ts +75 -160
  9. package/build/globals.ts +48 -89
  10. package/build/log.ts +27 -18
  11. package/build/pagefind.ts +33 -26
  12. package/build/pipeline.ts +98 -79
  13. package/build/reachability.ts +51 -248
  14. package/build/serve.ts +35 -6
  15. package/build/site-variables.ts +20 -11
  16. package/build/source-model.ts +400 -0
  17. package/build/{watch/records.ts → source-records.ts} +111 -49
  18. package/build/template-globals.ts +91 -0
  19. package/build/templates.ts +50 -40
  20. package/build/test-helpers.ts +15 -0
  21. package/build/toc-plugin.ts +5 -1
  22. package/build/types.d.ts +9 -1
  23. package/build/util.ts +8 -0
  24. package/build/utils/code.ts +6 -16
  25. package/build/utils/content-files.ts +57 -126
  26. package/build/utils/final-html.ts +239 -0
  27. package/build/utils/markdown.ts +237 -17
  28. package/build/utils/paths.ts +2 -1
  29. package/build/utils/render.ts +108 -99
  30. package/build/validate-config-links.ts +91 -14
  31. package/build/watch/assets.ts +0 -2
  32. package/build/watch/build-full.ts +14 -15
  33. package/build/watch/build-incremental.ts +15 -8
  34. package/build/watch/compiler-types.d.ts +6 -1
  35. package/build/watch/compiler.ts +27 -15
  36. package/build/watch/index.ts +2 -7
  37. package/build/watch/planner.ts +32 -16
  38. package/build/watch/reload.ts +4 -0
  39. package/build/watch/runtime.ts +29 -45
  40. package/build/watch/snapshot.ts +50 -402
  41. package/build/watch/validation.ts +6 -3
  42. package/build/watch-reachability-state.ts +16 -23
  43. package/build/watch-reload-client.ts +11 -4
  44. package/init/content/index.md +1 -1
  45. package/init/content/labs/02/index.md +61 -0
  46. package/init/content/labs/02/selection_sort.py +23 -0
  47. package/init/content/labs/index.md +4 -2
  48. package/init/content/lectures/01/index.md +1 -1
  49. package/init/content/markdown.md +1 -1
  50. package/package.json +3 -4
  51. package/src/code/index.ts +46 -18
  52. package/src/code.scss +2 -1
  53. package/src/globals.ts +47 -0
  54. package/src/index.ts +1 -0
  55. package/src/navigate/index.ts +2 -5
  56. package/src/navigate/lifecycle.ts +3 -1
  57. package/src/navigate/runtime.ts +6 -5
  58. package/src/page-update/index.ts +20 -16
  59. package/src/search/index.ts +53 -25
  60. package/src/search/style.scss +1 -1
  61. package/src/slides/index.ts +467 -0
  62. package/src/slides/style.scss +104 -0
  63. package/src/style.scss +8 -3
  64. package/src/timezone/index.ts +16 -20
  65. package/src/top/index.ts +2 -4
  66. package/src/trace/index.ts +91 -7
  67. package/src/trace/style.scss +1 -1
  68. package/src/util.ts +6 -2
  69. package/templates/_author.html +2 -2
  70. package/templates/_heading.html +1 -1
  71. package/templates/_nav.html +2 -2
  72. package/templates/_theme.scss +3 -3
  73. package/templates/_top.html +14 -14
  74. package/templates/default.html +8 -0
  75. package/types/dev.d.ts +1 -1
  76. package/types/site-variables.d.ts +4 -5
  77. package/watch/fs-commit.ts +63 -10
  78. package/build/apply-base-path-plugin.ts +0 -186
  79. package/build/content-watch.ts +0 -104
  80. package/build/validate-internal-links-plugin.ts +0 -207
@@ -1,288 +1,114 @@
1
1
  import path from 'path';
2
- import { JSDOM } from 'jsdom';
2
+ import { normalizeOutputPath } from './util';
3
+ import type { HtmlOutputAnalysis } from './types';
3
4
 
4
- function stripQueryAndHash(href: string): string {
5
- return href.split('#')[0].split('?')[0];
5
+ function normalizeInternalTarget(target: string): string {
6
+ return normalizeOutputPath(target);
6
7
  }
7
8
 
8
- function isExternalOrAnchor(href: string): boolean {
9
- if (!href || href.startsWith('#') || href.startsWith('//')) {
10
- return true;
11
- }
12
-
13
- return /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(href);
14
- }
15
-
16
- function normalizeUrlPath(pathname: string): string {
17
- const normalized = path.posix.normalize(pathname);
18
- if (normalized === '.' || normalized === '') {
19
- return '/';
20
- }
21
-
22
- return normalized.startsWith('/') ? normalized : `/${normalized}`;
23
- }
24
-
25
- function stripBasePath(pathname: string, basePath: string): string {
26
- const normalizedPath = normalizeUrlPath(pathname);
27
- const normalizedBasePath = normalizeUrlPath(basePath);
28
-
29
- if (normalizedBasePath === '/') {
30
- return normalizedPath;
31
- }
9
+ function toCandidateHtmlAssetPaths(target: string): string[] {
10
+ const normalizedTarget = normalizeInternalTarget(target);
32
11
 
33
- if (normalizedPath === normalizedBasePath) {
34
- return '/';
35
- }
36
-
37
- const prefix = `${normalizedBasePath}/`;
38
- if (normalizedPath.startsWith(prefix)) {
39
- return normalizeUrlPath(normalizedPath.slice(normalizedBasePath.length));
40
- }
41
-
42
- return normalizedPath;
43
- }
44
-
45
- function toCandidateAssetPaths(urlPath: string): string[] {
46
- const normalizedPath = normalizeUrlPath(urlPath);
47
-
48
- if (normalizedPath === '/') {
12
+ if (normalizedTarget === '/') {
49
13
  return ['index.html'];
50
14
  }
51
15
 
52
- if (normalizedPath.endsWith('.html')) {
53
- return [normalizedPath.slice(1)];
16
+ if (normalizedTarget.endsWith('.html')) {
17
+ return [normalizedTarget.slice(1)];
54
18
  }
55
19
 
56
- if (path.posix.extname(normalizedPath)) {
20
+ if (path.posix.extname(normalizedTarget)) {
57
21
  return [];
58
22
  }
59
23
 
60
- const withoutLeadingSlash = normalizedPath.slice(1);
61
- if (normalizedPath.endsWith('/')) {
24
+ const withoutLeadingSlash = normalizedTarget.slice(1);
25
+ if (normalizedTarget.endsWith('/')) {
62
26
  return [`${withoutLeadingSlash}index.html`];
63
27
  }
64
28
 
65
29
  return [`${withoutLeadingSlash}/index.html`, `${withoutLeadingSlash}.html`];
66
30
  }
67
31
 
68
- interface ResolveInternalPathOptions {
69
- href: string | null;
70
- fromAssetPath: string;
71
- basePath?: string;
72
- }
73
-
74
- function resolveInternalPath({
75
- href,
76
- fromAssetPath,
77
- basePath = '/',
78
- }: ResolveInternalPathOptions): string | null {
79
- if (!href) {
32
+ function resolveTargetToAssetTarget(target: string): string | null {
33
+ const normalizedTarget = normalizeInternalTarget(target);
34
+ if (normalizedTarget === '/' || normalizedTarget.endsWith('.html')) {
80
35
  return null;
81
36
  }
82
-
83
- const trimmedHref = href.trim();
84
- if (!trimmedHref || isExternalOrAnchor(trimmedHref)) {
85
- return null;
86
- }
87
-
88
- const pathname = stripQueryAndHash(trimmedHref);
89
- if (!pathname) {
90
- return null;
91
- }
92
-
93
- const resolvedPath = pathname.startsWith('/')
94
- ? stripBasePath(pathname, basePath)
95
- : normalizeUrlPath(
96
- path.posix.join(path.posix.dirname(`/${fromAssetPath}`), pathname),
97
- );
98
-
99
- try {
100
- return normalizeUrlPath(decodeURIComponent(resolvedPath));
101
- } catch {
102
- return normalizeUrlPath(resolvedPath);
103
- }
37
+ return path.posix.extname(normalizedTarget) ? normalizedTarget : null;
104
38
  }
105
39
 
106
- interface ResolveHrefToHtmlOptions {
107
- href: string | null;
108
- fromAssetPath: string;
109
- basePath?: string;
110
- knownAssets: Set<string>;
111
- }
112
-
113
- export function resolveHrefToHtmlAssetPath({
114
- href,
115
- fromAssetPath,
116
- basePath = '/',
117
- knownAssets,
118
- }: ResolveHrefToHtmlOptions): string | null {
119
- const decodedPath = resolveInternalPath({ href, fromAssetPath, basePath });
120
- if (!decodedPath) {
121
- return null;
122
- }
123
-
124
- for (const candidate of toCandidateAssetPaths(decodedPath)) {
125
- if (knownAssets.has(candidate)) {
126
- return candidate;
127
- }
128
- }
129
-
130
- return null;
131
- }
132
-
133
- interface ResolveHrefToPdfOptions {
134
- href: string | null;
135
- fromAssetPath: string;
136
- basePath?: string;
137
- knownPdfPaths: Set<string>;
138
- }
139
-
140
- export function resolveHrefToPdfPath({
141
- href,
142
- fromAssetPath,
143
- basePath = '/',
144
- knownPdfPaths,
145
- }: ResolveHrefToPdfOptions): string | null {
146
- const decodedPath = resolveInternalPath({ href, fromAssetPath, basePath });
147
- if (!decodedPath || !decodedPath.toLowerCase().endsWith('.pdf')) {
148
- return null;
149
- }
150
-
151
- return knownPdfPaths.has(decodedPath) ? decodedPath : null;
152
- }
153
-
154
- interface CollectDirectLinksOptions {
155
- html: string;
156
- fromAssetPath: string;
157
- knownAssets: Set<string>;
158
- knownPdfPaths?: Set<string>;
159
- basePath?: string;
160
- }
161
-
162
- interface DirectSiteAssetLinks {
163
- htmlAssetPaths: string[];
164
- pdfPaths: string[];
165
- }
166
-
167
- export function collectDirectSiteAssetLinks({
168
- html,
169
- fromAssetPath,
170
- knownAssets,
171
- knownPdfPaths = new Set(),
172
- basePath = '/',
173
- }: CollectDirectLinksOptions): DirectSiteAssetLinks {
40
+ export function collectOutgoingHtmlAssetPaths(
41
+ analysis: HtmlOutputAnalysis,
42
+ knownHtmlAssetPaths: Set<string>,
43
+ ): string[] {
174
44
  const htmlAssetPaths = new Set<string>();
175
- const pdfPaths = new Set<string>();
176
- const dom = new JSDOM(html);
177
-
178
- try {
179
- const links = dom.window.document.querySelectorAll('a');
180
- for (const link of links) {
181
- if (link.classList.contains('disabled')) {
182
- continue;
183
- }
184
-
185
- const href = link.getAttribute('href');
186
- const pdfPath = resolveHrefToPdfPath({
187
- href,
188
- fromAssetPath,
189
- basePath,
190
- knownPdfPaths,
191
- });
192
- if (pdfPath) {
193
- pdfPaths.add(pdfPath);
194
- }
195
-
196
- const targetPath = resolveHrefToHtmlAssetPath({
197
- href,
198
- fromAssetPath,
199
- basePath,
200
- knownAssets,
201
- });
202
- if (targetPath) {
203
- htmlAssetPaths.add(targetPath);
204
- }
205
- }
206
45
 
207
- const refreshTarget = getMetaRefreshTarget(dom.window.document);
208
- if (refreshTarget) {
209
- const targetPath = resolveHrefToHtmlAssetPath({
210
- href: refreshTarget,
211
- fromAssetPath,
212
- basePath,
213
- knownAssets,
214
- });
215
- if (targetPath) {
216
- htmlAssetPaths.add(targetPath);
46
+ for (const target of analysis.outgoingTargets) {
47
+ for (const candidate of toCandidateHtmlAssetPaths(target)) {
48
+ if (knownHtmlAssetPaths.has(candidate)) {
49
+ htmlAssetPaths.add(candidate);
50
+ break;
217
51
  }
218
52
  }
219
- } finally {
220
- dom.window.close();
221
53
  }
222
54
 
223
- return {
224
- htmlAssetPaths: [...htmlAssetPaths].sort(),
225
- pdfPaths: [...pdfPaths].sort(),
226
- };
55
+ return [...htmlAssetPaths].sort();
227
56
  }
228
57
 
229
58
  interface CollectReachableOptions {
230
- htmlAssetsByPath: Map<string, string>;
231
- knownPdfPaths?: Set<string>;
59
+ htmlAnalysisByPath: Map<string, HtmlOutputAnalysis>;
60
+ knownAssetTargets?: Set<string>;
232
61
  rootPath?: string;
233
- basePath?: string;
234
62
  }
235
63
 
236
64
  interface ReachableSiteAssets {
237
65
  reachableHtmlPaths: string[];
238
- reachablePdfPaths: string[];
66
+ reachableAssetTargets: string[];
239
67
  }
240
68
 
241
69
  export function collectReachableSiteAssets({
242
- htmlAssetsByPath,
243
- knownPdfPaths = new Set(),
70
+ htmlAnalysisByPath,
71
+ knownAssetTargets = new Set(),
244
72
  rootPath = 'index.html',
245
- basePath = '/',
246
73
  }: CollectReachableOptions): ReachableSiteAssets {
247
- if (!htmlAssetsByPath.has(rootPath)) {
74
+ if (!htmlAnalysisByPath.has(rootPath)) {
248
75
  throw new Error(`Pagefind reachability root not found: ${rootPath}`);
249
76
  }
250
77
 
251
- const knownAssets = new Set(htmlAssetsByPath.keys());
252
- const reachable = new Set<string>();
253
- const reachablePdfPaths = new Set<string>();
78
+ const knownHtmlAssetPaths = new Set(htmlAnalysisByPath.keys());
79
+ const reachableHtmlPaths = new Set<string>();
80
+ const reachableAssetTargets = new Set<string>();
254
81
  const pending: string[] = [rootPath];
255
82
 
256
83
  while (pending.length > 0) {
257
84
  const currentPath = pending.pop()!;
258
- if (reachable.has(currentPath)) {
85
+ if (reachableHtmlPaths.has(currentPath)) {
259
86
  continue;
260
87
  }
261
- reachable.add(currentPath);
88
+ reachableHtmlPaths.add(currentPath);
262
89
 
263
- const html = htmlAssetsByPath.get(currentPath)!;
264
- const { htmlAssetPaths, pdfPaths } = collectDirectSiteAssetLinks({
265
- html,
266
- fromAssetPath: currentPath,
267
- knownAssets,
268
- knownPdfPaths,
269
- basePath,
270
- });
90
+ const analysis = htmlAnalysisByPath.get(currentPath)!;
271
91
 
272
- for (const pdfPath of pdfPaths) {
273
- reachablePdfPaths.add(pdfPath);
92
+ for (const targetPath of collectOutgoingHtmlAssetPaths(
93
+ analysis,
94
+ knownHtmlAssetPaths,
95
+ )) {
96
+ if (!reachableHtmlPaths.has(targetPath)) {
97
+ pending.push(targetPath);
98
+ }
274
99
  }
275
100
 
276
- for (const targetPath of htmlAssetPaths) {
277
- if (!reachable.has(targetPath)) {
278
- pending.push(targetPath);
101
+ for (const target of analysis.outgoingTargets) {
102
+ const assetTarget = resolveTargetToAssetTarget(target);
103
+ if (assetTarget && knownAssetTargets.has(assetTarget)) {
104
+ reachableAssetTargets.add(assetTarget);
279
105
  }
280
106
  }
281
107
  }
282
108
 
283
109
  return {
284
- reachableHtmlPaths: [...reachable].sort(),
285
- reachablePdfPaths: [...reachablePdfPaths].sort(),
110
+ reachableHtmlPaths: [...reachableHtmlPaths].sort(),
111
+ reachableAssetTargets: [...reachableAssetTargets].sort(),
286
112
  };
287
113
  }
288
114
 
@@ -291,26 +117,3 @@ export function collectReachableHtmlAssets(
291
117
  ): string[] {
292
118
  return collectReachableSiteAssets(options).reachableHtmlPaths;
293
119
  }
294
-
295
- function getMetaRefreshTarget(document: Document): string | null {
296
- const refreshTags = document.querySelectorAll('meta[http-equiv]');
297
- for (const tag of refreshTags) {
298
- const httpEquiv = tag.getAttribute('http-equiv');
299
- if (!httpEquiv || httpEquiv.toLowerCase() !== 'refresh') {
300
- continue;
301
- }
302
-
303
- const content = tag.getAttribute('content') || '';
304
- const match = content.match(/(?:^|;)\s*url\s*=\s*(.+)\s*$/i);
305
- if (!match) {
306
- continue;
307
- }
308
-
309
- const target = match[1].trim().replace(/^['"]|['"]$/g, '');
310
- if (target) {
311
- return target;
312
- }
313
- }
314
-
315
- return null;
316
- }
package/build/serve.ts CHANGED
@@ -2,9 +2,14 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { makeLogger } from './log';
4
4
  import { B } from './colors';
5
+ import { WATCH_RELOAD_PATH, WATCH_RELOAD_TOPIC } from './watch/reload';
5
6
 
6
7
  const log = makeLogger(import.meta.url);
7
8
 
9
+ interface ServerUpgradeTarget {
10
+ upgrade(req: Request): boolean;
11
+ }
12
+
8
13
  export function resolvePathname(
9
14
  distDir: string,
10
15
  pathname: string,
@@ -40,13 +45,21 @@ export interface StartServerOptions {
40
45
  port?: number;
41
46
  distDir: string;
42
47
  onReady?: (port: number) => void;
48
+ watchReload?: { onClientOpen?: () => void; onClientClose?: () => void };
43
49
  }
44
50
 
45
- export function startServer(options: StartServerOptions): void {
46
- const { port = 8080, distDir, onReady } = options;
47
-
48
- function createResponse(req: Request): Response {
51
+ export function createDevServerFetchHandler(
52
+ distDir: string,
53
+ watchReloadEnabled = false,
54
+ ) {
55
+ return (req: Request, server: ServerUpgradeTarget): Response | undefined => {
49
56
  const url = new URL(req.url);
57
+ if (watchReloadEnabled && url.pathname === WATCH_RELOAD_PATH) {
58
+ return server.upgrade(req)
59
+ ? undefined
60
+ : new Response('WebSocket upgrade error', { status: 400 });
61
+ }
62
+
50
63
  const result = resolvePathname(distDir, url.pathname);
51
64
  if (!result) {
52
65
  return new Response('Not Found', { status: 404 });
@@ -67,22 +80,38 @@ export function startServer(options: StartServerOptions): void {
67
80
  }
68
81
 
69
82
  return new Response(Bun.file(result.filePath), { headers });
70
- }
83
+ };
84
+ }
85
+
86
+ export function startServer(options: StartServerOptions) {
87
+ const { port = 8080, distDir, onReady, watchReload } = options;
71
88
 
72
89
  try {
90
+ const fetch = createDevServerFetchHandler(distDir, Boolean(watchReload));
73
91
  const server = Bun.serve({
74
92
  port,
75
- fetch: createResponse,
93
+ fetch,
76
94
  error(error: Error) {
77
95
  log.error`Request failed: ${error}`;
78
96
  return new Response('Internal Server Error', { status: 500 });
79
97
  },
98
+ websocket: {
99
+ message() {},
100
+ open(ws) {
101
+ ws.subscribe(WATCH_RELOAD_TOPIC);
102
+ watchReload?.onClientOpen?.();
103
+ },
104
+ close() {
105
+ watchReload?.onClientClose?.();
106
+ },
107
+ },
80
108
  });
81
109
 
82
110
  log.info`Dev server: ${B`http://localhost:${server.port}/index.html`}`;
83
111
  if (onReady) {
84
112
  onReady(server.port!);
85
113
  }
114
+ return server;
86
115
  } catch (err) {
87
116
  log.error`Failed to start server on port ${port}: ${err}`;
88
117
  process.exit(1);
@@ -1,7 +1,7 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
1
  import { bundledLanguages } from 'shiki';
4
2
  import type { BundledLanguage } from 'shiki';
3
+ import { getSiteConfigBaseName, type SiteEnv } from './config-files';
4
+ import { loadSiteConfig } from './config-loader';
5
5
  import { compile as compileJsonSchema, doValidation } from './json-schema';
6
6
  import { getProjectDir } from './utils/paths';
7
7
  import type { PlainTextLanguage, SiteVariables } from './types';
@@ -17,12 +17,6 @@ const DEFAULT: Partial<SiteVariables> = {
17
17
 
18
18
  const isValid = compileJsonSchema(siteSchema);
19
19
 
20
- function getJson(filePath: string): Record<string, unknown> {
21
- return JSON.parse(
22
- fs.readFileSync(path.resolve(configDir, filePath), 'utf-8'),
23
- );
24
- }
25
-
26
20
  const PLAIN_TEXT_LANGUAGES = ['plain', 'text', 'txt'] as const;
27
21
 
28
22
  export function isPlainTextLanguage(lang: string): lang is PlainTextLanguage {
@@ -112,9 +106,10 @@ export function getRuntimeBundledShikiLanguages(
112
106
  return [...new Set(configured)];
113
107
  }
114
108
 
115
- function getSiteVariables(env: string): SiteVariables {
116
- const fileName = `site.${env}.json`;
117
- const fromFile = getJson(fileName);
109
+ function getSiteVariables(env: SiteEnv): SiteVariables {
110
+ const loaded = loadSiteConfig(configDir, env);
111
+ const fileName = loaded.fileName;
112
+ const fromFile = getValidatedSiteConfigRoot(loaded.value, fileName);
118
113
  const extensionToShikiLanguage = validateExtensionToShikiLanguage(
119
114
  fromFile.extensionToShikiLanguage,
120
115
  fileName,
@@ -167,3 +162,17 @@ export function getDevSiteVariables(): SiteVariables {
167
162
  export function getProdSiteVariables(): SiteVariables {
168
163
  return getSiteVariables('prod');
169
164
  }
165
+
166
+ export { getSiteConfigBaseName };
167
+
168
+ function getValidatedSiteConfigRoot(
169
+ value: unknown,
170
+ fileName: string,
171
+ ): Record<string, unknown> {
172
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
173
+ return value as Record<string, unknown>;
174
+ }
175
+
176
+ doValidation(isValid, value, fileName);
177
+ throw new Error(`${fileName} failed validation`);
178
+ }