@abreen/tada 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -33
- package/bin/tada.ts +356 -0
- package/bin/validators.test.ts +204 -0
- package/bin/validators.ts +83 -0
- package/{webpack/apply-base-path-plugin.js → build/apply-base-path-plugin.ts} +16 -7
- package/build/bundle.ts +117 -0
- package/{webpack/code.test.js → build/code.test.ts} +6 -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 +315 -0
- package/build/generate-favicon.ts +165 -0
- package/build/generate-fonts.ts +31 -0
- package/{webpack/generate-manifest-plugin.js → build/generate-manifest.ts} +29 -36
- package/build/globals.test.ts +101 -0
- package/{webpack/globals.js → build/globals.ts} +28 -13
- 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/{webpack/markdown-plugins.test.js → build/markdown-plugins.test.ts} +94 -9
- package/{webpack/pagefind-plugin.test.js → build/pagefind.test.ts} +74 -13
- package/build/pagefind.ts +339 -0
- package/{webpack/pdf-text.js → build/pdf-text.ts} +47 -27
- package/build/pipeline.ts +93 -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/{webpack/site-variables.js → build/site-variables.ts} +22 -15
- package/{webpack → build}/site.schema.json +3 -10
- package/{webpack/templates.js → build/templates.ts} +35 -33
- 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 +172 -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 +80 -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} +63 -34
- package/{webpack/utils/markdown.js → build/utils/markdown.ts} +94 -49
- package/build/utils/paths.test.ts +91 -0
- package/{webpack/utils/paths.js → build/utils/paths.ts} +14 -22
- package/{webpack/utils/render.js → build/utils/render.ts} +188 -123
- 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 +573 -0
- package/content/index.md +9 -3
- package/content/markdown.md +2 -1
- package/content/problem_sets/index.html +14 -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/package.json +28 -19
- package/src/_alerts.scss +92 -0
- package/src/_base.scss +106 -0
- package/src/{layout.scss → _layout.scss} +0 -2
- 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 +1 -2
- package/src/question/style.scss +1 -1
- package/src/search/index.ts +36 -15
- package/src/search/style.scss +9 -15
- package/src/style.scss +6 -269
- package/src/toc/style.scss +5 -39
- package/src/util.ts +8 -5
- package/templates/_theme.scss +38 -14
- package/tsconfig.json +10 -6
- package/types/file-system-access.d.ts +5 -0
- package/types/markdown-it-plugins.d.ts +11 -0
- package/types/untyped-modules.d.ts +40 -0
- package/bin/tada.js +0 -361
- package/content/problem_sets/index.md +0 -6
- 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/json-schema.js +0 -19
- package/webpack/log.js +0 -143
- package/webpack/pagefind-plugin.js +0 -379
- package/webpack/print-flair-plugin.js +0 -22
- package/webpack/serve.js +0 -104
- 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/types/{dev.ts → dev.d.ts} +0 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { makeLogger } from './log.js';
|
|
4
|
+
import { isFeatureEnabled } from './features.js';
|
|
5
|
+
import { initHighlighter } from './utils/shiki-highlighter.js';
|
|
6
|
+
import {
|
|
7
|
+
getBuildContentFiles,
|
|
8
|
+
getContentDir,
|
|
9
|
+
getValidInternalTargets,
|
|
10
|
+
renderCodePageAsset,
|
|
11
|
+
renderCopiedContentAsset,
|
|
12
|
+
renderLiterateJavaPageAsset,
|
|
13
|
+
renderPlainTextPageAsset,
|
|
14
|
+
} from './util.js';
|
|
15
|
+
import { isLiterateJava } from './utils/file-types.js';
|
|
16
|
+
import { checkJavac } from './utils/literate-java.js';
|
|
17
|
+
import type {
|
|
18
|
+
SiteVariables,
|
|
19
|
+
Asset,
|
|
20
|
+
ContentRenderOptions,
|
|
21
|
+
ContentRenderResult,
|
|
22
|
+
WatchState,
|
|
23
|
+
} from './types.js';
|
|
24
|
+
|
|
25
|
+
const log = makeLogger(__filename);
|
|
26
|
+
|
|
27
|
+
export class ContentRenderer {
|
|
28
|
+
private siteVariables: SiteVariables;
|
|
29
|
+
private loggedCodeDisabled: boolean;
|
|
30
|
+
private sourceFileCache: Map<string, Asset[]>;
|
|
31
|
+
private lastBuildFiles: Set<string>;
|
|
32
|
+
private javacAvailable: boolean | undefined;
|
|
33
|
+
|
|
34
|
+
constructor(siteVariables: SiteVariables) {
|
|
35
|
+
this.siteVariables = siteVariables || {};
|
|
36
|
+
this.loggedCodeDisabled = false;
|
|
37
|
+
this.sourceFileCache = new Map();
|
|
38
|
+
this.lastBuildFiles = new Set();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async initHighlighter(): Promise<void> {
|
|
42
|
+
const langs = [
|
|
43
|
+
...new Set([
|
|
44
|
+
'plaintext',
|
|
45
|
+
'text',
|
|
46
|
+
...Object.values(this.siteVariables.codeLanguages || {}),
|
|
47
|
+
]),
|
|
48
|
+
];
|
|
49
|
+
await initHighlighter(langs);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
getDirtySourceFiles(
|
|
53
|
+
buildContentFiles: string[],
|
|
54
|
+
{ changedContentFiles, templatesChanged }: WatchState = {},
|
|
55
|
+
): Set<string> {
|
|
56
|
+
if (
|
|
57
|
+
this.lastBuildFiles.size === 0 ||
|
|
58
|
+
!changedContentFiles ||
|
|
59
|
+
templatesChanged
|
|
60
|
+
) {
|
|
61
|
+
return new Set(buildContentFiles);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const buildFileSet = new Set(buildContentFiles);
|
|
65
|
+
const dirtySourceFiles = new Set<string>();
|
|
66
|
+
for (const filePath of changedContentFiles) {
|
|
67
|
+
if (buildFileSet.has(filePath)) {
|
|
68
|
+
dirtySourceFiles.add(filePath);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return dirtySourceFiles;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
isCopiedAssetSource(filePath: string): boolean {
|
|
75
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
76
|
+
const codeExtensions = this.siteVariables.codeLanguages || {};
|
|
77
|
+
return Object.prototype.hasOwnProperty.call(codeExtensions, ext.slice(1));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
getDirtyCopiedSourceFiles(
|
|
81
|
+
buildContentFiles: string[],
|
|
82
|
+
{ changedContentFiles }: WatchState = {},
|
|
83
|
+
): string[] {
|
|
84
|
+
if (this.lastBuildFiles.size === 0 || !changedContentFiles) {
|
|
85
|
+
return buildContentFiles.filter(filePath =>
|
|
86
|
+
this.isCopiedAssetSource(filePath),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const buildFileSet = new Set(buildContentFiles);
|
|
91
|
+
const dirtyCopiedSourceFiles: string[] = [];
|
|
92
|
+
for (const filePath of changedContentFiles) {
|
|
93
|
+
if (!buildFileSet.has(filePath) || !this.isCopiedAssetSource(filePath)) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
dirtyCopiedSourceFiles.push(filePath);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return dirtyCopiedSourceFiles;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
renderSourceAssets(
|
|
103
|
+
filePath: string,
|
|
104
|
+
contentDir: string,
|
|
105
|
+
validInternalTargets: Set<string>,
|
|
106
|
+
assetFiles: string[],
|
|
107
|
+
): Asset[] {
|
|
108
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
109
|
+
const assets: Asset[] = [];
|
|
110
|
+
|
|
111
|
+
if (isLiterateJava(filePath)) {
|
|
112
|
+
assets.push(
|
|
113
|
+
...renderLiterateJavaPageAsset({
|
|
114
|
+
filePath,
|
|
115
|
+
contentDir,
|
|
116
|
+
siteVariables: this.siteVariables,
|
|
117
|
+
assetFiles,
|
|
118
|
+
skipExecution: !this.javacAvailable,
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
return assets;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (ext === '.html' || ext === '.md' || ext === '.markdown') {
|
|
125
|
+
assets.push(
|
|
126
|
+
...renderPlainTextPageAsset({
|
|
127
|
+
filePath,
|
|
128
|
+
contentDir,
|
|
129
|
+
siteVariables: this.siteVariables,
|
|
130
|
+
validInternalTargets,
|
|
131
|
+
assetFiles,
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
return assets;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const codeExtensions = this.siteVariables.codeLanguages || {};
|
|
138
|
+
if (ext.slice(1) in codeExtensions) {
|
|
139
|
+
if (isFeatureEnabled(this.siteVariables, 'code')) {
|
|
140
|
+
assets.push(
|
|
141
|
+
...renderCodePageAsset({
|
|
142
|
+
filePath,
|
|
143
|
+
contentDir,
|
|
144
|
+
siteVariables: this.siteVariables,
|
|
145
|
+
assetFiles,
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
} else if (!this.loggedCodeDisabled) {
|
|
149
|
+
log.info`Not generating source code pages due to site.features.code = false`;
|
|
150
|
+
this.loggedCodeDisabled = true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return assets;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
writeUncachedAssets(
|
|
158
|
+
distDir: string,
|
|
159
|
+
copiedSourceFiles: string[],
|
|
160
|
+
contentDir: string,
|
|
161
|
+
): void {
|
|
162
|
+
for (const filePath of copiedSourceFiles) {
|
|
163
|
+
for (const asset of renderCopiedContentAsset({ filePath, contentDir })) {
|
|
164
|
+
const outPath = path.join(distDir, asset.assetPath);
|
|
165
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
166
|
+
fs.writeFileSync(outPath, asset.content);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
updateSourceCache(
|
|
172
|
+
filePath: string,
|
|
173
|
+
assets: Asset[],
|
|
174
|
+
changedHtmlAssetPaths: Set<string>,
|
|
175
|
+
removedHtmlAssetPaths: Set<string>,
|
|
176
|
+
): void {
|
|
177
|
+
const previousAssets = this.sourceFileCache.get(filePath) || [];
|
|
178
|
+
const nextAssetPaths = new Set(assets.map(asset => asset.assetPath));
|
|
179
|
+
|
|
180
|
+
for (const asset of previousAssets) {
|
|
181
|
+
if (nextAssetPaths.has(asset.assetPath)) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (asset.assetPath.endsWith('.html')) {
|
|
185
|
+
removedHtmlAssetPaths.add(asset.assetPath);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const asset of assets) {
|
|
190
|
+
if (asset.assetPath.endsWith('.html')) {
|
|
191
|
+
changedHtmlAssetPaths.add(asset.assetPath);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
this.sourceFileCache.set(filePath, assets);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
writeCachedAssets(distDir: string, buildContentFiles: string[]): void {
|
|
199
|
+
for (const filePath of buildContentFiles) {
|
|
200
|
+
const assets = this.sourceFileCache.get(filePath) || [];
|
|
201
|
+
for (const asset of assets) {
|
|
202
|
+
const outPath = path.join(distDir, asset.assetPath);
|
|
203
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
204
|
+
fs.writeFileSync(outPath, asset.content);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
pruneRemovedSources(
|
|
210
|
+
buildFileSet: Set<string>,
|
|
211
|
+
removedHtmlAssetPaths: Set<string>,
|
|
212
|
+
): void {
|
|
213
|
+
for (const filePath of [...this.lastBuildFiles]) {
|
|
214
|
+
if (buildFileSet.has(filePath)) {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const previousAssets = this.sourceFileCache.get(filePath) || [];
|
|
219
|
+
for (const asset of previousAssets) {
|
|
220
|
+
if (asset.assetPath.endsWith('.html')) {
|
|
221
|
+
removedHtmlAssetPaths.add(asset.assetPath);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
this.sourceFileCache.delete(filePath);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
processContent({
|
|
229
|
+
distDir,
|
|
230
|
+
assetFiles,
|
|
231
|
+
watchState,
|
|
232
|
+
}: ContentRenderOptions): ContentRenderResult {
|
|
233
|
+
const contentDir: string = getContentDir();
|
|
234
|
+
const buildContentFiles: string[] = getBuildContentFiles(
|
|
235
|
+
contentDir,
|
|
236
|
+
Object.keys(this.siteVariables.codeLanguages || {}),
|
|
237
|
+
);
|
|
238
|
+
const buildFileSet = new Set<string>(buildContentFiles);
|
|
239
|
+
const dirtySourceFiles = this.getDirtySourceFiles(
|
|
240
|
+
buildContentFiles,
|
|
241
|
+
watchState,
|
|
242
|
+
);
|
|
243
|
+
const dirtyCopiedSourceFiles = this.getDirtyCopiedSourceFiles(
|
|
244
|
+
buildContentFiles,
|
|
245
|
+
watchState,
|
|
246
|
+
);
|
|
247
|
+
const changedHtmlAssetPaths = new Set<string>();
|
|
248
|
+
const removedHtmlAssetPaths = new Set<string>();
|
|
249
|
+
const validInternalTargets = getValidInternalTargets(
|
|
250
|
+
contentDir,
|
|
251
|
+
buildContentFiles,
|
|
252
|
+
Object.keys(this.siteVariables.codeLanguages || {}),
|
|
253
|
+
);
|
|
254
|
+
const errors: Error[] = [];
|
|
255
|
+
|
|
256
|
+
this.pruneRemovedSources(buildFileSet, removedHtmlAssetPaths);
|
|
257
|
+
|
|
258
|
+
if (dirtySourceFiles.size > 0) {
|
|
259
|
+
const noun = dirtySourceFiles.size === 1 ? 'file' : 'files';
|
|
260
|
+
log.info`Processing ${dirtySourceFiles.size} content ${noun}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const hasLiterateJava = [...dirtySourceFiles].some(isLiterateJava);
|
|
264
|
+
if (hasLiterateJava && this.javacAvailable === undefined) {
|
|
265
|
+
this.javacAvailable = checkJavac();
|
|
266
|
+
if (!this.javacAvailable) {
|
|
267
|
+
log.warn`javac was not found; literate Java pages will not include execution output`;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
for (const filePath of dirtySourceFiles) {
|
|
272
|
+
let assets: Asset[];
|
|
273
|
+
try {
|
|
274
|
+
assets = this.renderSourceAssets(
|
|
275
|
+
filePath,
|
|
276
|
+
contentDir,
|
|
277
|
+
validInternalTargets,
|
|
278
|
+
assetFiles,
|
|
279
|
+
);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
errors.push(err as Error);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
this.updateSourceCache(
|
|
285
|
+
filePath,
|
|
286
|
+
assets,
|
|
287
|
+
changedHtmlAssetPaths,
|
|
288
|
+
removedHtmlAssetPaths,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
this.writeCachedAssets(distDir, buildContentFiles);
|
|
293
|
+
this.writeUncachedAssets(distDir, dirtyCopiedSourceFiles, contentDir);
|
|
294
|
+
this.lastBuildFiles = buildFileSet;
|
|
295
|
+
|
|
296
|
+
// Collect HTML asset content for Pagefind
|
|
297
|
+
const htmlAssetsByPath = new Map<string, string>();
|
|
298
|
+
for (const filePath of buildContentFiles) {
|
|
299
|
+
const assets = this.sourceFileCache.get(filePath) || [];
|
|
300
|
+
for (const asset of assets) {
|
|
301
|
+
if (asset.assetPath.endsWith('.html')) {
|
|
302
|
+
htmlAssetsByPath.set(asset.assetPath, asset.content as string);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
errors,
|
|
309
|
+
changedHtmlAssetPaths,
|
|
310
|
+
removedHtmlAssetPaths,
|
|
311
|
+
htmlAssetsByPath,
|
|
312
|
+
buildContentFiles,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
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.js';
|
|
7
|
+
import { getPackageDir } from './utils/paths.js';
|
|
8
|
+
import { deriveTheme } from './utils/derive-theme.js';
|
|
9
|
+
import type { SiteVariables } from './types.js';
|
|
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 (!color || !symbol) {
|
|
121
|
+
throw new Error('Missing required "color" and/or "symbol" options');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const { themeColorLight, textOnThemeLight } = deriveTheme(color);
|
|
125
|
+
|
|
126
|
+
log.info`Generating favicons`;
|
|
127
|
+
log.debug`Using font file: ${FONT_PATH}`;
|
|
128
|
+
const font = fontkit.openSync(FONT_PATH) as fontkit.Font;
|
|
129
|
+
|
|
130
|
+
const svgMarkup = createFaviconSvg(
|
|
131
|
+
svgSize,
|
|
132
|
+
themeColorLight,
|
|
133
|
+
textOnThemeLight,
|
|
134
|
+
symbol,
|
|
135
|
+
font,
|
|
136
|
+
fontWeight,
|
|
137
|
+
);
|
|
138
|
+
fs.writeFileSync(path.join(distDir, `${filenameBase}.svg`), svgMarkup);
|
|
139
|
+
|
|
140
|
+
const pngBuffers = await Promise.all(
|
|
141
|
+
sizes.map(async size => {
|
|
142
|
+
const svgForSize = createFaviconSvg(
|
|
143
|
+
size,
|
|
144
|
+
themeColorLight,
|
|
145
|
+
textOnThemeLight,
|
|
146
|
+
symbol,
|
|
147
|
+
font,
|
|
148
|
+
fontWeight,
|
|
149
|
+
);
|
|
150
|
+
const buf = await sharp(Buffer.from(svgForSize)).png().toBuffer();
|
|
151
|
+
fs.writeFileSync(path.join(distDir, `${filenameBase}-${size}.png`), buf);
|
|
152
|
+
return { size, buf };
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const icoBuffer = await pngToIco(
|
|
157
|
+
pngBuffers
|
|
158
|
+
.filter(b => b.size <= 256)
|
|
159
|
+
.sort((a, b) => a.size - b.size)
|
|
160
|
+
.map(x => x.buf),
|
|
161
|
+
);
|
|
162
|
+
fs.writeFileSync(path.join(distDir, `${filenameBase}.ico`), icoBuffer);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export { createFaviconSvg };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { getPackageDir } from './utils/paths.js';
|
|
4
|
+
import { makeLogger } from './log.js';
|
|
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
|
+
}
|
|
@@ -1,43 +1,23 @@
|
|
|
1
|
-
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { createApplyBasePath } from './util.js';
|
|
4
|
+
import type { SiteVariables } from './types.js';
|
|
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,101 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import createGlobals from './globals.js';
|
|
3
|
+
import type { SiteVariables } from './types.js';
|
|
4
|
+
|
|
5
|
+
const baseSite: SiteVariables = {
|
|
6
|
+
base: 'http://localhost:8080',
|
|
7
|
+
basePath: '/',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function makeGlobals(subPath = 'index', site: Partial<SiteVariables> = {}) {
|
|
11
|
+
return createGlobals({}, { ...baseSite, ...site }, subPath);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('createGlobals', () => {
|
|
15
|
+
test('isHomePage is true when subPath is "index"', () => {
|
|
16
|
+
expect(makeGlobals('index').isHomePage).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('isHomePage is false for other paths', () => {
|
|
20
|
+
expect(makeGlobals('about').isHomePage).toBe(false);
|
|
21
|
+
expect(makeGlobals('docs/guide').isHomePage).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('cx is an alias for classNames', () => {
|
|
25
|
+
const g = makeGlobals();
|
|
26
|
+
expect(g.cx).toBe(g.classNames);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('timezoneChooser includes noscript when defaultTimeZone is set', () => {
|
|
30
|
+
const g = makeGlobals('index', { defaultTimeZone: 'America/New_York' });
|
|
31
|
+
expect(g.timezoneChooser).toContain('noscript');
|
|
32
|
+
expect(g.timezoneChooser).toContain('select');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('timezoneChooser has no noscript when defaultTimeZone is not set', () => {
|
|
36
|
+
const g = makeGlobals('index');
|
|
37
|
+
expect(g.timezoneChooser).toContain('select');
|
|
38
|
+
expect(g.timezoneChooser).not.toContain('noscript');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('isoDate', () => {
|
|
43
|
+
test('converts date string to ISO date', () => {
|
|
44
|
+
const g = makeGlobals();
|
|
45
|
+
expect(g.isoDate('2025-06-15')).toBe('2025-06-15');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('converts full datetime to date only', () => {
|
|
49
|
+
const g = makeGlobals();
|
|
50
|
+
expect(g.isoDate('2025-06-15T12:30:00Z')).toBe('2025-06-15');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('returns null for null/undefined/empty', () => {
|
|
54
|
+
const g = makeGlobals();
|
|
55
|
+
expect(g.isoDate(null)).toBe(null);
|
|
56
|
+
expect(g.isoDate(undefined)).toBe(null);
|
|
57
|
+
expect(g.isoDate('')).toBe(null);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('readableDate', () => {
|
|
62
|
+
test('formats date as Month Day, Year', () => {
|
|
63
|
+
const g = makeGlobals();
|
|
64
|
+
expect(g.readableDate('2025-01-15T00:00:00Z')).toBe('January 15, 2025');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('strips leading zeros from day and month', () => {
|
|
68
|
+
const g = makeGlobals();
|
|
69
|
+
expect(g.readableDate('2025-03-05T00:00:00Z')).toBe('March 5, 2025');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('handles Date objects', () => {
|
|
73
|
+
const g = makeGlobals();
|
|
74
|
+
const result = g.readableDate(new Date('2025-12-25T00:00:00Z'));
|
|
75
|
+
expect(result).toBe('December 25, 2025');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('returns empty string for null/undefined/empty', () => {
|
|
79
|
+
const g = makeGlobals();
|
|
80
|
+
expect(g.readableDate(null)).toBe('');
|
|
81
|
+
expect(g.readableDate(undefined)).toBe('');
|
|
82
|
+
expect(g.readableDate('')).toBe('');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('classNames', () => {
|
|
87
|
+
test('returns truthy keys joined by space', () => {
|
|
88
|
+
const g = makeGlobals();
|
|
89
|
+
expect(g.classNames({ foo: true, bar: false, baz: 1 })).toBe('foo baz');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('returns empty string when all falsy', () => {
|
|
93
|
+
const g = makeGlobals();
|
|
94
|
+
expect(g.classNames({ a: false, b: 0, c: '' })).toBe('');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('returns empty string for empty object', () => {
|
|
98
|
+
const g = makeGlobals();
|
|
99
|
+
expect(g.classNames({})).toBe('');
|
|
100
|
+
});
|
|
101
|
+
});
|