@abreen/tada 1.15.7 → 1.15.9

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.
package/build/bundle.ts CHANGED
@@ -19,6 +19,19 @@ import type { SiteVariables } from './types';
19
19
  import timezones from '../src/timezone/timezones.json' with { type: 'json' };
20
20
  import pkg from '../package.json' with { type: 'json' };
21
21
 
22
+ interface BunBuildPlugin {
23
+ name: string;
24
+ setup(build: PluginBuilder): void;
25
+ }
26
+
27
+ interface CoverageHooks {
28
+ createBundlePlugin(): BunBuildPlugin;
29
+ }
30
+
31
+ interface CoverageGlobal {
32
+ __tadaCoverage?: CoverageHooks;
33
+ }
34
+
22
35
  export function getBundleNaming(): string {
23
36
  const version = pkg.version.replace(/[^a-zA-Z0-9.-]/g, '-');
24
37
  return `[name].bundle.tada-${version}.[ext]`;
@@ -92,9 +105,7 @@ function createDefine(
92
105
  };
93
106
  }
94
107
 
95
- function createScssPlugin(siteVariables: SiteVariables) {
96
- const themeDir = renderThemeScss(siteVariables);
97
-
108
+ function createScssPlugin(themeDir: string) {
98
109
  return {
99
110
  name: 'scss',
100
111
  setup(build: PluginBuilder) {
@@ -108,6 +119,12 @@ function createScssPlugin(siteVariables: SiteVariables) {
108
119
  };
109
120
  }
110
121
 
122
+ function getCoverageBundlePlugin(): BunBuildPlugin | null {
123
+ return (
124
+ (globalThis as CoverageGlobal).__tadaCoverage?.createBundlePlugin() ?? null
125
+ );
126
+ }
127
+
111
128
  export async function bundle(
112
129
  siteVariables: SiteVariables,
113
130
  { mode = 'development', distDir }: { mode?: string; distDir?: string } = {},
@@ -121,30 +138,42 @@ export async function bundle(
121
138
  path.resolve(packageDir, 'src/critical.scss'),
122
139
  ];
123
140
 
124
- const result = await Bun.build({
125
- entrypoints,
126
- outdir: resolvedDistDir,
127
- naming: getBundleNaming(),
128
- minify: mode === 'production',
129
- sourcemap: isDev ? 'inline' : 'none',
130
- define: createDefine(siteVariables, isDev),
131
- external: ['*.woff2'],
132
- plugins: [createScssPlugin(siteVariables)],
133
- });
141
+ const themeDir = renderThemeScss(siteVariables);
134
142
 
135
- if (!result.success) {
136
- const messages = result.logs
137
- .filter(log => log.level === 'error')
138
- .map(log => log.message || String(log));
139
- throw new Error(`Bundle failed:\n${messages.join('\n')}`);
143
+ try {
144
+ const plugins = [createScssPlugin(themeDir)];
145
+ const coverageBundlePlugin = getCoverageBundlePlugin();
146
+ if (coverageBundlePlugin) {
147
+ plugins.push(coverageBundlePlugin);
148
+ }
149
+
150
+ const result = await Bun.build({
151
+ entrypoints,
152
+ outdir: resolvedDistDir,
153
+ naming: getBundleNaming(),
154
+ minify: mode === 'production',
155
+ sourcemap: isDev ? 'inline' : 'none',
156
+ define: createDefine(siteVariables, isDev),
157
+ external: ['*.woff2'],
158
+ plugins,
159
+ });
160
+
161
+ if (!result.success) {
162
+ const messages = result.logs
163
+ .filter(log => log.level === 'error')
164
+ .map(log => log.message || String(log));
165
+ throw new Error(`Bundle failed:\n${messages.join('\n')}`);
166
+ }
167
+
168
+ // Return the output filenames for asset tag injection
169
+ const assetFiles = result.outputs.map(output =>
170
+ toPosix(path.relative(resolvedDistDir, output.path)),
171
+ );
172
+
173
+ return assetFiles;
174
+ } finally {
175
+ fs.rmSync(themeDir, { recursive: true, force: true });
140
176
  }
141
-
142
- // Return the output filenames for asset tag injection
143
- const assetFiles = result.outputs.map(output =>
144
- toPosix(path.relative(resolvedDistDir, output.path)),
145
- );
146
-
147
- return assetFiles;
148
177
  }
149
178
 
150
- export { renderThemeScss, createDefine };
179
+ export { renderThemeScss };
package/build/colors.ts CHANGED
@@ -12,11 +12,11 @@ export const R = style(['red']);
12
12
  export const G = style(['green']);
13
13
  export const B = style(['blue']);
14
14
  export const Y = style(['yellow']);
15
- export const L = style(['blackBright']);
15
+ export const L = style(['gray']);
16
16
  export const P = style(['magenta']);
17
17
  export const I = style(['italic', 'bold']);
18
18
 
19
19
  export const Ri = style(['inverse', 'red']);
20
20
  export const Gi = style(['inverse', 'green']);
21
21
  export const Yi = style(['inverse', 'yellow']);
22
- export const Li = style(['inverse', 'blackBright']);
22
+ export const Li = style(['inverse', 'gray']);
@@ -5,7 +5,7 @@ export type SiteEnv = 'dev' | 'prod';
5
5
  export type SiteConfigName = 'site.dev' | 'site.prod';
6
6
  export type ProjectConfigName = 'nav' | 'authors';
7
7
  export type ConfigName = SiteConfigName | ProjectConfigName;
8
- export type ProjectConfigExtension = '.yaml' | '.yml' | '.json';
8
+ type ProjectConfigExtension = '.yaml' | '.yml' | '.json';
9
9
 
10
10
  export interface ResolvedProjectConfigFile<
11
11
  Name extends ConfigName = ConfigName,
@@ -18,21 +18,21 @@ export interface ResolvedProjectConfigFile<
18
18
  required: boolean;
19
19
  }
20
20
 
21
- export const CONFIG_FILE_EXTENSIONS = ['.yaml', '.yml', '.json'] as const;
22
- export const DEFAULT_CONFIG_FILE_EXTENSION = '.yaml' as const;
21
+ const CONFIG_FILE_EXTENSIONS = ['.yaml', '.yml', '.json'] as const;
22
+ const DEFAULT_CONFIG_FILE_EXTENSION = '.yaml' as const;
23
23
 
24
- export const SITE_CONFIG_BASE_NAMES = {
24
+ const SITE_CONFIG_BASE_NAMES = {
25
25
  dev: 'site.dev',
26
26
  prod: 'site.prod',
27
27
  } as const satisfies Record<SiteEnv, SiteConfigName>;
28
28
 
29
- export const PROJECT_CONFIG_BASE_NAMES = {
29
+ const PROJECT_CONFIG_BASE_NAMES = {
30
30
  nav: 'nav',
31
31
  authors: 'authors',
32
32
  } as const satisfies Record<ProjectConfigName, ProjectConfigName>;
33
33
 
34
- export const REQUIRED_PROJECT_CONFIG_NAMES = ['nav'] as const;
35
- export const OPTIONAL_PROJECT_CONFIG_NAMES = ['authors'] as const;
34
+ const REQUIRED_PROJECT_CONFIG_NAMES = ['nav'] as const;
35
+ const OPTIONAL_PROJECT_CONFIG_NAMES = ['authors'] as const;
36
36
  export const PROJECT_CONFIG_NAMES = [
37
37
  ...REQUIRED_PROJECT_CONFIG_NAMES,
38
38
  ...OPTIONAL_PROJECT_CONFIG_NAMES,
@@ -66,7 +66,7 @@ export function getProjectConfigBaseName(
66
66
  return PROJECT_CONFIG_BASE_NAMES[name];
67
67
  }
68
68
 
69
- export function getSupportedConfigFileNames(baseName: string): string[] {
69
+ function getSupportedConfigFileNames(baseName: string): string[] {
70
70
  return CONFIG_FILE_EXTENSIONS.map(ext => `${baseName}${ext}`);
71
71
  }
72
72
 
@@ -83,21 +83,17 @@ export function getSupportedConfigFileNamesText(baseName: string): string {
83
83
  return joinWithOr(getSupportedConfigFileNames(baseName));
84
84
  }
85
85
 
86
- export function getDefaultConfigFileName(baseName: string): string {
86
+ function getDefaultConfigFileName(baseName: string): string {
87
87
  return `${baseName}${DEFAULT_CONFIG_FILE_EXTENSION}`;
88
88
  }
89
89
 
90
- export function getDefaultConfigFilePath(
90
+ function getDefaultConfigFilePath(
91
91
  projectDir: string,
92
92
  baseName: string,
93
93
  ): string {
94
94
  return path.join(projectDir, getDefaultConfigFileName(baseName));
95
95
  }
96
96
 
97
- export function getDefaultSiteConfigFileName(env: SiteEnv): string {
98
- return getDefaultConfigFileName(getSiteConfigBaseName(env));
99
- }
100
-
101
97
  export function getDefaultSiteConfigPath(
102
98
  projectDir: string,
103
99
  env: SiteEnv,
@@ -105,12 +101,6 @@ export function getDefaultSiteConfigPath(
105
101
  return getDefaultConfigFilePath(projectDir, getSiteConfigBaseName(env));
106
102
  }
107
103
 
108
- export function getDefaultProjectConfigFileName(
109
- name: ProjectConfigName,
110
- ): string {
111
- return getDefaultConfigFileName(getProjectConfigBaseName(name));
112
- }
113
-
114
104
  export function getDefaultProjectConfigFilePath(
115
105
  projectDir: string,
116
106
  name: ProjectConfigName,
@@ -118,7 +108,7 @@ export function getDefaultProjectConfigFilePath(
118
108
  return getDefaultConfigFilePath(projectDir, getProjectConfigBaseName(name));
119
109
  }
120
110
 
121
- export function resolveConfigFile<Name extends ConfigName = ConfigName>(
111
+ function resolveConfigFile<Name extends ConfigName = ConfigName>(
122
112
  projectDir: string,
123
113
  {
124
114
  name,
@@ -115,7 +115,7 @@ function interpolateConfigValue(
115
115
  return value;
116
116
  }
117
117
 
118
- export function loadResolvedConfigFile<
118
+ function loadResolvedConfigFile<
119
119
  T = unknown,
120
120
  Name extends ConfigName = ConfigName,
121
121
  >(
@@ -175,6 +175,7 @@ export default function externalLinks(
175
175
  const newClassAttr = classAttr ? `${classAttr} external` : 'external';
176
176
  token.attrSet('class', newClassAttr);
177
177
  token.attrSet('target', '_blank');
178
+ token.attrSet('rel', 'noopener noreferrer');
178
179
  log.debug`${href} -> "${newClassAttr}"`;
179
180
 
180
181
  const closeIdx = findMatchingClose(children, i);
@@ -158,5 +158,3 @@ export async function generateFavicons(
158
158
  );
159
159
  fs.writeFileSync(path.join(distDir, `${filenameBase}.ico`), icoBuffer);
160
160
  }
161
-
162
- export { createFaviconSvg };
@@ -67,5 +67,3 @@ export function generateWebAppManifest(
67
67
  JSON.stringify(manifest),
68
68
  );
69
69
  }
70
-
71
- export { createManifest };
package/build/pdf-text.ts CHANGED
@@ -8,12 +8,12 @@ interface MutoolOutput {
8
8
  stderr: string;
9
9
  }
10
10
 
11
- export interface PdfPage {
11
+ interface PdfPage {
12
12
  pageNumber: number;
13
13
  content: string;
14
14
  }
15
15
 
16
- export interface PdfExtractResult {
16
+ interface PdfExtractResult {
17
17
  pages: PdfPage[];
18
18
  hasExtractedText: boolean;
19
19
  }
@@ -37,7 +37,7 @@ function resolveTargetToAssetTarget(target: string): string | null {
37
37
  return path.posix.extname(normalizedTarget) ? normalizedTarget : null;
38
38
  }
39
39
 
40
- export function collectOutgoingHtmlAssetPaths(
40
+ function collectOutgoingHtmlAssetPaths(
41
41
  analysis: HtmlOutputAnalysis,
42
42
  knownHtmlAssetPaths: Set<string>,
43
43
  ): string[] {
@@ -1,6 +1,6 @@
1
1
  import { bundledLanguages } from 'shiki';
2
2
  import type { BundledLanguage } from 'shiki';
3
- import { getSiteConfigBaseName, type SiteEnv } from './config-files';
3
+ import type { SiteEnv } from './config-files';
4
4
  import { loadSiteConfig } from './config-loader';
5
5
  import { compile as compileJsonSchema, doValidation } from './json-schema';
6
6
  import { getProjectDir } from './utils/paths';
@@ -163,8 +163,6 @@ export function getProdSiteVariables(): SiteVariables {
163
163
  return getSiteVariables('prod');
164
164
  }
165
165
 
166
- export { getSiteConfigBaseName };
167
-
168
166
  function getValidatedSiteConfigRoot(
169
167
  value: unknown,
170
168
  fileName: string,
@@ -23,6 +23,7 @@ interface TemplateGlobals {
23
23
  readableDate: (date: string | Date | null | undefined) => string;
24
24
  classNames: (obj: Record<string, unknown>) => string;
25
25
  cx: (obj: Record<string, unknown>) => string;
26
+ encodeAuthoredUrl: (value: string) => string;
26
27
  renderTimeZoneChooser: () => string;
27
28
  }
28
29
 
@@ -51,10 +52,16 @@ export default function createTemplateGlobals(
51
52
  readableDate,
52
53
  classNames,
53
54
  cx: classNames,
55
+ encodeAuthoredUrl,
54
56
  renderTimeZoneChooser,
55
57
  };
56
58
  }
57
59
 
60
+ // Encode raw authored URL text, but preserve percent escapes authors already wrote.
61
+ export function encodeAuthoredUrl(value: string): string {
62
+ return encodeURI(value).replace(/%25([0-9a-fA-F]{2})/g, '%$1');
63
+ }
64
+
58
65
  function isoDate(str: string | null | undefined): string | null {
59
66
  if (str == null || str == '') {
60
67
  return null;
@@ -144,4 +144,4 @@ function compileAndSetValidator(
144
144
  validators[name] = compileJsonSchema(schema);
145
145
  }
146
146
 
147
- export { getHtmlTemplatesDir, getProjectConfigDir };
147
+ export { getProjectConfigDir };
package/build/util.ts CHANGED
@@ -1,37 +1,16 @@
1
- export {
2
- addGeneratedRouteAliases,
3
- getProcessedExts,
4
- getSourceOutputPaths,
5
- getSourceTargetPaths,
6
- isBuildContentSource,
7
- } from './source-model';
8
- export {
9
- getBuildContentFiles,
10
- getContentFiles,
11
- getContentOutputRelPaths,
12
- getContentSourceOutputRelPaths,
13
- getFilesByExtensions,
14
- getValidInternalTargets,
15
- shouldSkipContentFile,
16
- } from './utils/content-files';
17
- export { extensionIsMarkdown, isPartial } from './utils/file-types';
18
- export { createMarkdown } from './utils/markdown';
1
+ export { getFilesByExtensions } from './utils/content-files';
19
2
  export {
20
3
  createApplyBasePath,
21
4
  getContentDir,
22
5
  getDistDir,
23
6
  getPackageDir,
24
- getProjectDir,
25
7
  getPublicDir,
26
8
  normalizeOutputPath,
27
9
  toPosix,
28
10
  } from './utils/paths';
29
11
  export {
30
- injectAssetTags,
31
- preparePageTemplateHtml,
32
12
  renderCodePageAsset,
33
13
  renderCopiedContentAsset,
34
14
  renderLiterateJavaPageAsset,
35
15
  renderPlainTextPageAsset,
36
16
  } from './utils/render';
37
- export { parseFrontMatter } from './utils/front-matter';
@@ -32,7 +32,7 @@ export function runJavac(
32
32
  }
33
33
  }
34
34
 
35
- export function getExecError(err: unknown): string {
35
+ function getExecError(err: unknown): string {
36
36
  const execErr = err as { stderr?: Buffer; message: string };
37
37
  return execErr.stderr ? execErr.stderr.toString() : execErr.message;
38
38
  }
@@ -23,7 +23,7 @@ interface MarkdownPartialsEnv {
23
23
  [PARTIAL_CONTEXT_STACK_KEY]?: MarkdownPartialContext[];
24
24
  }
25
25
 
26
- export interface MarkdownPartialsOptions {
26
+ interface MarkdownPartialsOptions {
27
27
  filePath: string;
28
28
  templateParams: Record<string, unknown>;
29
29
  dependencyCollector?: RenderDependencyCollector;
@@ -64,10 +64,7 @@ export async function initHighlighter(
64
64
  }
65
65
  }
66
66
 
67
- export function getHighlighter(): HighlighterGeneric<
68
- BundledLanguage,
69
- BundledTheme
70
- > {
67
+ function getHighlighter(): HighlighterGeneric<BundledLanguage, BundledTheme> {
71
68
  if (!highlighter) {
72
69
  throw new Error('Shiki highlighter not initialized');
73
70
  }
@@ -33,7 +33,7 @@ const MIN_VALUE_WIDTH = 40;
33
33
  const MIN_ARRAY_CELL_WIDTH = 48;
34
34
 
35
35
  /** Information collected about a heap object across all trace steps. */
36
- export interface ObjectInfo {
36
+ interface ObjectInfo {
37
37
  id: string;
38
38
  type: string;
39
39
  firstStep: number;
@@ -61,7 +61,7 @@ export interface ObjectInfo {
61
61
  }
62
62
 
63
63
  /** A tree node used to build the hierarchy for d3-flextree. */
64
- export interface TreeNode {
64
+ interface TreeNode {
65
65
  id: string;
66
66
  width: number;
67
67
  height: number;
@@ -1,4 +1,5 @@
1
1
  import path from 'path';
2
+ import { encodeAuthoredUrl } from './template-globals';
2
3
  import { normalizeOutputPath } from './utils/paths';
3
4
 
4
5
  interface NavLink {
@@ -24,6 +25,51 @@ function requireRootRelativePath(
24
25
  return `${errorPrefix} must start with "/": "${value}"`;
25
26
  }
26
27
 
28
+ const SPECIAL_URL_SCHEMES = new Set([
29
+ 'file',
30
+ 'ftp',
31
+ 'http',
32
+ 'https',
33
+ 'ws',
34
+ 'wss',
35
+ ]);
36
+
37
+ function isValidAbsoluteAuthorUrl(value: string): boolean {
38
+ if (value.trim() !== value) {
39
+ return false;
40
+ }
41
+
42
+ const schemeMatch = value.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/);
43
+ if (!schemeMatch) {
44
+ return false;
45
+ }
46
+
47
+ const scheme = schemeMatch[1].toLowerCase();
48
+ if (
49
+ SPECIAL_URL_SCHEMES.has(scheme) &&
50
+ !value.startsWith(`${schemeMatch[1]}://`)
51
+ ) {
52
+ return false;
53
+ }
54
+
55
+ try {
56
+ new URL(encodeAuthoredUrl(value));
57
+ return true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ function getInternalHrefTarget(href: string): string {
64
+ const { pathname } = splitHref(href);
65
+ const normalized = normalizeOutputPath(pathname);
66
+ try {
67
+ return normalizeOutputPath(decodeURIComponent(normalized));
68
+ } catch {
69
+ return normalized;
70
+ }
71
+ }
72
+
27
73
  export function validateNavLinks(
28
74
  navData: unknown,
29
75
  validTargets: Set<string>,
@@ -97,6 +143,10 @@ export function validateAuthorLinks(
97
143
  }
98
144
 
99
145
  if (author.url) {
146
+ if (isValidAbsoluteAuthorUrl(author.url)) {
147
+ continue;
148
+ }
149
+
100
150
  const urlRootRelativeError = requireRootRelativePath(
101
151
  author.url,
102
152
  `${fileName}: url for "${key}"`,
@@ -104,7 +154,7 @@ export function validateAuthorLinks(
104
154
  if (urlRootRelativeError) {
105
155
  errors.push(urlRootRelativeError);
106
156
  } else {
107
- const urlPath = normalizeOutputPath(author.url);
157
+ const urlPath = getInternalHrefTarget(author.url);
108
158
  if (!validTargets.has(urlPath)) {
109
159
  errors.push(`${fileName}: broken url for "${key}": "${author.url}"`);
110
160
  }
@@ -6,7 +6,7 @@ import {
6
6
  } from '../config-files';
7
7
  import { getProjectConfigDir } from '../templates';
8
8
 
9
- export type WatchConfigPathKind = 'site' | 'nav' | 'authors';
9
+ type WatchConfigPathKind = 'site' | 'nav' | 'authors';
10
10
 
11
11
  function getConfigPathEntries(): Array<{
12
12
  kind: WatchConfigPathKind;
@@ -14,7 +14,7 @@ export interface TadaIncrementalWatchPlan {
14
14
  publicToRemove: Set<string>;
15
15
  }
16
16
 
17
- export type TadaWatchPlan = { kind: 'full' } | TadaIncrementalWatchPlan;
17
+ type TadaWatchPlan = { kind: 'full' } | TadaIncrementalWatchPlan;
18
18
 
19
19
  export function diffAuthorKeys(previous: unknown, next: unknown): Set<string> {
20
20
  const previousMap =
@@ -1,11 +1,8 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { normalizeOutputPath } from '../util';
4
3
  import {
5
4
  type TadaProjectScan,
6
5
  assertNoOutputPathConflicts,
7
- scanProject,
8
- updateProjectScan,
9
6
  } from '../source-model';
10
7
  import type { Asset, HtmlOutputAnalysis, SiteVariables } from '../types';
11
8
  import type { TadaBuildMeta } from './compiler-types';
@@ -60,7 +57,7 @@ function buildReverseMap(
60
57
  return reverse;
61
58
  }
62
59
 
63
- export function collectOutputOwners(snapshot: {
60
+ function collectOutputOwners(snapshot: {
64
61
  contentRecords: Map<string, TadaSourceRecord>;
65
62
  publicRecords: Map<string, TadaSourceRecord>;
66
63
  }): Map<string, TadaOutputOwner> {
@@ -164,7 +161,7 @@ export function collectSourceHtmlAnalysis(
164
161
  return htmlAnalysisByOutputPath;
165
162
  }
166
163
 
167
- export function collectHtmlAssetsByPath(
164
+ function collectHtmlAssetsByPath(
168
165
  contentRecords: Map<string, TadaSourceRecord>,
169
166
  ): Map<string, string> {
170
167
  const htmlAssetsByPath = new Map<string, string>();
@@ -178,7 +175,7 @@ export function collectHtmlAssetsByPath(
178
175
  return htmlAssetsByPath;
179
176
  }
180
177
 
181
- export function collectHtmlAnalysisByPath(
178
+ function collectHtmlAnalysisByPath(
182
179
  contentRecords: Map<string, TadaSourceRecord>,
183
180
  ): Map<string, HtmlOutputAnalysis> {
184
181
  const htmlAnalysisByPath = new Map<string, HtmlOutputAnalysis>();
@@ -201,13 +198,4 @@ export function createBuildMeta(snapshot: TadaSnapshot): TadaBuildMeta {
201
198
  };
202
199
  }
203
200
 
204
- export function normalizeInternalTarget(target: string): string {
205
- return normalizeOutputPath(target);
206
- }
207
-
208
- export {
209
- assertNoOutputPathConflicts,
210
- scanProject,
211
- updateProjectScan,
212
- type TadaProjectScan,
213
- };
201
+ export { assertNoOutputPathConflicts, type TadaProjectScan };
@@ -25,12 +25,10 @@ Selection sort is a well-known comparison-based sorting algorithm.
25
25
 
26
26
  Selection sort's strength is its simplicity.
27
27
 
28
+ - The helper method, `index_smallest()` is easy to understand
28
29
  - `index_smallest()` always scans from `start` to the end
29
30
  - It requires no additional memory (other than local variables)
30
31
 
31
- However, it has no `break` statements and therefore can't return early if
32
- the array is already sorted.
33
-
34
32
  ---
35
33
 
36
34
  ## Performance
@@ -51,12 +49,16 @@ The innermost statement compares `nums[i]` and `nums[curr]`.
51
49
  - when `i` is $n - 3$, the comparison is executed two times
52
50
  - when `i` is $n - 2$, the comparison is executed one time
53
51
 
54
- Therefore the total number of comparisons is
52
+ Therefore the total number of comparisons $C(n)$ is
55
53
 
56
54
  $$
57
- (n-1) + (n-2) + \dots + 1 = \sum_{i=1}^{n-1}i
55
+ (n-1) + (n-2) + \dots + 1 = \sum_{i=1}^{n-1}i = \frac{n(n-1)}{2}
58
56
  $$
59
57
 
58
+ ??? question What complexity class does $C(n)$ belong to?
59
+ It's a quadratic function. The class is $O(n^2)$.
60
+ ???
61
+
60
62
  ---
61
63
 
62
64
  ## Review
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abreen/tada",
3
- "version": "1.15.7",
3
+ "version": "1.15.9",
4
4
  "type": "module",
5
5
  "description": "A static site generator",
6
6
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  "check-deps": "bun run scripts/check-deps.ts",
34
34
  "init-example": "bun run bin/tada.ts init example --no-interactive",
35
35
  "iew": "bun run scripts/init-example-watch.ts",
36
- "lint": "eslint src/ build/ bin/",
36
+ "lint": "eslint src/ build/ bin/ templates/*.test.ts",
37
37
  "lint:sass": "bun run scripts/lint-sass.ts",
38
38
  "typecheck": "tsc --noEmit",
39
39
  "format": "prettier --write \"**/*.{ts,js,scss,json}\"",
@@ -61,7 +61,6 @@
61
61
  "chokidar": "^4.0.3",
62
62
  "culori": "^4.0.2",
63
63
  "d3-flextree": "^2.1.2",
64
- "d3-hierarchy": "^3.1.2",
65
64
  "fontkit": "^2.0.4",
66
65
  "front-matter": "^4.0.2",
67
66
  "java-parser": "^3.0.1",
@@ -101,7 +100,6 @@
101
100
  "@types/bun": "^1.3.12",
102
101
  "@types/culori": "^4.0.1",
103
102
  "@types/d3-flextree": "^2.1.4",
104
- "@types/d3-hierarchy": "^3.1.7",
105
103
  "@types/fontkit": "^2.0.9",
106
104
  "@types/istanbul-lib-coverage": "^2.0.6",
107
105
  "@types/istanbul-lib-instrument": "^1.7.8",
@@ -109,12 +107,14 @@
109
107
  "@types/istanbul-reports": "^3.0.4",
110
108
  "@types/lodash": "^4.17.24",
111
109
  "@types/markdown-it-container": "^4.0.0",
110
+ "@types/node": "^25.6.0",
112
111
  "eslint": "^10.2.1",
113
112
  "husky": "^9.1.7",
114
113
  "istanbul-lib-coverage": "^3.2.2",
115
114
  "istanbul-lib-instrument": "^6.0.3",
116
115
  "istanbul-lib-report": "^3.0.1",
117
116
  "istanbul-reports": "^3.2.0",
117
+ "knip": "^6.11.0",
118
118
  "lint-staged": "^16.4.0",
119
119
  "postcss-scss": "^4.0.9",
120
120
  "prettier": "^3.8.3",
@@ -122,7 +122,7 @@
122
122
  "stylelint": "^17.8.0",
123
123
  "stylelint-config-recess-order": "^7.7.0",
124
124
  "stylelint-config-standard-scss": "^17.0.0",
125
- "typescript": "^5.9.3",
125
+ "typescript": "^6.0.3",
126
126
  "typescript-eslint": "^8.58.2"
127
127
  }
128
128
  }
@@ -6,7 +6,7 @@
6
6
  "properties": {
7
7
  "name": { "type": "string" },
8
8
  "avatar": { "type": "string", "pattern": "^\\/[-a-zA-Z0-9_./#]*$" },
9
- "url": { "type": "string", "pattern": "^\\/[-a-zA-Z0-9_./#]*$" }
9
+ "url": { "type": "string", "minLength": 1 }
10
10
  },
11
11
  "additionalProperties": false
12
12
  }
@@ -130,6 +130,14 @@ function getLocationKey(window: Window): string {
130
130
  );
131
131
  }
132
132
 
133
+ function isAbortError(err: unknown): boolean {
134
+ return err instanceof Error && err.name === 'AbortError';
135
+ }
136
+
137
+ function isActiveNavigation(controller: AbortController): boolean {
138
+ return currentAbortController === controller && !controller.signal.aborted;
139
+ }
140
+
133
141
  export function initNavigation(window: Window): void {
134
142
  currentAbortController = null;
135
143
  historyIndex = 0;
@@ -191,6 +199,8 @@ export async function navigateToUrl(
191
199
 
192
200
  const controller = new AbortController();
193
201
  currentAbortController = controller;
202
+ document.documentElement.classList.remove('nav-forward', 'nav-back');
203
+ cleanupViewTransitionNames(document);
194
204
 
195
205
  const header = document.querySelector('header');
196
206
  header?.classList.add('loading');
@@ -199,21 +209,46 @@ export async function navigateToUrl(
199
209
  try {
200
210
  response = await globals.fetch(url, { signal: controller.signal });
201
211
  } catch (err: unknown) {
212
+ if (!isActiveNavigation(controller)) {
213
+ return;
214
+ }
202
215
  header?.classList.remove('loading');
203
- if (err instanceof Error && err.name === 'AbortError') {
216
+ if (isAbortError(err)) {
204
217
  return;
205
218
  }
206
219
  globals.setLocationHref(window, url);
207
220
  return;
208
221
  }
209
222
 
223
+ if (!isActiveNavigation(controller)) {
224
+ return;
225
+ }
226
+
210
227
  if (!response.ok) {
211
228
  header?.classList.remove('loading');
212
229
  globals.setLocationHref(window, url);
213
230
  return;
214
231
  }
215
232
 
216
- const html = await response.text();
233
+ let html: string;
234
+ try {
235
+ html = await response.text();
236
+ } catch (err: unknown) {
237
+ if (!isActiveNavigation(controller)) {
238
+ return;
239
+ }
240
+ header?.classList.remove('loading');
241
+ if (isAbortError(err)) {
242
+ return;
243
+ }
244
+ globals.setLocationHref(window, url);
245
+ return;
246
+ }
247
+
248
+ if (!isActiveNavigation(controller)) {
249
+ return;
250
+ }
251
+
217
252
  header?.classList.remove('loading');
218
253
 
219
254
  const DOMParserCtor = (window as unknown as { DOMParser: typeof DOMParser })
@@ -233,7 +268,11 @@ export async function navigateToUrl(
233
268
 
234
269
  const parsed = new URL(url);
235
270
 
236
- const doSwap = () => {
271
+ const doSwap = (): boolean => {
272
+ if (!isActiveNavigation(controller)) {
273
+ return false;
274
+ }
275
+
237
276
  teardownPerPageComponents();
238
277
  swapContent(document, newDoc);
239
278
  updateHead(document, newDoc);
@@ -257,6 +296,7 @@ export async function navigateToUrl(
257
296
 
258
297
  scrollByIndex.set(historyIndex, window.scrollY);
259
298
  scrollByLocation.set(getLocationKey(window), window.scrollY);
299
+ return true;
260
300
  };
261
301
 
262
302
  const transitionDoc = document as Document & {
@@ -295,7 +335,9 @@ export async function navigateToUrl(
295
335
  );
296
336
 
297
337
  const transition = transitionDoc.startViewTransition(() => {
298
- doSwap();
338
+ if (!doSwap()) {
339
+ return;
340
+ }
299
341
 
300
342
  const newTitleEl = document.querySelector('.title-and-info');
301
343
  const newHeaderHeight =
@@ -323,13 +365,23 @@ export async function navigateToUrl(
323
365
  });
324
366
 
325
367
  await transition.finished;
368
+ if (!isActiveNavigation(controller)) {
369
+ return;
370
+ }
371
+
326
372
  document.documentElement.classList.remove('nav-forward', 'nav-back');
327
373
  cleanupViewTransitionNames(document);
328
374
  } else {
329
- doSwap();
375
+ if (!doSwap()) {
376
+ return;
377
+ }
330
378
  }
331
379
 
332
380
  await mountPerPageComponents(window);
381
+ if (!isActiveNavigation(controller)) {
382
+ return;
383
+ }
384
+
333
385
  currentAbortController = null;
334
386
  dispatchNavigationEvent(window);
335
387
  }
@@ -11,7 +11,7 @@ import { globals } from '../globals';
11
11
 
12
12
  const POLL_INTERVAL_MS = 600_000;
13
13
 
14
- export interface PageUpdateOptions {
14
+ interface PageUpdateOptions {
15
15
  pollIntervalMs?: number;
16
16
  refreshPage?: (window: Window) => Promise<void>;
17
17
  }
@@ -112,6 +112,10 @@
112
112
  }
113
113
  }
114
114
 
115
+ .js body.is-presenting .question-a-body::after {
116
+ display: none;
117
+ }
118
+
115
119
  .js .question-a-body[data-revealed] {
116
120
  cursor: unset;
117
121
 
@@ -40,11 +40,6 @@ interface Pagefind {
40
40
 
41
41
  let pagefind: Pagefind | null = null;
42
42
 
43
- // Unit tests need to clear the module-scoped Pagefind instance between cases.
44
- export function resetPagefindForTest() {
45
- pagefind = null;
46
- }
47
-
48
43
  type SubResult = { title: string; url: string; excerpt: string };
49
44
 
50
45
  type State = {
@@ -1,4 +1,4 @@
1
- export type SubResult = { title: string; url: string; excerpt: string };
1
+ type SubResult = { title: string; url: string; excerpt: string };
2
2
  export type Result = {
3
3
  title: string;
4
4
  titleHtml: string | null;
@@ -1,5 +1,5 @@
1
1
  interface TraceToolbarState {
2
- hidden: boolean;
2
+ hidden: HTMLElement['hidden'];
3
3
  ariaHidden: string | null;
4
4
  }
5
5
 
@@ -1,50 +1,14 @@
1
+ import {
2
+ DEFAULT_PERIOD_STYLE,
3
+ detectPeriodStyle,
4
+ normalizeHM,
5
+ to12Hour,
6
+ type PeriodStyle,
7
+ } from './time-format';
8
+
1
9
  const STORAGE_KEY = 'timezoneSelection';
2
10
  const TIMEZONE_SELECT_LABEL = 'Time zone';
3
11
 
4
- type PeriodStyle = [am: string, pm: string];
5
-
6
- const DEFAULT_PERIOD_STYLE: PeriodStyle = ['a.m.', 'p.m.'];
7
-
8
- const PERIOD_PATTERNS: { pattern: RegExp; style: PeriodStyle }[] = [
9
- { pattern: /[AP]\.M\./, style: ['A.M.', 'P.M.'] },
10
- { pattern: /[AP]M/, style: ['AM', 'PM'] },
11
- { pattern: /[ap]\.m\./, style: ['a.m.', 'p.m.'] },
12
- { pattern: /[ap]m/, style: ['am', 'pm'] },
13
- ];
14
-
15
- export function detectPeriodStyle(text: string): PeriodStyle | null {
16
- for (const { pattern, style } of PERIOD_PATTERNS) {
17
- if (pattern.test(text)) {
18
- return style;
19
- }
20
- }
21
- return null;
22
- }
23
-
24
- function pad(n: number | string) {
25
- return String(n).padStart(2, '0');
26
- }
27
-
28
- export function to12Hour(
29
- h: number,
30
- m: number,
31
- style: PeriodStyle | null = DEFAULT_PERIOD_STYLE,
32
- ) {
33
- const hour12 = ((h + 11) % 12) + 1;
34
- if (!style) {
35
- return `${hour12}:${pad(m)}`;
36
- }
37
- const period = h >= 12 ? style[1] : style[0];
38
- return `${hour12}:${pad(m)} ${period}`;
39
- }
40
-
41
- export function normalizeHM(totalMinutes: number) {
42
- const mins = ((totalMinutes % 1440) + 1440) % 1440;
43
- const h = Math.floor(mins / 60);
44
- const m = mins % 60;
45
- return [h, m] as const;
46
- }
47
-
48
12
  function getDayShift(totalMinutes: number) {
49
13
  // Negative partial days like -30 minutes should still count as the previous day.
50
14
  return Math.floor(totalMinutes / 1440);
@@ -0,0 +1,43 @@
1
+ export type PeriodStyle = [am: string, pm: string];
2
+
3
+ export const DEFAULT_PERIOD_STYLE: PeriodStyle = ['a.m.', 'p.m.'];
4
+
5
+ const PERIOD_PATTERNS: { pattern: RegExp; style: PeriodStyle }[] = [
6
+ { pattern: /[AP]\.M\./, style: ['A.M.', 'P.M.'] },
7
+ { pattern: /[AP]M/, style: ['AM', 'PM'] },
8
+ { pattern: /[ap]\.m\./, style: ['a.m.', 'p.m.'] },
9
+ { pattern: /[ap]m/, style: ['am', 'pm'] },
10
+ ];
11
+
12
+ export function detectPeriodStyle(text: string): PeriodStyle | null {
13
+ for (const { pattern, style } of PERIOD_PATTERNS) {
14
+ if (pattern.test(text)) {
15
+ return style;
16
+ }
17
+ }
18
+ return null;
19
+ }
20
+
21
+ function pad(n: number | string) {
22
+ return String(n).padStart(2, '0');
23
+ }
24
+
25
+ export function to12Hour(
26
+ h: number,
27
+ m: number,
28
+ style: PeriodStyle | null = DEFAULT_PERIOD_STYLE,
29
+ ) {
30
+ const hour12 = ((h + 11) % 12) + 1;
31
+ if (!style) {
32
+ return `${hour12}:${pad(m)}`;
33
+ }
34
+ const period = h >= 12 ? style[1] : style[0];
35
+ return `${hour12}:${pad(m)} ${period}`;
36
+ }
37
+
38
+ export function normalizeHM(totalMinutes: number) {
39
+ const mins = ((totalMinutes % 1440) + 1440) % 1440;
40
+ const h = Math.floor(mins / 60);
41
+ const m = mins % 60;
42
+ return [h, m] as const;
43
+ }
package/src/toc/index.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  import { debounce } from '../util';
2
+ import {
3
+ alertToTableItem,
4
+ getHighlightIndexes,
5
+ headingToTableItem,
6
+ switchCurrent,
7
+ type Dinkus,
8
+ } from './model';
2
9
 
3
10
  const LATENCY_MS = 50;
4
11
 
5
- export type HeadingLevel = '1' | '2' | '3' | '4' | '5' | '6';
6
- export type AlertType = 'warning' | 'note';
7
-
8
- export type Alert = { type: AlertType; title: string };
9
- export type Heading = { level: HeadingLevel; innerHtml: string; id: string };
10
- export type Dinkus = { type: 'dinkus' };
11
-
12
12
  function getContainer(parent: HTMLElement): HTMLElement | null {
13
13
  return parent.querySelector('nav.toc');
14
14
  }
@@ -40,27 +40,6 @@ function scrollIfNeeded(element: HTMLElement, doc: Document) {
40
40
  container.scrollTo({ top: desiredScrollTop });
41
41
  }
42
42
 
43
- export function getHighlightIndexes(items: (Heading | Alert | Dinkus)[]) {
44
- const indexes: (number | null)[] = [];
45
- let currentHeadingIndex: number | null = null;
46
- let tocIndex = 0;
47
-
48
- items.forEach(item => {
49
- if (!('level' in item) && item.type === 'dinkus') {
50
- return;
51
- }
52
-
53
- if ('level' in item) {
54
- currentHeadingIndex = tocIndex;
55
- }
56
-
57
- indexes.push(currentHeadingIndex ?? tocIndex);
58
- tocIndex++;
59
- });
60
-
61
- return indexes;
62
- }
63
-
64
43
  function getHeadingsAndAlerts(
65
44
  parent: HTMLElement,
66
45
  ): (HTMLHeadingElement | HTMLDivElement)[] {
@@ -97,57 +76,6 @@ function getViewportActivationPoint(win: Window, doc: Document) {
97
76
  return headerOffset + (win.innerHeight - headerOffset) / 3;
98
77
  }
99
78
 
100
- export function headingToTableItem(el: HTMLHeadingElement): Heading {
101
- const level = el.tagName[1] as HeadingLevel;
102
-
103
- const subtitle = el.querySelector('.heading-subtitle');
104
- const subtitleText = subtitle?.textContent || '';
105
- let mainText = el.textContent || '';
106
-
107
- if (mainText.length > 0 && subtitleText.length > 0) {
108
- mainText = mainText.replace(subtitleText, '').trim();
109
- return {
110
- level,
111
- id: el.id,
112
- innerHtml: `${mainText}: <span class="heading-subtitle">${subtitleText}</span>`,
113
- };
114
- }
115
- return { level, id: el.id, innerHtml: el.innerHTML };
116
- }
117
-
118
- export function alertToTableItem(el: HTMLElement): Alert | null {
119
- const classes = el.className
120
- .split(' ')
121
- .map(cl => cl.trim())
122
- .filter(cl => cl != 'alert');
123
-
124
- const firstClass = classes[0];
125
- if (firstClass === 'warning' || firstClass === 'note') {
126
- let title = el.querySelector('.title')?.innerHTML;
127
- if (!title) {
128
- if (firstClass === 'warning') {
129
- title = 'Warning';
130
- } else {
131
- title = 'Note';
132
- }
133
- }
134
-
135
- return { type: firstClass, title };
136
- }
137
-
138
- return null;
139
- }
140
-
141
- export function switchCurrent(
142
- oldCurrent: HTMLElement | null,
143
- newCurrent: HTMLElement,
144
- ) {
145
- if (oldCurrent) {
146
- oldCurrent.classList.remove('current');
147
- }
148
- newCurrent.classList.add('current');
149
- }
150
-
151
79
  export default (window: Window) => {
152
80
  const toc = window.document.querySelector('nav.toc') as HTMLElement;
153
81
  if (toc == null) {
@@ -0,0 +1,78 @@
1
+ type HeadingLevel = '1' | '2' | '3' | '4' | '5' | '6';
2
+ type AlertType = 'warning' | 'note';
3
+
4
+ export type Alert = { type: AlertType; title: string };
5
+ export type Heading = { level: HeadingLevel; innerHtml: string; id: string };
6
+ export type Dinkus = { type: 'dinkus' };
7
+
8
+ export function getHighlightIndexes(items: (Heading | Alert | Dinkus)[]) {
9
+ const indexes: (number | null)[] = [];
10
+ let currentHeadingIndex: number | null = null;
11
+ let tocIndex = 0;
12
+
13
+ items.forEach(item => {
14
+ if (!('level' in item) && item.type === 'dinkus') {
15
+ return;
16
+ }
17
+
18
+ if ('level' in item) {
19
+ currentHeadingIndex = tocIndex;
20
+ }
21
+
22
+ indexes.push(currentHeadingIndex ?? tocIndex);
23
+ tocIndex++;
24
+ });
25
+
26
+ return indexes;
27
+ }
28
+
29
+ export function headingToTableItem(el: HTMLHeadingElement): Heading {
30
+ const level = el.tagName[1] as HeadingLevel;
31
+
32
+ const subtitle = el.querySelector('.heading-subtitle');
33
+ const subtitleText = subtitle?.textContent || '';
34
+ let mainText = el.textContent || '';
35
+
36
+ if (mainText.length > 0 && subtitleText.length > 0) {
37
+ mainText = mainText.replace(subtitleText, '').trim();
38
+ return {
39
+ level,
40
+ id: el.id,
41
+ innerHtml: `${mainText}: <span class="heading-subtitle">${subtitleText}</span>`,
42
+ };
43
+ }
44
+ return { level, id: el.id, innerHtml: el.innerHTML };
45
+ }
46
+
47
+ export function alertToTableItem(el: HTMLElement): Alert | null {
48
+ const classes = el.className
49
+ .split(' ')
50
+ .map(cl => cl.trim())
51
+ .filter(cl => cl != 'alert');
52
+
53
+ const firstClass = classes[0];
54
+ if (firstClass === 'warning' || firstClass === 'note') {
55
+ let title = el.querySelector('.title')?.innerHTML;
56
+ if (!title) {
57
+ if (firstClass === 'warning') {
58
+ title = 'Warning';
59
+ } else {
60
+ title = 'Note';
61
+ }
62
+ }
63
+
64
+ return { type: firstClass, title };
65
+ }
66
+
67
+ return null;
68
+ }
69
+
70
+ export function switchCurrent(
71
+ oldCurrent: HTMLElement | null,
72
+ newCurrent: HTMLElement,
73
+ ) {
74
+ if (oldCurrent) {
75
+ oldCurrent.classList.remove('current');
76
+ }
77
+ newCurrent.classList.add('current');
78
+ }
@@ -1,14 +1,14 @@
1
1
  <aside class="author">
2
2
  Written by
3
3
  <% if (page.author.url) { %>
4
- <a href="<%= page.author.url %>">
4
+ <a href="<%- encodeAuthoredUrl(page.author.url) %>">
5
5
  <% } %>
6
6
  <span class="text"><%= page.author.name %></span>
7
7
  <% if (page.author.avatar) { %>
8
8
  <img
9
9
  width="32"
10
10
  height="32"
11
- src="<%= page.author.avatar %>"
11
+ src="<%- encodeAuthoredUrl(page.author.avatar) %>"
12
12
  alt="Picture of <%= page.author.name %>"
13
13
  class="avatar"
14
14
  />
@@ -1,5 +1,5 @@
1
1
  <% if (site.features.footer) { %>
2
2
  <footer data-pagefind-ignore>
3
- <p>Built using <a href="https://github.com/abreen/Tada" target="_blank">Tada</a> <%= tadaVersion %></p>
3
+ <p>Built using <a href="https://github.com/abreen/Tada" target="_blank" rel="noopener noreferrer">Tada</a> <%= tadaVersion %></p>
4
4
  </footer>
5
5
  <% } %>
@@ -1,6 +1,6 @@
1
1
  <div class="title-and-info" data-pagefind-body>
2
2
  <% if (page.parent) { %>
3
- <a class="breadcrumb" href="<%= page.parent %>" data-pagefind-ignore><%= page.parentLabel %></a>
3
+ <a class="breadcrumb" href="<%- encodeAuthoredUrl(page.parent) %>" data-pagefind-ignore><%= page.parentLabel %></a>
4
4
  <% } %>
5
5
  <h1 data-pagefind-weight="10" data-pagefind-meta="title_html[data-title-html]" data-title-html="<%- page.titleHtml %>"><%= page.titleHtml %></h1>
6
6
 
@@ -7,8 +7,8 @@
7
7
  <li>
8
8
  <a
9
9
  class="<%= cx({disabled: link.disabled, external: link.external}) %>"
10
- <% if (!link.disabled) { %>href="<%= link.internal || link.external %>"<% } %>
11
- <% if (link.external) { %>target="_blank"<% } %>
10
+ <% if (!link.disabled) { %>href="<%- encodeAuthoredUrl(link.internal || link.external) %>"<% } %>
11
+ <% if (link.external) { %>target="_blank" rel="noopener noreferrer"<% } %>
12
12
  >
13
13
  <%= link.text %></a>
14
14
  </li>