@abreen/tada 1.0.2 → 1.2.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.
- package/README.md +96 -48
- package/bin/tada.ts +621 -0
- package/bin/validators.test.ts +205 -0
- package/bin/validators.ts +85 -0
- package/{webpack/apply-base-path-plugin.js → build/apply-base-path-plugin.ts} +37 -17
- package/build/build-manifest.test.ts +349 -0
- package/build/build-manifest.ts +165 -0
- package/build/bundle.ts +116 -0
- package/{webpack/code.test.js → build/code.test.ts} +10 -7
- package/build/colors.ts +25 -0
- package/build/content-watch.ts +107 -0
- package/build/copy.ts +118 -0
- package/{webpack/deflist-id-plugin.js → build/deflist-id-plugin.ts} +7 -6
- package/{webpack/external-links-plugin.js → build/external-links-plugin.ts} +14 -5
- package/build/features.ts +11 -0
- package/build/generate-content-assets.ts +320 -0
- package/build/generate-favicon.ts +172 -0
- package/build/generate-fonts.ts +31 -0
- package/build/generate-katex-assets.ts +35 -0
- package/{webpack/generate-manifest-plugin.js → build/generate-manifest.ts} +29 -36
- package/build/globals.test.ts +99 -0
- package/build/globals.ts +88 -0
- package/{webpack/heading-subtitle-plugin.js → build/heading-subtitle-plugin.ts} +4 -2
- package/build/json-schema.test.ts +57 -0
- package/build/json-schema.ts +33 -0
- package/build/log.test.ts +111 -0
- package/build/log.ts +167 -0
- package/build/markdown-plugins.test.ts +396 -0
- package/{webpack/pagefind-plugin.test.js → build/pagefind.test.ts} +74 -13
- package/build/pagefind.ts +339 -0
- package/build/pdf-text.test.ts +52 -0
- package/{webpack/pdf-text.js → build/pdf-text.ts} +47 -27
- package/build/pipeline.ts +120 -0
- package/{webpack/reachability.test.js → build/reachability.test.ts} +3 -3
- package/{webpack/reachability.js → build/reachability.ts} +77 -34
- package/build/serve.ts +112 -0
- package/build/site-variables.ts +68 -0
- package/{webpack/templates.js → build/templates.ts} +38 -35
- package/{webpack/text-to-id.js → build/text-to-id.ts} +2 -2
- package/build/toc-plugin.test.ts +105 -0
- package/{webpack/toc-plugin.js → build/toc-plugin.ts} +32 -13
- package/build/types.ts +181 -0
- package/build/util.ts +26 -0
- package/{webpack/utils/code.js → build/utils/code.ts} +119 -60
- package/{webpack/utils/content-files.js → build/utils/content-files.ts} +40 -35
- package/build/utils/derive-theme.test.ts +111 -0
- package/build/utils/derive-theme.ts +85 -0
- package/build/utils/file-types.test.ts +61 -0
- package/build/utils/file-types.ts +13 -0
- package/build/utils/front-matter.test.ts +77 -0
- package/{webpack/utils/front-matter.js → build/utils/front-matter.ts} +22 -9
- package/{webpack → build}/utils/jdi-runner/LiterateRunner.java +1 -1
- package/{webpack/utils/literate-java.js → build/utils/literate-java.ts} +68 -38
- package/{webpack/utils/markdown.js → build/utils/markdown.ts} +126 -49
- package/build/utils/paths.test.ts +91 -0
- package/{webpack/utils/paths.js → build/utils/paths.ts} +18 -22
- package/{webpack/utils/render.js → build/utils/render.ts} +208 -130
- package/build/utils/shiki-highlighter.ts +29 -0
- package/build/validate-internal-links-plugin.test.ts +106 -0
- package/{webpack/validate-internal-links-plugin.js → build/validate-internal-links-plugin.ts} +47 -20
- package/{webpack/watch-reachability-state.test.js → build/watch-reachability-state.test.ts} +8 -8
- package/{webpack/watch-reachability-state.js → build/watch-reachability-state.ts} +63 -24
- package/{webpack/watch-reload-client.js → build/watch-reload-client.ts} +3 -1
- package/build/watch.ts +549 -0
- package/fonts/google-sans-code/woff2/GoogleSansCodeVariable-Italic.woff2 +0 -0
- package/fonts/google-sans-code/woff2/GoogleSansCodeVariable.woff2 +0 -0
- package/fonts/inter/woff2/InterVariable-Italic.woff2 +0 -0
- package/fonts/inter/woff2/InterVariable.woff2 +0 -0
- package/{content → init/content}/index.md +9 -3
- package/{content → init/content}/lectures/index.md +1 -1
- package/{content → init/content}/markdown.md +28 -8
- package/init/content/problem_sets/index.html +14 -0
- package/package.json +33 -22
- package/{templates → schema}/nav.schema.json +1 -2
- package/{webpack → schema}/site.schema.json +4 -12
- package/src/_alerts.scss +96 -0
- package/src/_base.scss +145 -0
- package/src/{layout.scss → _layout.scss} +36 -1
- package/src/anchor/index.ts +0 -1
- package/src/anchor/style.scss +1 -9
- package/src/code/index.ts +3 -3
- package/src/code.scss +1 -1
- package/src/critical.scss +5 -0
- package/src/header/_base.scss +129 -0
- package/src/header/style.scss +3 -131
- package/src/index.ts +4 -5
- package/src/question/style.scss +1 -1
- package/src/search/index.test.ts +203 -0
- package/src/search/index.ts +38 -93
- package/src/search/pdf-utils.ts +79 -0
- package/src/search/style.scss +8 -28
- package/src/style.scss +15 -326
- package/src/timezone/index.ts +5 -6
- package/src/toc/style.scss +5 -39
- package/src/util.ts +9 -6
- package/templates/_theme.scss +38 -14
- package/templates/_top.html +1 -1
- package/tsconfig.json +10 -6
- package/types/dev.d.ts +1 -0
- package/types/file-system-access.d.ts +5 -0
- package/types/markdown-it-plugins.d.ts +11 -0
- package/types/site-variables.d.ts +6 -10
- package/types/untyped-modules.d.ts +47 -0
- package/bin/tada.js +0 -361
- package/config/authors.json +0 -1
- package/config/nav.json +0 -28
- package/content/problem_sets/index.md +0 -6
- package/types/dev.ts +0 -3
- package/webpack/build-state.js +0 -97
- package/webpack/colors.js +0 -15
- package/webpack/config.base.js +0 -151
- package/webpack/config.dev.js +0 -23
- package/webpack/config.prod.js +0 -32
- package/webpack/content-watch-plugin.js +0 -153
- package/webpack/features.js +0 -5
- package/webpack/generate-content-assets-plugin.js +0 -308
- package/webpack/generate-favicon-plugin.js +0 -198
- package/webpack/generate-fonts-plugin.js +0 -69
- package/webpack/globals.js +0 -74
- package/webpack/json-schema.js +0 -19
- package/webpack/log.js +0 -143
- package/webpack/markdown-plugins.test.js +0 -203
- package/webpack/pagefind-plugin.js +0 -379
- package/webpack/print-flair-plugin.js +0 -22
- package/webpack/serve.js +0 -104
- package/webpack/site-variables.js +0 -53
- package/webpack/util.js +0 -49
- package/webpack/utils/define-plugin.js +0 -20
- package/webpack/utils/file-types.js +0 -26
- package/webpack/utils/parse-hsl.js +0 -8
- package/webpack/utils/shiki-highlighter.js +0 -26
- package/webpack/watch.js +0 -166
- /package/{webpack → build}/flair.json +0 -0
- /package/{webpack → build}/utils/jdi-runner/LiterateRunner.class +0 -0
- /package/fonts/google-sans-code/{GoogleSansCodeVariable-Italic.ttf → ttf/GoogleSansCodeVariable-Italic.ttf} +0 -0
- /package/fonts/google-sans-code/{GoogleSansCodeVariable.ttf → ttf/GoogleSansCodeVariable.ttf} +0 -0
- /package/fonts/inter/{InterVariable-Italic.ttf → ttf/InterVariable-Italic.ttf} +0 -0
- /package/fonts/inter/{InterVariable.ttf → ttf/InterVariable.ttf} +0 -0
- /package/{content → init/content}/lectures/01/Pair.java.md +0 -0
- /package/{content → init/content}/lectures/01/Rectangle.java +0 -0
- /package/{content → init/content}/lectures/01/demo.py +0 -0
- /package/{content → init/content}/lectures/01/index.md +0 -0
- /package/{content → init/content}/lectures/01/lecture1.pdf +0 -0
- /package/{public → init/public}/avatars/alex.jpg +0 -0
- /package/{public → init/public}/test.txt +0 -0
- /package/{templates → schema}/authors.schema.json +0 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import * as fontkit from 'fontkit';
|
|
4
|
+
import sharp from 'sharp';
|
|
5
|
+
import pngToIco from 'png-to-ico';
|
|
6
|
+
import { makeLogger } from './log';
|
|
7
|
+
import { getPackageDir } from './utils/paths';
|
|
8
|
+
import { deriveTheme } from './utils/derive-theme';
|
|
9
|
+
import type { SiteVariables } from './types';
|
|
10
|
+
|
|
11
|
+
const log = makeLogger(__filename);
|
|
12
|
+
|
|
13
|
+
const FONT_PATH = path.join(
|
|
14
|
+
getPackageDir(),
|
|
15
|
+
'fonts',
|
|
16
|
+
'inter',
|
|
17
|
+
'ttf',
|
|
18
|
+
'InterVariable.ttf',
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
function createFaviconSvg(
|
|
22
|
+
size: number,
|
|
23
|
+
color: string,
|
|
24
|
+
textColor: string,
|
|
25
|
+
symbol: string,
|
|
26
|
+
font: fontkit.Font,
|
|
27
|
+
cssWeight: number,
|
|
28
|
+
): string {
|
|
29
|
+
if (size < 10) {
|
|
30
|
+
throw new Error(`size is too small: ${size}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!symbol) {
|
|
34
|
+
throw new Error(`invalid symbol: ${symbol}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (symbol.length > 5) {
|
|
38
|
+
throw new Error(`symbol is too long (must be <= 5 chars): ${symbol}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Apply variation axes for variable fonts
|
|
42
|
+
const instance =
|
|
43
|
+
typeof font.getVariation === 'function' &&
|
|
44
|
+
font.variationAxes &&
|
|
45
|
+
Object.keys(font.variationAxes).length > 0
|
|
46
|
+
? font.getVariation({ wght: cssWeight })
|
|
47
|
+
: font;
|
|
48
|
+
|
|
49
|
+
const run = instance.layout(symbol);
|
|
50
|
+
|
|
51
|
+
// Compute combined bounding box across all glyphs (font units, y-up)
|
|
52
|
+
let x = 0;
|
|
53
|
+
let totalMinX = Infinity,
|
|
54
|
+
totalMaxX = -Infinity;
|
|
55
|
+
let totalMinY = Infinity,
|
|
56
|
+
totalMaxY = -Infinity;
|
|
57
|
+
const advances: number[] = [];
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < run.glyphs.length; i++) {
|
|
60
|
+
const bbox = run.glyphs[i].bbox;
|
|
61
|
+
totalMinX = Math.min(totalMinX, x + bbox.minX);
|
|
62
|
+
totalMaxX = Math.max(totalMaxX, x + bbox.maxX);
|
|
63
|
+
totalMinY = Math.min(totalMinY, bbox.minY);
|
|
64
|
+
totalMaxY = Math.max(totalMaxY, bbox.maxY);
|
|
65
|
+
advances.push(x);
|
|
66
|
+
x += run.positions[i].xAdvance;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const textW = totalMaxX - totalMinX;
|
|
70
|
+
const textH = totalMaxY - totalMinY;
|
|
71
|
+
|
|
72
|
+
if (textW <= 0 || textH <= 0) {
|
|
73
|
+
throw new Error(`Could not get glyph bounds for symbol: ${symbol}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const pad = size * 0.1;
|
|
77
|
+
const contentW = size - pad * 2;
|
|
78
|
+
const contentH = size - pad * 2;
|
|
79
|
+
|
|
80
|
+
// Scale font units to SVG pixels to fill padded content area
|
|
81
|
+
const s = Math.min(contentW / textW, contentH / textH);
|
|
82
|
+
|
|
83
|
+
// Font coords are y-up; SVG is y-down. The outer transform flips y via scale(s, -s).
|
|
84
|
+
// We want the bbox center to land at (size/2, size/2).
|
|
85
|
+
const bboxCenterX = (totalMinX + totalMaxX) / 2;
|
|
86
|
+
const bboxCenterY = (totalMinY + totalMaxY) / 2;
|
|
87
|
+
const tx = size / 2 - bboxCenterX * s;
|
|
88
|
+
const ty = size / 2 + bboxCenterY * s;
|
|
89
|
+
|
|
90
|
+
// Each glyph is translated by its cumulative x-advance (in font units).
|
|
91
|
+
// The outer group handles y-flip and centering.
|
|
92
|
+
const paths = run.glyphs
|
|
93
|
+
.map((glyph: fontkit.Glyph, i: number) => {
|
|
94
|
+
const d = glyph.path.toSVG();
|
|
95
|
+
return advances[i] === 0
|
|
96
|
+
? `<path d="${d}" fill="${textColor}" />`
|
|
97
|
+
: `<path d="${d}" transform="translate(${advances[i]} 0)" fill="${textColor}" />`;
|
|
98
|
+
})
|
|
99
|
+
.join('\n ');
|
|
100
|
+
|
|
101
|
+
return `<svg viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">
|
|
102
|
+
<rect width="${size}" height="${size}" rx="${size / 10}" fill="${color}" />
|
|
103
|
+
<g transform="translate(${tx.toFixed(2)} ${ty.toFixed(2)}) scale(${s.toFixed(4)} ${(-s).toFixed(4)})">
|
|
104
|
+
${paths}
|
|
105
|
+
</g>
|
|
106
|
+
</svg>`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function generateFavicons(
|
|
110
|
+
siteVariables: SiteVariables,
|
|
111
|
+
distDir: string,
|
|
112
|
+
): Promise<void> {
|
|
113
|
+
const color = siteVariables.faviconColor;
|
|
114
|
+
const symbol = siteVariables.faviconSymbol;
|
|
115
|
+
const fontWeight = siteVariables.faviconFontWeight || 700;
|
|
116
|
+
const sizes = [16, 32, 48, 64, 128, 192, 256, 512, 1024];
|
|
117
|
+
const svgSize = 512;
|
|
118
|
+
const filenameBase = 'favicon';
|
|
119
|
+
|
|
120
|
+
if (!symbol) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
'Favicon generation requires "symbol" in site config (or "faviconSymbol")',
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (!color) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
'Favicon generation requires "themeColor" in site config (or "faviconColor")',
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const { themeColorLight, textOnThemeLight } = deriveTheme(color);
|
|
132
|
+
|
|
133
|
+
log.info`Generating favicons`;
|
|
134
|
+
log.debug`Using font file: ${FONT_PATH}`;
|
|
135
|
+
const font = fontkit.openSync(FONT_PATH) as fontkit.Font;
|
|
136
|
+
|
|
137
|
+
const svgMarkup = createFaviconSvg(
|
|
138
|
+
svgSize,
|
|
139
|
+
themeColorLight,
|
|
140
|
+
textOnThemeLight,
|
|
141
|
+
symbol,
|
|
142
|
+
font,
|
|
143
|
+
fontWeight,
|
|
144
|
+
);
|
|
145
|
+
fs.writeFileSync(path.join(distDir, `${filenameBase}.svg`), svgMarkup);
|
|
146
|
+
|
|
147
|
+
const pngBuffers = await Promise.all(
|
|
148
|
+
sizes.map(async size => {
|
|
149
|
+
const svgForSize = createFaviconSvg(
|
|
150
|
+
size,
|
|
151
|
+
themeColorLight,
|
|
152
|
+
textOnThemeLight,
|
|
153
|
+
symbol,
|
|
154
|
+
font,
|
|
155
|
+
fontWeight,
|
|
156
|
+
);
|
|
157
|
+
const buf = await sharp(Buffer.from(svgForSize)).png().toBuffer();
|
|
158
|
+
fs.writeFileSync(path.join(distDir, `${filenameBase}-${size}.png`), buf);
|
|
159
|
+
return { size, buf };
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const icoBuffer = await pngToIco(
|
|
164
|
+
pngBuffers
|
|
165
|
+
.filter(b => b.size <= 256)
|
|
166
|
+
.sort((a, b) => a.size - b.size)
|
|
167
|
+
.map(x => x.buf),
|
|
168
|
+
);
|
|
169
|
+
fs.writeFileSync(path.join(distDir, `${filenameBase}.ico`), icoBuffer);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export { createFaviconSvg };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { getPackageDir } from './utils/paths';
|
|
4
|
+
import { makeLogger } from './log';
|
|
5
|
+
|
|
6
|
+
const log = makeLogger(__filename);
|
|
7
|
+
const FONTS_DIR = path.join(getPackageDir(), 'fonts');
|
|
8
|
+
|
|
9
|
+
export function copyFonts(distDir: string): void {
|
|
10
|
+
log.info`Copying fonts`;
|
|
11
|
+
|
|
12
|
+
for (const family of fs.readdirSync(FONTS_DIR)) {
|
|
13
|
+
const woff2Dir = path.join(FONTS_DIR, family, 'woff2');
|
|
14
|
+
if (!fs.existsSync(woff2Dir) || !fs.statSync(woff2Dir).isDirectory()) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const outFamilyDir = path.join(distDir, family);
|
|
19
|
+
fs.mkdirSync(outFamilyDir, { recursive: true });
|
|
20
|
+
|
|
21
|
+
for (const file of fs.readdirSync(woff2Dir)) {
|
|
22
|
+
if (file.endsWith('.woff2')) {
|
|
23
|
+
fs.copyFileSync(
|
|
24
|
+
path.join(woff2Dir, file),
|
|
25
|
+
path.join(outFamilyDir, file),
|
|
26
|
+
);
|
|
27
|
+
log.debug`Copied ${family}/${file}`;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import * as sass from 'sass';
|
|
4
|
+
import { makeLogger } from './log';
|
|
5
|
+
|
|
6
|
+
const log = makeLogger(__filename);
|
|
7
|
+
|
|
8
|
+
export function copyKatexAssets(distDir: string): void {
|
|
9
|
+
log.info`Copying KaTeX assets`;
|
|
10
|
+
|
|
11
|
+
const outDir = path.join(distDir, 'katex');
|
|
12
|
+
const outFontsDir = path.join(outDir, 'fonts');
|
|
13
|
+
fs.mkdirSync(outFontsDir, { recursive: true });
|
|
14
|
+
|
|
15
|
+
// Compile KaTeX SCSS with woff2-only font references
|
|
16
|
+
const katexScssDir = path.dirname(
|
|
17
|
+
require.resolve('katex/src/styles/katex.scss'),
|
|
18
|
+
);
|
|
19
|
+
const result = sass.compileString(
|
|
20
|
+
`@use 'katex' with ($use-woff2: true, $use-woff: false, $use-ttf: false, $font-folder: 'fonts');`,
|
|
21
|
+
{ loadPaths: [katexScssDir], style: 'compressed' },
|
|
22
|
+
);
|
|
23
|
+
fs.writeFileSync(path.join(outDir, 'katex.min.css'), result.css);
|
|
24
|
+
|
|
25
|
+
// Copy woff2 fonts
|
|
26
|
+
const katexDistDir = path.dirname(
|
|
27
|
+
require.resolve('katex/dist/katex.min.css'),
|
|
28
|
+
);
|
|
29
|
+
const fontsDir = path.join(katexDistDir, 'fonts');
|
|
30
|
+
for (const file of fs.readdirSync(fontsDir)) {
|
|
31
|
+
if (file.endsWith('.woff2')) {
|
|
32
|
+
fs.copyFileSync(path.join(fontsDir, file), path.join(outFontsDir, file));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -1,43 +1,23 @@
|
|
|
1
|
-
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { createApplyBasePath } from './util';
|
|
4
|
+
import type { SiteVariables } from './types';
|
|
2
5
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
apply(compiler) {
|
|
9
|
-
compiler.hooks.thisCompilation.tap(
|
|
10
|
-
'GenerateManifestPlugin',
|
|
11
|
-
compilation => {
|
|
12
|
-
const wp = compilation.compiler.webpack || {};
|
|
13
|
-
const { RawSource } =
|
|
14
|
-
(wp.sources && wp.sources) || require('webpack-sources');
|
|
15
|
-
|
|
16
|
-
compilation.hooks.processAssets.tapPromise(
|
|
17
|
-
{
|
|
18
|
-
name: 'GenerateManifestPlugin',
|
|
19
|
-
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
|
|
20
|
-
},
|
|
21
|
-
async () => {
|
|
22
|
-
try {
|
|
23
|
-
const manifest = createManifest(this.siteVariables);
|
|
24
|
-
const output = JSON.stringify(manifest);
|
|
25
|
-
compilation.emitAsset('manifest.json', new RawSource(output));
|
|
26
|
-
} catch (err) {
|
|
27
|
-
compilation.errors.push(
|
|
28
|
-
new Error(`Error: ${err && err.message ? err.message : err}`),
|
|
29
|
-
);
|
|
30
|
-
}
|
|
31
|
-
},
|
|
32
|
-
);
|
|
33
|
-
},
|
|
34
|
-
);
|
|
35
|
-
}
|
|
6
|
+
interface ManifestIcon {
|
|
7
|
+
src: string;
|
|
8
|
+
sizes: string;
|
|
9
|
+
type: string;
|
|
10
|
+
purpose: string;
|
|
36
11
|
}
|
|
37
12
|
|
|
38
|
-
|
|
13
|
+
interface WebAppManifest {
|
|
14
|
+
name: string | undefined;
|
|
15
|
+
start_url: string;
|
|
16
|
+
display: string;
|
|
17
|
+
icons: ManifestIcon[];
|
|
18
|
+
}
|
|
39
19
|
|
|
40
|
-
function createManifest(siteVariables) {
|
|
20
|
+
function createManifest(siteVariables: SiteVariables): WebAppManifest {
|
|
41
21
|
const applyBasePath = createApplyBasePath(siteVariables);
|
|
42
22
|
|
|
43
23
|
return {
|
|
@@ -114,3 +94,16 @@ function createManifest(siteVariables) {
|
|
|
114
94
|
],
|
|
115
95
|
};
|
|
116
96
|
}
|
|
97
|
+
|
|
98
|
+
export function generateManifest(
|
|
99
|
+
siteVariables: SiteVariables,
|
|
100
|
+
distDir: string,
|
|
101
|
+
): void {
|
|
102
|
+
const manifest = createManifest(siteVariables);
|
|
103
|
+
fs.writeFileSync(
|
|
104
|
+
path.join(distDir, 'manifest.json'),
|
|
105
|
+
JSON.stringify(manifest),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export { createManifest };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import createGlobals from './globals';
|
|
3
|
+
import type { SiteVariables } from './types';
|
|
4
|
+
|
|
5
|
+
const baseSite: SiteVariables = {
|
|
6
|
+
base: 'http://localhost:8080',
|
|
7
|
+
basePath: '/',
|
|
8
|
+
title: 'Test',
|
|
9
|
+
titlePostfix: ' - Test',
|
|
10
|
+
themeColor: 'hsl(195 70% 40%)',
|
|
11
|
+
defaultTimeZone: 'America/New_York',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function makeGlobals(subPath = 'index', site: Partial<SiteVariables> = {}) {
|
|
15
|
+
return createGlobals({}, { ...baseSite, ...site }, subPath);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('createGlobals', () => {
|
|
19
|
+
test('isHomePage is true when subPath is "index"', () => {
|
|
20
|
+
expect(makeGlobals('index').isHomePage).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('isHomePage is false for other paths', () => {
|
|
24
|
+
expect(makeGlobals('about').isHomePage).toBe(false);
|
|
25
|
+
expect(makeGlobals('docs/guide').isHomePage).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('cx is an alias for classNames', () => {
|
|
29
|
+
const g = makeGlobals();
|
|
30
|
+
expect(g.cx).toBe(g.classNames);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('renderTimeZoneChooser includes noscript when defaultTimeZone is set', () => {
|
|
34
|
+
const g = makeGlobals('index', { defaultTimeZone: 'America/New_York' });
|
|
35
|
+
expect(g.renderTimeZoneChooser()).toContain('noscript');
|
|
36
|
+
expect(g.renderTimeZoneChooser()).toContain('select');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('isoDate', () => {
|
|
41
|
+
test('converts date string to ISO date', () => {
|
|
42
|
+
const g = makeGlobals();
|
|
43
|
+
expect(g.isoDate('2025-06-15')).toBe('2025-06-15');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('converts full datetime to date only', () => {
|
|
47
|
+
const g = makeGlobals();
|
|
48
|
+
expect(g.isoDate('2025-06-15T12:30:00Z')).toBe('2025-06-15');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('returns null for null/undefined/empty', () => {
|
|
52
|
+
const g = makeGlobals();
|
|
53
|
+
expect(g.isoDate(null)).toBe(null);
|
|
54
|
+
expect(g.isoDate(undefined)).toBe(null);
|
|
55
|
+
expect(g.isoDate('')).toBe(null);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('readableDate', () => {
|
|
60
|
+
test('formats date as Month Day, Year', () => {
|
|
61
|
+
const g = makeGlobals();
|
|
62
|
+
expect(g.readableDate('2025-01-15T00:00:00Z')).toBe('January 15, 2025');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('strips leading zeros from day and month', () => {
|
|
66
|
+
const g = makeGlobals();
|
|
67
|
+
expect(g.readableDate('2025-03-05T00:00:00Z')).toBe('March 5, 2025');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('handles Date objects', () => {
|
|
71
|
+
const g = makeGlobals();
|
|
72
|
+
const result = g.readableDate(new Date('2025-12-25T00:00:00Z'));
|
|
73
|
+
expect(result).toBe('December 25, 2025');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('returns empty string for null/undefined/empty', () => {
|
|
77
|
+
const g = makeGlobals();
|
|
78
|
+
expect(g.readableDate(null)).toBe('');
|
|
79
|
+
expect(g.readableDate(undefined)).toBe('');
|
|
80
|
+
expect(g.readableDate('')).toBe('');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('classNames', () => {
|
|
85
|
+
test('returns truthy keys joined by space', () => {
|
|
86
|
+
const g = makeGlobals();
|
|
87
|
+
expect(g.classNames({ foo: true, bar: false, baz: 1 })).toBe('foo baz');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('returns empty string when all falsy', () => {
|
|
91
|
+
const g = makeGlobals();
|
|
92
|
+
expect(g.classNames({ a: false, b: 0, c: '' })).toBe('');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('returns empty string for empty object', () => {
|
|
96
|
+
const g = makeGlobals();
|
|
97
|
+
expect(g.classNames({})).toBe('');
|
|
98
|
+
});
|
|
99
|
+
});
|
package/build/globals.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import timezones from '../src/timezone/timezones.json' with { type: 'json' };
|
|
2
|
+
import type { SiteVariables } from './types';
|
|
3
|
+
|
|
4
|
+
interface Globals {
|
|
5
|
+
isHomePage: boolean;
|
|
6
|
+
isoDate: (str: string | null | undefined) => string | null;
|
|
7
|
+
readableDate: (date: string | Date | null | undefined) => string;
|
|
8
|
+
classNames: (obj: Record<string, unknown>) => string;
|
|
9
|
+
cx: (obj: Record<string, unknown>) => string;
|
|
10
|
+
renderTimeZoneChooser: () => string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function createGlobals(
|
|
14
|
+
pageVariables: Record<string, unknown>,
|
|
15
|
+
siteVariables: SiteVariables,
|
|
16
|
+
subPath: string,
|
|
17
|
+
): Globals {
|
|
18
|
+
const defaultTz = timezones.find(
|
|
19
|
+
t => t.value === siteVariables.defaultTimeZone,
|
|
20
|
+
)!;
|
|
21
|
+
const renderTimeZoneChooser = () =>
|
|
22
|
+
`<select class="time-zone" hidden></select><noscript>Times shown in ${defaultTz.abbreviation}.</noscript>`;
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
isHomePage: subPath === 'index',
|
|
26
|
+
isoDate,
|
|
27
|
+
readableDate,
|
|
28
|
+
classNames,
|
|
29
|
+
cx: classNames,
|
|
30
|
+
renderTimeZoneChooser,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isoDate(str: string | null | undefined): string | null {
|
|
35
|
+
if (str == null || str == '') {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const date = new Date(str);
|
|
39
|
+
return date.toISOString().slice(0, 10);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readableDate(date: string | Date | null | undefined): string {
|
|
43
|
+
if (date == null || date == '') {
|
|
44
|
+
return '';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!(date instanceof Date)) {
|
|
48
|
+
date = new Date(date);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const str = date.toISOString();
|
|
52
|
+
const year = str.slice(0, 4);
|
|
53
|
+
let month: string = str.slice(5, 7);
|
|
54
|
+
if (month[0] === '0') {
|
|
55
|
+
month = month[1];
|
|
56
|
+
}
|
|
57
|
+
let day: string = str.slice(8, 10);
|
|
58
|
+
if (day[0] === '0') {
|
|
59
|
+
day = day[1];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const months: Record<string, string> = {
|
|
63
|
+
1: 'January',
|
|
64
|
+
2: 'February',
|
|
65
|
+
3: 'March',
|
|
66
|
+
4: 'April',
|
|
67
|
+
5: 'May',
|
|
68
|
+
6: 'June',
|
|
69
|
+
7: 'July',
|
|
70
|
+
8: 'August',
|
|
71
|
+
9: 'September',
|
|
72
|
+
10: 'October',
|
|
73
|
+
11: 'November',
|
|
74
|
+
12: 'December',
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return `${months[month]} ${day}, ${year}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function classNames(obj: Record<string, unknown>): string {
|
|
81
|
+
const names: string[] = [];
|
|
82
|
+
for (const key in obj) {
|
|
83
|
+
if (obj[key]) {
|
|
84
|
+
names.push(key);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return names.join(' ');
|
|
88
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
import type MarkdownIt from 'markdown-it';
|
|
2
|
+
|
|
3
|
+
export default function specialHeadingsPlugin(md: MarkdownIt): void {
|
|
2
4
|
md.core.ruler.push('special_headings', state => {
|
|
3
5
|
const tokens = state.tokens;
|
|
4
6
|
for (let i = 0; i < tokens.length; i++) {
|
|
@@ -77,4 +79,4 @@ module.exports = function specialHeadingsPlugin(md) {
|
|
|
77
79
|
tokens[i].attrJoin('class', 'has-subtitle');
|
|
78
80
|
}
|
|
79
81
|
});
|
|
80
|
-
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { compile, doValidation } from './json-schema';
|
|
3
|
+
|
|
4
|
+
const schema = {
|
|
5
|
+
type: 'object',
|
|
6
|
+
properties: { name: { type: 'string' }, age: { type: 'number' } },
|
|
7
|
+
required: ['name'],
|
|
8
|
+
additionalProperties: false,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe('compile', () => {
|
|
12
|
+
test('returns a validate function', () => {
|
|
13
|
+
const validator = compile(schema);
|
|
14
|
+
expect(typeof validator).toBe('function');
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('doValidation', () => {
|
|
19
|
+
test('does not throw for valid input', () => {
|
|
20
|
+
const validator = compile(schema);
|
|
21
|
+
expect(() =>
|
|
22
|
+
doValidation(validator, { name: 'Alice' }, 'test.json'),
|
|
23
|
+
).not.toThrow();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('does not throw when optional fields are present', () => {
|
|
27
|
+
const validator = compile(schema);
|
|
28
|
+
expect(() =>
|
|
29
|
+
doValidation(validator, { name: 'Alice', age: 30 }, 'test.json'),
|
|
30
|
+
).not.toThrow();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('throws when required field is missing', () => {
|
|
34
|
+
const validator = compile(schema);
|
|
35
|
+
expect(() => doValidation(validator, {}, 'test.json')).toThrow(
|
|
36
|
+
'test.json failed validation',
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('throws with additionalProperties message', () => {
|
|
41
|
+
const validator = compile(schema);
|
|
42
|
+
expect(() =>
|
|
43
|
+
doValidation(validator, { name: 'Alice', extra: true }, 'test.json'),
|
|
44
|
+
).toThrow('unknown property "extra"');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('throws with field path in error', () => {
|
|
48
|
+
const nestedSchema = {
|
|
49
|
+
type: 'object',
|
|
50
|
+
properties: { name: { type: 'string' } },
|
|
51
|
+
};
|
|
52
|
+
const validator = compile(nestedSchema);
|
|
53
|
+
expect(() => doValidation(validator, { name: 123 }, 'data.json')).toThrow(
|
|
54
|
+
'data.json failed validation',
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import JsonSchemaCompiler from 'ajv';
|
|
2
|
+
import type { ValidateFunction, ErrorObject } from 'ajv';
|
|
3
|
+
|
|
4
|
+
const compiler = new JsonSchemaCompiler();
|
|
5
|
+
|
|
6
|
+
export function compile(schema: object): ValidateFunction {
|
|
7
|
+
return compiler.compile(schema);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function formatValidationError(error: ErrorObject): string {
|
|
11
|
+
const path = error.instancePath || '/';
|
|
12
|
+
const parts: string[] = [path];
|
|
13
|
+
if (error.keyword === 'additionalProperties') {
|
|
14
|
+
parts.push(
|
|
15
|
+
`unknown property "${(error.params as { additionalProperty: string }).additionalProperty}"`,
|
|
16
|
+
);
|
|
17
|
+
} else {
|
|
18
|
+
parts.push(error.message || 'unknown error');
|
|
19
|
+
}
|
|
20
|
+
return parts.join(': ');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function doValidation(
|
|
24
|
+
validator: ValidateFunction,
|
|
25
|
+
input: unknown,
|
|
26
|
+
fileName: string,
|
|
27
|
+
): void {
|
|
28
|
+
const valid = validator(input);
|
|
29
|
+
if (!valid) {
|
|
30
|
+
const details = validator.errors!.map(formatValidationError).join('\n');
|
|
31
|
+
throw new Error(`${fileName} failed validation:\n${details}`);
|
|
32
|
+
}
|
|
33
|
+
}
|