@abreen/tada 1.0.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/LICENSE +21 -0
- package/README.md +290 -0
- package/bin/tada.js +361 -0
- package/config/authors.json +1 -0
- package/config/nav.json +28 -0
- package/content/index.md +19 -0
- package/content/lectures/01/Pair.java.md +296 -0
- package/content/lectures/01/Rectangle.java +80 -0
- package/content/lectures/01/demo.py +9 -0
- package/content/lectures/01/index.md +39 -0
- package/content/lectures/01/lecture1.pdf +0 -0
- package/content/lectures/index.md +25 -0
- package/content/markdown.md +379 -0
- package/content/problem_sets/index.md +6 -0
- package/fonts/google-sans-code/GoogleSansCodeVariable-Italic.ttf +0 -0
- package/fonts/google-sans-code/GoogleSansCodeVariable.ttf +0 -0
- package/fonts/google-sans-code/LICENSE.txt +93 -0
- package/fonts/inter/InterVariable-Italic.ttf +0 -0
- package/fonts/inter/InterVariable.ttf +0 -0
- package/fonts/inter/LICENSE.txt +92 -0
- package/package.json +70 -0
- package/public/avatars/alex.jpg +0 -0
- package/public/test.txt +1 -0
- package/src/_mixins.scss +4 -0
- package/src/anchor/README.md +6 -0
- package/src/anchor/index.ts +34 -0
- package/src/anchor/style.scss +48 -0
- package/src/code/README.md +5 -0
- package/src/code/index.ts +113 -0
- package/src/code/style.scss +101 -0
- package/src/code.scss +54 -0
- package/src/header/README.md +8 -0
- package/src/header/index.ts +43 -0
- package/src/header/style.scss +228 -0
- package/src/index.ts +73 -0
- package/src/layout.scss +144 -0
- package/src/literate/style.scss +60 -0
- package/src/print/README.md +4 -0
- package/src/print/index.ts +32 -0
- package/src/print/style.scss +82 -0
- package/src/question/README.md +3 -0
- package/src/question/index.ts +25 -0
- package/src/question/style.scss +116 -0
- package/src/search/README.md +6 -0
- package/src/search/index.ts +574 -0
- package/src/search/style.scss +217 -0
- package/src/style.scss +815 -0
- package/src/timezone/index.test.ts +100 -0
- package/src/timezone/index.ts +298 -0
- package/src/timezone/style.scss +16 -0
- package/src/timezone/timezones.json +58 -0
- package/src/toc/README.md +3 -0
- package/src/toc/index.ts +322 -0
- package/src/toc/style.scss +203 -0
- package/src/top/README.md +4 -0
- package/src/top/index.ts +75 -0
- package/src/util.ts +122 -0
- package/templates/_author.html +27 -0
- package/templates/_bottom.html +3 -0
- package/templates/_download.html +1 -0
- package/templates/_heading.html +19 -0
- package/templates/_nav.html +18 -0
- package/templates/_theme.scss +97 -0
- package/templates/_top.html +87 -0
- package/templates/authors.schema.json +13 -0
- package/templates/code.html +31 -0
- package/templates/default.html +13 -0
- package/templates/literate.html +16 -0
- package/templates/nav.schema.json +27 -0
- package/tsconfig.json +15 -0
- package/types/dev.ts +3 -0
- package/types/sass.d.ts +1 -0
- package/types/site-variables.d.ts +16 -0
- package/webpack/apply-base-path-plugin.js +78 -0
- package/webpack/build-state.js +97 -0
- package/webpack/code.test.js +162 -0
- package/webpack/colors.js +15 -0
- package/webpack/config.base.js +147 -0
- package/webpack/config.dev.js +23 -0
- package/webpack/config.prod.js +32 -0
- package/webpack/content-watch-plugin.js +153 -0
- package/webpack/deflist-id-plugin.js +62 -0
- package/webpack/external-links-plugin.js +37 -0
- package/webpack/features.js +5 -0
- package/webpack/flair.json +1 -0
- package/webpack/generate-content-assets-plugin.js +308 -0
- package/webpack/generate-favicon-plugin.js +198 -0
- package/webpack/generate-fonts-plugin.js +69 -0
- package/webpack/generate-manifest-plugin.js +116 -0
- package/webpack/globals.js +74 -0
- package/webpack/heading-subtitle-plugin.js +80 -0
- package/webpack/json-schema.js +19 -0
- package/webpack/log.js +143 -0
- package/webpack/markdown-plugins.test.js +203 -0
- package/webpack/pagefind-plugin.js +379 -0
- package/webpack/pagefind-plugin.test.js +131 -0
- package/webpack/pdf-text.js +163 -0
- package/webpack/print-flair-plugin.js +22 -0
- package/webpack/reachability.js +273 -0
- package/webpack/reachability.test.js +80 -0
- package/webpack/serve.js +104 -0
- package/webpack/site-variables.js +53 -0
- package/webpack/site.schema.json +67 -0
- package/webpack/templates.js +128 -0
- package/webpack/text-to-id.js +8 -0
- package/webpack/toc-plugin.js +167 -0
- package/webpack/util.js +49 -0
- package/webpack/utils/code.js +439 -0
- package/webpack/utils/content-files.js +147 -0
- package/webpack/utils/define-plugin.js +20 -0
- package/webpack/utils/file-types.js +26 -0
- package/webpack/utils/front-matter.js +57 -0
- package/webpack/utils/jdi-runner/LiterateRunner.class +0 -0
- package/webpack/utils/jdi-runner/LiterateRunner.java +241 -0
- package/webpack/utils/literate-java.js +153 -0
- package/webpack/utils/markdown.js +244 -0
- package/webpack/utils/parse-hsl.js +8 -0
- package/webpack/utils/paths.js +58 -0
- package/webpack/utils/render.js +466 -0
- package/webpack/utils/shiki-highlighter.js +26 -0
- package/webpack/validate-internal-links-plugin.js +155 -0
- package/webpack/watch-reachability-state.js +273 -0
- package/webpack/watch-reachability-state.test.js +198 -0
- package/webpack/watch-reload-client.js +54 -0
- package/webpack/watch.js +166 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const { JSDOM } = require('jsdom');
|
|
3
|
+
|
|
4
|
+
function stripQueryAndHash(href) {
|
|
5
|
+
return href.split('#')[0].split('?')[0];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function isExternalOrAnchor(href) {
|
|
9
|
+
if (!href || href.startsWith('#') || href.startsWith('//')) {
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(href);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeUrlPath(pathname) {
|
|
17
|
+
const normalized = path.posix.normalize(pathname);
|
|
18
|
+
if (normalized === '.' || normalized === '') {
|
|
19
|
+
return '/';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return normalized.startsWith('/') ? normalized : `/${normalized}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function stripBasePath(pathname, basePath) {
|
|
26
|
+
const normalizedPath = normalizeUrlPath(pathname);
|
|
27
|
+
const normalizedBasePath = normalizeUrlPath(basePath || '/');
|
|
28
|
+
|
|
29
|
+
if (normalizedBasePath === '/') {
|
|
30
|
+
return normalizedPath;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (normalizedPath === normalizedBasePath) {
|
|
34
|
+
return '/';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const prefix = `${normalizedBasePath}/`;
|
|
38
|
+
if (normalizedPath.startsWith(prefix)) {
|
|
39
|
+
return normalizeUrlPath(normalizedPath.slice(normalizedBasePath.length));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return normalizedPath;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function toCandidateAssetPaths(urlPath) {
|
|
46
|
+
const normalizedPath = normalizeUrlPath(urlPath);
|
|
47
|
+
|
|
48
|
+
if (normalizedPath === '/') {
|
|
49
|
+
return ['index.html'];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (normalizedPath.endsWith('.html')) {
|
|
53
|
+
return [normalizedPath.slice(1)];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (path.posix.extname(normalizedPath)) {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const withoutLeadingSlash = normalizedPath.slice(1);
|
|
61
|
+
if (normalizedPath.endsWith('/')) {
|
|
62
|
+
return [`${withoutLeadingSlash}index.html`];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return [`${withoutLeadingSlash}/index.html`, `${withoutLeadingSlash}.html`];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function resolveInternalPath({ href, fromAssetPath, basePath = '/' }) {
|
|
69
|
+
if (!href) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const trimmedHref = href.trim();
|
|
74
|
+
if (!trimmedHref || isExternalOrAnchor(trimmedHref)) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const pathname = stripQueryAndHash(trimmedHref);
|
|
79
|
+
if (!pathname) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let resolvedPath = pathname.startsWith('/')
|
|
84
|
+
? stripBasePath(pathname, basePath)
|
|
85
|
+
: normalizeUrlPath(
|
|
86
|
+
path.posix.join(path.posix.dirname(`/${fromAssetPath}`), pathname),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
return normalizeUrlPath(decodeURIComponent(resolvedPath));
|
|
91
|
+
} catch {
|
|
92
|
+
return normalizeUrlPath(resolvedPath);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function resolveHrefToHtmlAssetPath({
|
|
97
|
+
href,
|
|
98
|
+
fromAssetPath,
|
|
99
|
+
basePath = '/',
|
|
100
|
+
knownAssets,
|
|
101
|
+
}) {
|
|
102
|
+
const decodedPath = resolveInternalPath({ href, fromAssetPath, basePath });
|
|
103
|
+
if (!decodedPath) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const candidate of toCandidateAssetPaths(decodedPath)) {
|
|
108
|
+
if (knownAssets.has(candidate)) {
|
|
109
|
+
return candidate;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function resolveHrefToPdfPath({
|
|
117
|
+
href,
|
|
118
|
+
fromAssetPath,
|
|
119
|
+
basePath = '/',
|
|
120
|
+
knownPdfPaths,
|
|
121
|
+
}) {
|
|
122
|
+
const decodedPath = resolveInternalPath({ href, fromAssetPath, basePath });
|
|
123
|
+
if (!decodedPath || !decodedPath.toLowerCase().endsWith('.pdf')) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return knownPdfPaths.has(decodedPath) ? decodedPath : null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function collectDirectSiteAssetLinks({
|
|
131
|
+
html,
|
|
132
|
+
fromAssetPath,
|
|
133
|
+
knownAssets,
|
|
134
|
+
knownPdfPaths = new Set(),
|
|
135
|
+
basePath = '/',
|
|
136
|
+
}) {
|
|
137
|
+
const htmlAssetPaths = new Set();
|
|
138
|
+
const pdfPaths = new Set();
|
|
139
|
+
const dom = new JSDOM(html);
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const links = dom.window.document.querySelectorAll('a');
|
|
143
|
+
for (const link of links) {
|
|
144
|
+
if (link.classList.contains('disabled')) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const href = link.getAttribute('href');
|
|
149
|
+
const pdfPath = resolveHrefToPdfPath({
|
|
150
|
+
href,
|
|
151
|
+
fromAssetPath,
|
|
152
|
+
basePath,
|
|
153
|
+
knownPdfPaths,
|
|
154
|
+
});
|
|
155
|
+
if (pdfPath) {
|
|
156
|
+
pdfPaths.add(pdfPath);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const targetPath = resolveHrefToHtmlAssetPath({
|
|
160
|
+
href,
|
|
161
|
+
fromAssetPath,
|
|
162
|
+
basePath,
|
|
163
|
+
knownAssets,
|
|
164
|
+
});
|
|
165
|
+
if (targetPath) {
|
|
166
|
+
htmlAssetPaths.add(targetPath);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const refreshTarget = getMetaRefreshTarget(dom.window.document);
|
|
171
|
+
if (refreshTarget) {
|
|
172
|
+
const targetPath = resolveHrefToHtmlAssetPath({
|
|
173
|
+
href: refreshTarget,
|
|
174
|
+
fromAssetPath,
|
|
175
|
+
basePath,
|
|
176
|
+
knownAssets,
|
|
177
|
+
});
|
|
178
|
+
if (targetPath) {
|
|
179
|
+
htmlAssetPaths.add(targetPath);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
} finally {
|
|
183
|
+
dom.window.close();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
htmlAssetPaths: [...htmlAssetPaths].sort(),
|
|
188
|
+
pdfPaths: [...pdfPaths].sort(),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function collectReachableSiteAssets({
|
|
193
|
+
htmlAssetsByPath,
|
|
194
|
+
knownPdfPaths = new Set(),
|
|
195
|
+
rootPath = 'index.html',
|
|
196
|
+
basePath = '/',
|
|
197
|
+
}) {
|
|
198
|
+
if (!htmlAssetsByPath.has(rootPath)) {
|
|
199
|
+
throw new Error(`Pagefind reachability root not found: ${rootPath}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const knownAssets = new Set(htmlAssetsByPath.keys());
|
|
203
|
+
const reachable = new Set();
|
|
204
|
+
const reachablePdfPaths = new Set();
|
|
205
|
+
const pending = [rootPath];
|
|
206
|
+
|
|
207
|
+
while (pending.length > 0) {
|
|
208
|
+
const currentPath = pending.pop();
|
|
209
|
+
if (reachable.has(currentPath)) {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
reachable.add(currentPath);
|
|
213
|
+
|
|
214
|
+
const html = htmlAssetsByPath.get(currentPath);
|
|
215
|
+
const { htmlAssetPaths, pdfPaths } = collectDirectSiteAssetLinks({
|
|
216
|
+
html,
|
|
217
|
+
fromAssetPath: currentPath,
|
|
218
|
+
knownAssets,
|
|
219
|
+
knownPdfPaths,
|
|
220
|
+
basePath,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
for (const pdfPath of pdfPaths) {
|
|
224
|
+
reachablePdfPaths.add(pdfPath);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
for (const targetPath of htmlAssetPaths) {
|
|
228
|
+
if (!reachable.has(targetPath)) {
|
|
229
|
+
pending.push(targetPath);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
reachableHtmlPaths: [...reachable].sort(),
|
|
236
|
+
reachablePdfPaths: [...reachablePdfPaths].sort(),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function collectReachableHtmlAssets(options) {
|
|
241
|
+
return collectReachableSiteAssets(options).reachableHtmlPaths;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function getMetaRefreshTarget(document) {
|
|
245
|
+
const refreshTags = document.querySelectorAll('meta[http-equiv]');
|
|
246
|
+
for (const tag of refreshTags) {
|
|
247
|
+
const httpEquiv = tag.getAttribute('http-equiv');
|
|
248
|
+
if (!httpEquiv || httpEquiv.toLowerCase() !== 'refresh') {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const content = tag.getAttribute('content') || '';
|
|
253
|
+
const match = content.match(/(?:^|;)\s*url\s*=\s*(.+)\s*$/i);
|
|
254
|
+
if (!match) {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const target = match[1].trim().replace(/^['"]|['"]$/g, '');
|
|
259
|
+
if (target) {
|
|
260
|
+
return target;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = {
|
|
268
|
+
collectDirectSiteAssetLinks,
|
|
269
|
+
collectReachableHtmlAssets,
|
|
270
|
+
collectReachableSiteAssets,
|
|
271
|
+
resolveHrefToHtmlAssetPath,
|
|
272
|
+
resolveHrefToPdfPath,
|
|
273
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const { describe, expect, test } = require('bun:test');
|
|
2
|
+
const {
|
|
3
|
+
collectDirectSiteAssetLinks,
|
|
4
|
+
collectReachableSiteAssets,
|
|
5
|
+
} = require('./reachability');
|
|
6
|
+
|
|
7
|
+
describe('reachability', () => {
|
|
8
|
+
test('collectDirectSiteAssetLinks resolves relative links and ignores excluded links', () => {
|
|
9
|
+
const result = collectDirectSiteAssetLinks({
|
|
10
|
+
html: `
|
|
11
|
+
<a href="../guide.html?x=1#top">Guide</a>
|
|
12
|
+
<a class="disabled" href="/ignore/">Ignore</a>
|
|
13
|
+
<a href="https://example.com/">External</a>
|
|
14
|
+
<a href="#section">Anchor</a>
|
|
15
|
+
<a href="mailto:test@example.com">Mail</a>
|
|
16
|
+
<a href="/docs/guide.pdf#page=2">PDF</a>
|
|
17
|
+
`,
|
|
18
|
+
fromAssetPath: 'section/index.html',
|
|
19
|
+
knownAssets: new Set(['guide.html', 'ignore/index.html']),
|
|
20
|
+
knownPdfPaths: new Set(['/docs/guide.pdf']),
|
|
21
|
+
basePath: '/',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
expect(result).toEqual({
|
|
25
|
+
htmlAssetPaths: ['guide.html'],
|
|
26
|
+
pdfPaths: ['/docs/guide.pdf'],
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('collectDirectSiteAssetLinks honors basePath and meta refresh', () => {
|
|
31
|
+
const result = collectDirectSiteAssetLinks({
|
|
32
|
+
html: `
|
|
33
|
+
<a href="/course/faq/">FAQ</a>
|
|
34
|
+
<meta http-equiv="refresh" content="0; url='/course/refresh/'" />
|
|
35
|
+
`,
|
|
36
|
+
fromAssetPath: 'index.html',
|
|
37
|
+
knownAssets: new Set(['faq/index.html', 'refresh/index.html']),
|
|
38
|
+
basePath: '/course',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(result).toEqual({
|
|
42
|
+
htmlAssetPaths: ['faq/index.html', 'refresh/index.html'],
|
|
43
|
+
pdfPaths: [],
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('collectReachableSiteAssets reuses direct link parsing for html and pdf reachability', () => {
|
|
48
|
+
const htmlAssetsByPath = new Map([
|
|
49
|
+
[
|
|
50
|
+
'index.html',
|
|
51
|
+
`
|
|
52
|
+
<a href="/about/">About</a>
|
|
53
|
+
<a href="/docs/guide.pdf">Guide PDF</a>
|
|
54
|
+
`,
|
|
55
|
+
],
|
|
56
|
+
[
|
|
57
|
+
'about/index.html',
|
|
58
|
+
`<meta http-equiv="refresh" content="0; url='/redirect/'" />`,
|
|
59
|
+
],
|
|
60
|
+
['redirect/index.html', '<p>Done</p>'],
|
|
61
|
+
['orphan/index.html', '<p>Orphan</p>'],
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
const result = collectReachableSiteAssets({
|
|
65
|
+
htmlAssetsByPath,
|
|
66
|
+
knownPdfPaths: new Set(['/docs/guide.pdf']),
|
|
67
|
+
rootPath: 'index.html',
|
|
68
|
+
basePath: '/',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(result).toEqual({
|
|
72
|
+
reachableHtmlPaths: [
|
|
73
|
+
'about/index.html',
|
|
74
|
+
'index.html',
|
|
75
|
+
'redirect/index.html',
|
|
76
|
+
],
|
|
77
|
+
reachablePdfPaths: ['/docs/guide.pdf'],
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
package/webpack/serve.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { getDistDir } = require('./util');
|
|
4
|
+
const { makeLogger } = require('./log');
|
|
5
|
+
const { B } = require('./colors');
|
|
6
|
+
|
|
7
|
+
const log = makeLogger(__filename);
|
|
8
|
+
|
|
9
|
+
function messageReady(port) {
|
|
10
|
+
if (process.send) {
|
|
11
|
+
process.send({ ready: true, port });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let distDir;
|
|
16
|
+
try {
|
|
17
|
+
distDir = getDistDir();
|
|
18
|
+
} catch (err) {
|
|
19
|
+
log.error`Failed to start server: ${err}`;
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolvePathname(pathname) {
|
|
24
|
+
let decodedPath;
|
|
25
|
+
try {
|
|
26
|
+
decodedPath = decodeURIComponent(pathname);
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const resolvedPath = path.resolve(distDir, '.' + decodedPath);
|
|
32
|
+
const relativePath = path.relative(distDir, resolvedPath);
|
|
33
|
+
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let stat;
|
|
38
|
+
try {
|
|
39
|
+
stat = fs.statSync(resolvedPath);
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (stat.isDirectory()) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!stat.isFile()) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return resolvedPath;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function createResponse(req) {
|
|
56
|
+
const url = new URL(req.url);
|
|
57
|
+
const filePath = resolvePathname(url.pathname);
|
|
58
|
+
if (!filePath) {
|
|
59
|
+
return new Response('Not Found', { status: 404 });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return new Response(Bun.file(filePath));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function tryListen(port, fallbackPort) {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
try {
|
|
68
|
+
const server = Bun.serve({
|
|
69
|
+
port,
|
|
70
|
+
fetch: createResponse,
|
|
71
|
+
error(error) {
|
|
72
|
+
log.error`Request failed: ${error}`;
|
|
73
|
+
return new Response('Internal Server Error', { status: 500 });
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
log.info`Dev server: ${B`http://localhost:${server.port}/index.html`}`;
|
|
78
|
+
messageReady(server.port);
|
|
79
|
+
resolve(server);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if (err.code === 'EADDRINUSE' && fallbackPort) {
|
|
82
|
+
log.warn`Port ${port} in use, trying fallback ${fallbackPort}...`;
|
|
83
|
+
return tryListen(fallbackPort, null).then(resolve).catch(reject);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
reject(err);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
tryListen(8080, 8081).catch(err => {
|
|
92
|
+
log.error`Failed to start server: ${err}`;
|
|
93
|
+
process.exit(1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
process.on('uncaughtException', err => {
|
|
97
|
+
log.error`Uncaught exception: ${err}`;
|
|
98
|
+
process.exit(1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
process.on('unhandledRejection', reason => {
|
|
102
|
+
log.error`Unhandled rejection: ${reason}`;
|
|
103
|
+
process.exit(1);
|
|
104
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { compile: compileJsonSchema, doValidation } = require('./json-schema');
|
|
4
|
+
const { getProjectDir } = require('./utils/paths');
|
|
5
|
+
const configDir = path.resolve(getProjectDir(), 'config');
|
|
6
|
+
|
|
7
|
+
const DEFAULT = { basePath: '/', features: { search: true, code: true } };
|
|
8
|
+
|
|
9
|
+
const isValid = compileJsonSchema(require('./site.schema.json'));
|
|
10
|
+
|
|
11
|
+
function getJson(filePath) {
|
|
12
|
+
return JSON.parse(
|
|
13
|
+
fs.readFileSync(path.resolve(configDir, filePath), 'utf-8'),
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getSiteVariables(env) {
|
|
18
|
+
const fileName = `site.${env}.json`;
|
|
19
|
+
const fromFile = getJson(fileName);
|
|
20
|
+
const variables = {
|
|
21
|
+
...DEFAULT,
|
|
22
|
+
...fromFile,
|
|
23
|
+
features: { ...DEFAULT.features, ...(fromFile.features || {}) },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Derive faviconSymbol from symbol if not explicitly set
|
|
27
|
+
if (variables.symbol && !variables.faviconSymbol) {
|
|
28
|
+
variables.faviconSymbol = variables.symbol;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Derive faviconColor from themeColor if not explicitly set
|
|
32
|
+
if (variables.themeColor && !variables.faviconColor) {
|
|
33
|
+
variables.faviconColor = variables.themeColor;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Derive titlePostfix from title if not explicitly set
|
|
37
|
+
if (variables.title && !variables.titlePostfix) {
|
|
38
|
+
variables.titlePostfix = ` - ${variables.title}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
doValidation(isValid, variables, fileName);
|
|
42
|
+
return variables;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getDevSiteVariables() {
|
|
46
|
+
return getSiteVariables('dev');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getProdSiteVariables() {
|
|
50
|
+
return getSiteVariables('prod');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { getDevSiteVariables, getProdSiteVariables };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "object",
|
|
3
|
+
"required": ["defaultTimeZone"],
|
|
4
|
+
"properties": {
|
|
5
|
+
"features": {
|
|
6
|
+
"type": "object",
|
|
7
|
+
"default": { "search": true, "code": true },
|
|
8
|
+
"properties": {
|
|
9
|
+
"search": { "type": "boolean", "default": true },
|
|
10
|
+
"code": { "type": "boolean", "default": true },
|
|
11
|
+
"favicon": { "type": "boolean", "default": false },
|
|
12
|
+
"literateJava": { "type": "boolean", "default": false }
|
|
13
|
+
},
|
|
14
|
+
"required": ["search", "code", "favicon"],
|
|
15
|
+
"additionalProperties": false
|
|
16
|
+
},
|
|
17
|
+
"base": { "type": "string", "pattern": "^https?:\\/\\/[-.:a-zA-Z0-9]+$" },
|
|
18
|
+
"basePath": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"default": "/",
|
|
21
|
+
"pattern": "^\\/[-a-zA-Z0-9]*?$"
|
|
22
|
+
},
|
|
23
|
+
"internalDomains": {
|
|
24
|
+
"type": "array",
|
|
25
|
+
"default": [],
|
|
26
|
+
"items": { "type": "string", "pattern": "^[-.:a-zA-Z0-9]+$" }
|
|
27
|
+
},
|
|
28
|
+
"title": { "type": "string" },
|
|
29
|
+
"symbol": { "type": "string", "pattern": "^[A-Z0-9\\- ]{1,5}$" },
|
|
30
|
+
"titlePostfix": { "type": "string" },
|
|
31
|
+
"courseId": { "type": "string" },
|
|
32
|
+
"themeColor": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"pattern": "^hsl\\(\\d+(deg)? \\d+% \\d+%\\)$"
|
|
35
|
+
},
|
|
36
|
+
"tintHue": {
|
|
37
|
+
"type": "integer",
|
|
38
|
+
"minimum": 0,
|
|
39
|
+
"maximum": 360,
|
|
40
|
+
"default": 20
|
|
41
|
+
},
|
|
42
|
+
"tintAmount": {
|
|
43
|
+
"type": "integer",
|
|
44
|
+
"minimum": 0,
|
|
45
|
+
"maximum": 100,
|
|
46
|
+
"default": 100
|
|
47
|
+
},
|
|
48
|
+
"faviconColor": {
|
|
49
|
+
"type": "string",
|
|
50
|
+
"pattern": "^hsl\\(\\d+(deg)? \\d+% \\d+%\\)$"
|
|
51
|
+
},
|
|
52
|
+
"faviconSymbol": { "type": "string", "pattern": "^[A-Z0-9\\- ]{1,5}$" },
|
|
53
|
+
"faviconFontWeight": {
|
|
54
|
+
"type": "integer",
|
|
55
|
+
"minimum": 1,
|
|
56
|
+
"maximum": 1000,
|
|
57
|
+
"default": 700
|
|
58
|
+
},
|
|
59
|
+
"defaultTimeZone": { "type": "string" },
|
|
60
|
+
"vars": { "type": "object" },
|
|
61
|
+
"codeLanguages": {
|
|
62
|
+
"type": "object",
|
|
63
|
+
"additionalProperties": { "type": "string" }
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"additionalProperties": false
|
|
67
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const _ = require('lodash');
|
|
4
|
+
const { compile: compileJsonSchema, doValidation } = require('./json-schema');
|
|
5
|
+
const { makeLogger } = require('./log');
|
|
6
|
+
|
|
7
|
+
const log = makeLogger(__filename);
|
|
8
|
+
|
|
9
|
+
// Store all templates in memory (don't read template files during build)
|
|
10
|
+
const templates = {};
|
|
11
|
+
|
|
12
|
+
// All parsed data (from .json files)
|
|
13
|
+
const jsonData = {};
|
|
14
|
+
|
|
15
|
+
// Compiled JSON Schema for the .json files
|
|
16
|
+
const validators = {};
|
|
17
|
+
|
|
18
|
+
// Keeps track of template call tree
|
|
19
|
+
const renderStack = [];
|
|
20
|
+
let errorStack = null;
|
|
21
|
+
|
|
22
|
+
// JSON data files that live in the user's config directory
|
|
23
|
+
const JSON_DATA_FILES = ['nav.json', 'authors.json'];
|
|
24
|
+
|
|
25
|
+
function getHtmlTemplatesDir() {
|
|
26
|
+
const { getPackageDir } = require('./utils/paths');
|
|
27
|
+
return path.resolve(getPackageDir(), 'templates');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getJsonDataDir() {
|
|
31
|
+
const { getConfigDir } = require('./utils/paths');
|
|
32
|
+
return getConfigDir();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function json(fileName) {
|
|
36
|
+
return jsonData[fileName];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function render(fileName, params) {
|
|
40
|
+
if (params != null) {
|
|
41
|
+
// Allow the template to call render(), it will use our params
|
|
42
|
+
params.render = otherFileName => render(otherFileName, params);
|
|
43
|
+
|
|
44
|
+
// Allow the template to read the JSON files we previously read into memory
|
|
45
|
+
params.json = json;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
renderStack.push(fileName);
|
|
49
|
+
try {
|
|
50
|
+
return _.template(templates[fileName])(params);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (errorStack == null) {
|
|
53
|
+
errorStack = renderStack.slice();
|
|
54
|
+
|
|
55
|
+
if (renderStack.length > 1) {
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
} else if (renderStack.length === 1) {
|
|
59
|
+
const topItem = errorStack[errorStack.length - 1];
|
|
60
|
+
throw new Error(`Render error in ${topItem}: ${err}`);
|
|
61
|
+
}
|
|
62
|
+
} finally {
|
|
63
|
+
renderStack.pop();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function compileTemplates(siteVariables) {
|
|
68
|
+
log.info`Compiling templates`;
|
|
69
|
+
|
|
70
|
+
Object.keys(templates).forEach(k => delete templates[k]);
|
|
71
|
+
Object.keys(jsonData).forEach(k => delete jsonData[k]);
|
|
72
|
+
|
|
73
|
+
// Load HTML templates from the package
|
|
74
|
+
const htmlDir = getHtmlTemplatesDir();
|
|
75
|
+
fs.readdirSync(htmlDir).forEach(fileName => {
|
|
76
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
77
|
+
if (ext === '.html') {
|
|
78
|
+
log.debug`Reading ${fileName}`;
|
|
79
|
+
templates[fileName] = fs.readFileSync(
|
|
80
|
+
path.join(htmlDir, fileName),
|
|
81
|
+
'utf-8',
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Load JSON data files from the project's config directory
|
|
87
|
+
const jsonDir = getJsonDataDir();
|
|
88
|
+
for (const fileName of JSON_DATA_FILES) {
|
|
89
|
+
const filePath = path.join(jsonDir, fileName);
|
|
90
|
+
if (!fs.existsSync(filePath)) {
|
|
91
|
+
throw new Error(`Missing required data file: ${filePath}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Schema validation (schemas live in the package templates dir)
|
|
95
|
+
const schemaFile = `${path.parse(fileName).name}.schema.json`;
|
|
96
|
+
const schemaPath = path.join(htmlDir, schemaFile);
|
|
97
|
+
if (!fs.existsSync(schemaPath)) {
|
|
98
|
+
throw new Error(`Missing JSON Schema for ${fileName}: ${schemaPath}`);
|
|
99
|
+
}
|
|
100
|
+
compileAndSetValidator(schemaPath, fileName);
|
|
101
|
+
|
|
102
|
+
log.debug`Reading ${fileName}`;
|
|
103
|
+
jsonData[fileName] = JSON.parse(
|
|
104
|
+
_.template(fs.readFileSync(filePath, 'utf-8'))({
|
|
105
|
+
...(siteVariables.vars || {}),
|
|
106
|
+
site: siteVariables,
|
|
107
|
+
base: siteVariables.base,
|
|
108
|
+
basePath: siteVariables.basePath,
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
doValidation(validators[fileName], jsonData[fileName], fileName);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function compileAndSetValidator(schemaPath, fileName) {
|
|
117
|
+
const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf-8'));
|
|
118
|
+
validators[fileName] = compileJsonSchema(schema);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = {
|
|
122
|
+
compileTemplates,
|
|
123
|
+
render,
|
|
124
|
+
getHtmlTemplatesDir,
|
|
125
|
+
getJsonDataDir,
|
|
126
|
+
json,
|
|
127
|
+
JSON_DATA_FILES,
|
|
128
|
+
};
|