@twango/twiki 0.0.0-stage → 0.1.1
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/.github/actions/product-cache/action.yml +32 -0
- package/.github/actions/product-cache/index.mjs +450 -0
- package/LICENSE +21 -0
- package/README.md +146 -2
- package/bun.lock +1701 -0
- package/cli/runtime.test.ts +420 -0
- package/cli/runtime.ts +385 -0
- package/cli/twiki.ts +305 -0
- package/package.json +138 -4
- package/site/_headers +7 -0
- package/site/assets/fonts/OFL.txt +93 -0
- package/site/assets/fonts/OverusedGrotesk-SemiBold.ttf +0 -0
- package/site/assets/social-background.svg +38 -0
- package/site/bun.lock +1701 -0
- package/site/database/index.ts +7 -0
- package/site/database/schema.ts +95 -0
- package/site/drizzle.config.ts +7 -0
- package/site/migrations/0001_auth.sql +14 -0
- package/site/migrations/0002_site_access.sql +5 -0
- package/site/migrations/0003_github_username.sql +1 -0
- package/site/migrations/meta/0002_snapshot.json +432 -0
- package/site/migrations/meta/0003_snapshot.json +439 -0
- package/site/migrations/meta/_journal.json +27 -0
- package/site/package.json +120 -0
- package/site/src/app.d.ts +14 -0
- package/site/src/app.html +11 -0
- package/site/src/hooks.server.ts +12 -0
- package/site/src/lib/analytics-config.json +1 -0
- package/site/src/lib/analytics.ts +40 -0
- package/site/src/lib/auth-config.json +3 -0
- package/site/src/lib/components/SEO.svelte +113 -0
- package/site/src/lib/components/doc/AccessLink.svelte +27 -0
- package/site/src/lib/components/doc/Backlinks.svelte +59 -0
- package/site/src/lib/components/doc/Breadcrumbs.svelte +29 -0
- package/site/src/lib/components/doc/ContentMeta.svelte +61 -0
- package/site/src/lib/components/doc/DocPager.svelte +91 -0
- package/site/src/lib/components/doc/DocShell.svelte +115 -0
- package/site/src/lib/components/doc/LicenseNotice.svelte +26 -0
- package/site/src/lib/components/doc/LockedContent.svelte +29 -0
- package/site/src/lib/components/doc/NavTree.svelte +158 -0
- package/site/src/lib/components/doc/NoteActions.svelte +139 -0
- package/site/src/lib/components/doc/PublicationBadge.svelte +49 -0
- package/site/src/lib/components/doc/ReaderModeToggle.svelte +19 -0
- package/site/src/lib/components/doc/Sidebar.svelte +351 -0
- package/site/src/lib/components/doc/Toc.svelte +339 -0
- package/site/src/lib/components/doc/enhancements.ts +146 -0
- package/site/src/lib/components/doc/heading-tracker.svelte.ts +39 -0
- package/site/src/lib/components/doc/image-preview.ts +114 -0
- package/site/src/lib/components/doc/reader-mode.svelte.ts +40 -0
- package/site/src/lib/components/embeds/mermaid.ts +353 -0
- package/site/src/lib/components/files/FileCode.svelte +81 -0
- package/site/src/lib/components/files/FileExplorer.svelte +74 -0
- package/site/src/lib/components/files/FileHistory.svelte +170 -0
- package/site/src/lib/components/files/FileIcon.svelte +28 -0
- package/site/src/lib/components/files/FileRmd.svelte +209 -0
- package/site/src/lib/components/files/FileTabs.svelte +180 -0
- package/site/src/lib/components/files/FileTree.svelte +67 -0
- package/site/src/lib/components/files/blame-gutter.ts +45 -0
- package/site/src/lib/components/files/code-view.ts +98 -0
- package/site/src/lib/components/files/file-workspace.svelte.ts +100 -0
- package/site/src/lib/components/graph/GraphPanel.svelte +275 -0
- package/site/src/lib/components/graph/GraphView.svelte +100 -0
- package/site/src/lib/components/graph/graph-data.ts +137 -0
- package/site/src/lib/components/graph/graph-model.ts +103 -0
- package/site/src/lib/components/graph/index.ts +3 -0
- package/site/src/lib/components/graph/render-graph.ts +626 -0
- package/site/src/lib/components/popover/index.ts +1 -0
- package/site/src/lib/components/popover/link-popovers.ts +233 -0
- package/site/src/lib/components/popover/popover.css +82 -0
- package/site/src/lib/components/search/SearchButton.svelte +57 -0
- package/site/src/lib/components/search/SearchPalette.svelte +189 -0
- package/site/src/lib/components/search/index.ts +3 -0
- package/site/src/lib/components/search/pagefind-client.svelte.ts +129 -0
- package/site/src/lib/components/search/pagefind-query.ts +22 -0
- package/site/src/lib/components/search/pagefind-url.ts +12 -0
- package/site/src/lib/components/search/search-state.svelte.ts +22 -0
- package/site/src/lib/components/ui/IconButton.svelte +30 -0
- package/site/src/lib/components/ui/ThemeToggle.svelte +22 -0
- package/site/src/lib/components/ui/index.ts +1 -0
- package/site/src/lib/config.ts +9 -0
- package/site/src/lib/content-license.ts +7 -0
- package/site/src/lib/file-history.ts +37 -0
- package/site/src/lib/file-icons.ts +27 -0
- package/site/src/lib/file-tabs.ts +72 -0
- package/site/src/lib/files.ts +73 -0
- package/site/src/lib/github-user.ts +70 -0
- package/site/src/lib/metadata.ts +166 -0
- package/site/src/lib/notebook-nav.ts +63 -0
- package/site/src/lib/publication.ts +1 -0
- package/site/src/lib/rmd.ts +156 -0
- package/site/src/lib/server/access-admin.ts +33 -0
- package/site/src/lib/server/content-model.ts +253 -0
- package/site/src/lib/server/content-provider.ts +266 -0
- package/site/src/lib/server/content.ts +277 -0
- package/site/src/lib/server/runtime-content.ts +34 -0
- package/site/src/lib/site-config.json +9 -0
- package/site/src/lib/social-image.ts +13 -0
- package/site/src/lib/styles/article.css +607 -0
- package/site/src/lib/styles/foundation.css +138 -0
- package/site/src/lib/styles/print.css +115 -0
- package/site/src/lib/styles/shell.css +182 -0
- package/site/src/lib/text-exports.ts +5 -0
- package/site/src/lib/types.ts +140 -0
- package/site/src/lib/utils.ts +6 -0
- package/site/src/routes/+error.svelte +16 -0
- package/site/src/routes/+layout.server.ts +10 -0
- package/site/src/routes/+layout.svelte +72 -0
- package/site/src/routes/+layout.ts +3 -0
- package/site/src/routes/404/+page.svelte +13 -0
- package/site/src/routes/[...slug]/+page.server.ts +24 -0
- package/site/src/routes/[...slug]/+page.svelte +180 -0
- package/site/src/routes/[course]/files/[...file]/+page.svelte +373 -0
- package/site/src/routes/[course]/files/[...file]/+page.ts +38 -0
- package/site/src/routes/admin/access/+page.server.ts +53 -0
- package/site/src/routes/admin/access/+page.svelte +118 -0
- package/site/src/routes/graph.json/+server.ts +7 -0
- package/site/src/routes/index.xml/+server.ts +7 -0
- package/site/src/routes/layout.css +10 -0
- package/site/src/routes/robots.txt/+server.ts +9 -0
- package/site/src/routes/sitemap.xml/+server.ts +7 -0
- package/site/src/routes/tags/+page.server.ts +5 -0
- package/site/src/routes/tags/+page.svelte +38 -0
- package/site/src/routes/tags/[...tag]/+page.server.ts +22 -0
- package/site/src/routes/tags/[...tag]/+page.svelte +52 -0
- package/site/static/favicon.png +0 -0
- package/site/static/fonts/JetBrainsMono-Italic-VF.woff2 +0 -0
- package/site/static/fonts/JetBrainsMono-VF.woff2 +0 -0
- package/site/static/fonts/OverusedGrotesk-VF.woff2 +0 -0
- package/site/static/fonts/OverusedGroteskItalic-VF.woff2 +0 -0
- package/site/static/fonts/Virgil.woff2 +0 -0
- package/site/svelte.config.js +71 -0
- package/site/tooling/LICENSE-quartz +25 -0
- package/site/tooling/adapter.js +55 -0
- package/site/tooling/assets.ts +323 -0
- package/site/tooling/benchmark.ts +191 -0
- package/site/tooling/build.ts +407 -0
- package/site/tooling/cache.ts +133 -0
- package/site/tooling/check-diagrams.ts +164 -0
- package/site/tooling/check-links.ts +86 -0
- package/site/tooling/check-targets.ts +139 -0
- package/site/tooling/compiler.ts +1386 -0
- package/site/tooling/deploy.ts +112 -0
- package/site/tooling/fingerprint.ts +81 -0
- package/site/tooling/lib/app-version.js +92 -0
- package/site/tooling/lib/application-asset-urls.ts +27 -0
- package/site/tooling/lib/application-cache.ts +269 -0
- package/site/tooling/lib/asset-hash-cache.ts +116 -0
- package/site/tooling/lib/autotag.ts +99 -0
- package/site/tooling/lib/build-assembly.ts +148 -0
- package/site/tooling/lib/build-workspace.ts +386 -0
- package/site/tooling/lib/cache-selection.ts +70 -0
- package/site/tooling/lib/cloudflare-prewarm.js +40 -0
- package/site/tooling/lib/comments.ts +32 -0
- package/site/tooling/lib/content-cache.ts +244 -0
- package/site/tooling/lib/content-urls.ts +50 -0
- package/site/tooling/lib/course-cache.ts +298 -0
- package/site/tooling/lib/course-files.ts +281 -0
- package/site/tooling/lib/currency.ts +33 -0
- package/site/tooling/lib/deployment-config.ts +222 -0
- package/site/tooling/lib/deployment-manifest.ts +274 -0
- package/site/tooling/lib/deployment-prepare.ts +325 -0
- package/site/tooling/lib/deployment-upload.ts +293 -0
- package/site/tooling/lib/dev-git-state.ts +130 -0
- package/site/tooling/lib/dev-history.ts +86 -0
- package/site/tooling/lib/dev-social-images.ts +38 -0
- package/site/tooling/lib/dev-state.ts +107 -0
- package/site/tooling/lib/edition-builds.ts +184 -0
- package/site/tooling/lib/edition-paths.js +29 -0
- package/site/tooling/lib/empty-tag-route.js +23 -0
- package/site/tooling/lib/file-history.ts +201 -0
- package/site/tooling/lib/file-icons.ts +93 -0
- package/site/tooling/lib/file-policy.ts +38 -0
- package/site/tooling/lib/file-search.js +13 -0
- package/site/tooling/lib/git-revisions.ts +41 -0
- package/site/tooling/lib/history-pool.ts +74 -0
- package/site/tooling/lib/history-worker.ts +22 -0
- package/site/tooling/lib/knit-rmd.R +38 -0
- package/site/tooling/lib/lastmod.ts +201 -0
- package/site/tooling/lib/markdown-files.ts +51 -0
- package/site/tooling/lib/ofm.ts +634 -0
- package/site/tooling/lib/output.ts +60 -0
- package/site/tooling/lib/product-cache.ts +1006 -0
- package/site/tooling/lib/project-config.ts +288 -0
- package/site/tooling/lib/public-assets.ts +38 -0
- package/site/tooling/lib/public-links.ts +56 -0
- package/site/tooling/lib/publishing.ts +156 -0
- package/site/tooling/lib/r-runtime.ts +257 -0
- package/site/tooling/lib/rebuild-queue.ts +25 -0
- package/site/tooling/lib/rmd-preflight.ts +166 -0
- package/site/tooling/lib/rmd-preview-cache.ts +93 -0
- package/site/tooling/lib/rmd-previews.ts +129 -0
- package/site/tooling/lib/runtime-data-modules.ts +30 -0
- package/site/tooling/lib/runtime-packaging.ts +321 -0
- package/site/tooling/lib/search-cache.js +165 -0
- package/site/tooling/lib/search-inputs.js +129 -0
- package/site/tooling/lib/slug.ts +377 -0
- package/site/tooling/lib/social-images.ts +210 -0
- package/site/tooling/lib/text-exports.ts +51 -0
- package/site/tooling/lib/visit-elements.ts +17 -0
- package/site/tooling/live-test.ts +138 -0
- package/site/tooling/pipeline.ts +103 -0
- package/site/tooling/prepare.ts +3 -0
- package/site/tooling/publish.ts +28 -0
- package/site/tooling/r-cache.ts +45 -0
- package/site/tooling/r-render-profile.json +7 -0
- package/site/tooling/search.js +94 -0
- package/site/tooling/verify-deployment.ts +109 -0
- package/site/tooling/vite-content.ts +240 -0
- package/site/tooling/wrangler-paths.js +25 -0
- package/site/tooling/wrangler.ts +19 -0
- package/site/tsconfig.json +16 -0
- package/site/vite.config.ts +40 -0
- package/site/worker/access.ts +57 -0
- package/site/worker/analytics.ts +100 -0
- package/site/worker/auth-routes.ts +78 -0
- package/site/worker/auth.ts +78 -0
- package/site/worker/content-runtime.ts +226 -0
- package/site/worker/framing.ts +4 -0
- package/site/worker/gate.ts +147 -0
- package/site/worker/index.ts +9 -0
- package/site/worker/login.ts +62 -0
- package/site/worker/publication.ts +44 -0
- package/site/worker/runtime.ts +104 -0
- package/site/worker/tsconfig.json +13 -0
- package/site/wrangler.jsonc +37 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
2
|
+
import { closeSync, mkdirSync, openSync, writeSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export type BuildMode = 'serial' | 'parallel' | 'isolated-serial';
|
|
6
|
+
|
|
7
|
+
export function buildMode(value = process.env.WISCONSIN_BUILD_MODE): BuildMode {
|
|
8
|
+
if (value === undefined || value === 'serial') return 'serial';
|
|
9
|
+
if (value === 'parallel' || value === 'isolated-serial') return value;
|
|
10
|
+
throw new Error('WISCONSIN_BUILD_MODE must be serial, parallel or isolated-serial');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface BuildOptions {
|
|
14
|
+
mode: Exclude<BuildMode, 'serial'>;
|
|
15
|
+
contentRepo: string;
|
|
16
|
+
applicationVersion: string;
|
|
17
|
+
logDirectory: string;
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
log?: (message: string) => void;
|
|
20
|
+
command?: string;
|
|
21
|
+
args?: string[];
|
|
22
|
+
killGraceMs?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Build normal Vite projects in separate process groups; reap both before returning or rejecting. */
|
|
26
|
+
export async function runEditionBuilds(
|
|
27
|
+
sites: { publicSite: string; fullSite: string },
|
|
28
|
+
options: BuildOptions
|
|
29
|
+
) {
|
|
30
|
+
const log = options.log ?? console.log;
|
|
31
|
+
const processes = new Set<ChildProcess>();
|
|
32
|
+
const tasks: Promise<number>[] = [];
|
|
33
|
+
const terminations: Promise<void>[] = [];
|
|
34
|
+
const terminating = new Set<ChildProcess>();
|
|
35
|
+
let failure: Error | undefined;
|
|
36
|
+
mkdirSync(options.logDirectory, { recursive: true, mode: 0o700 });
|
|
37
|
+
|
|
38
|
+
function signalGroup(child: ChildProcess, signal: NodeJS.Signals) {
|
|
39
|
+
if (child.pid === undefined) return;
|
|
40
|
+
try {
|
|
41
|
+
if (process.platform === 'win32') child.kill(signal);
|
|
42
|
+
else process.kill(-child.pid, signal);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function terminate(child: ChildProcess) {
|
|
49
|
+
if (terminating.has(child) || child.pid === undefined) return;
|
|
50
|
+
terminating.add(child);
|
|
51
|
+
signalGroup(child, 'SIGTERM');
|
|
52
|
+
// Retain the deadline even if the direct child exits first: its descendants
|
|
53
|
+
// can ignore SIGTERM or close their inherited pipes before exiting.
|
|
54
|
+
terminations.push(
|
|
55
|
+
new Promise((resolve) => {
|
|
56
|
+
setTimeout(() => {
|
|
57
|
+
try {
|
|
58
|
+
signalGroup(child, 'SIGKILL');
|
|
59
|
+
} finally {
|
|
60
|
+
resolve();
|
|
61
|
+
}
|
|
62
|
+
}, options.killGraceMs ?? 2_000);
|
|
63
|
+
})
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function cancel(error: Error) {
|
|
68
|
+
failure ??= error;
|
|
69
|
+
for (const child of processes) terminate(child);
|
|
70
|
+
}
|
|
71
|
+
const interrupt = () => cancel(new Error('Edition builds interrupted by SIGINT'));
|
|
72
|
+
const terminateSignal = () => cancel(new Error('Edition builds interrupted by SIGTERM'));
|
|
73
|
+
const abort = () =>
|
|
74
|
+
cancel(new Error('Edition builds aborted', { cause: options.signal?.reason }));
|
|
75
|
+
process.on('SIGINT', interrupt);
|
|
76
|
+
process.on('SIGTERM', terminateSignal);
|
|
77
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
78
|
+
if (options.signal?.aborted) abort();
|
|
79
|
+
|
|
80
|
+
function start(publicEdition: boolean) {
|
|
81
|
+
if (failure) throw failure;
|
|
82
|
+
const edition = publicEdition ? 'public' : 'full';
|
|
83
|
+
const started = performance.now();
|
|
84
|
+
const file = openSync(path.join(options.logDirectory, `${edition}.log`), 'wx', 0o600);
|
|
85
|
+
let child: ChildProcess;
|
|
86
|
+
try {
|
|
87
|
+
child = spawn(options.command ?? 'bun', options.args ?? ['x', 'vite', 'build'], {
|
|
88
|
+
cwd: publicEdition ? sites.publicSite : sites.fullSite,
|
|
89
|
+
detached: process.platform !== 'win32',
|
|
90
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
91
|
+
env: {
|
|
92
|
+
...process.env,
|
|
93
|
+
NODE_ENV: process.env.NODE_ENV ?? 'production',
|
|
94
|
+
WISCONSIN_CONTENT_REPO: options.contentRepo,
|
|
95
|
+
WISCONSIN_APPLICATION_VERSION: options.applicationVersion,
|
|
96
|
+
VITE_PUBLIC_EDITION: String(publicEdition)
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
} catch (error) {
|
|
100
|
+
closeSync(file);
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
processes.add(child);
|
|
104
|
+
const task = new Promise<number>((resolve, reject) => {
|
|
105
|
+
let error: Error | undefined;
|
|
106
|
+
for (const stream of [child.stdout!, child.stderr!]) {
|
|
107
|
+
let pending = '';
|
|
108
|
+
stream.setEncoding('utf8');
|
|
109
|
+
stream.on('data', (chunk: string) => {
|
|
110
|
+
try {
|
|
111
|
+
writeSync(file, chunk);
|
|
112
|
+
pending += chunk;
|
|
113
|
+
const lines = pending.split('\n');
|
|
114
|
+
pending = lines.pop()!;
|
|
115
|
+
for (const line of lines) log(`${edition} | ${line}`);
|
|
116
|
+
} catch (cause) {
|
|
117
|
+
error = new Error(`Could not record ${edition} build output`, { cause });
|
|
118
|
+
cancel(error);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
stream.on('end', () => {
|
|
122
|
+
try {
|
|
123
|
+
if (pending) log(`${edition} | ${pending}`);
|
|
124
|
+
} catch (cause) {
|
|
125
|
+
error = new Error(`Could not record ${edition} build output`, { cause });
|
|
126
|
+
cancel(error);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
child.on('error', (cause) => {
|
|
131
|
+
error = new Error(`Could not start ${edition} build`, { cause });
|
|
132
|
+
cancel(error);
|
|
133
|
+
});
|
|
134
|
+
child.on('close', (code, signal) => {
|
|
135
|
+
closeSync(file);
|
|
136
|
+
if (error || code !== 0) {
|
|
137
|
+
error ??= new Error(`${edition} build failed (${signal ?? `exit ${code}`})`);
|
|
138
|
+
cancel(error);
|
|
139
|
+
reject(error);
|
|
140
|
+
} else {
|
|
141
|
+
const seconds = (performance.now() - started) / 1000;
|
|
142
|
+
try {
|
|
143
|
+
log(`build: ${edition} completed in ${seconds.toFixed(2)}s`);
|
|
144
|
+
resolve(seconds);
|
|
145
|
+
} catch (cause) {
|
|
146
|
+
error = new Error(`Could not record ${edition} build timing`, { cause });
|
|
147
|
+
cancel(error);
|
|
148
|
+
reject(error);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
processes.delete(child);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
tasks.push(task);
|
|
155
|
+
return task;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
let publicSeconds: number;
|
|
160
|
+
let fullSeconds: number;
|
|
161
|
+
if (options.mode === 'parallel') {
|
|
162
|
+
const results = await Promise.allSettled([start(true), start(false)]);
|
|
163
|
+
if (failure) throw failure;
|
|
164
|
+
if (results[0].status === 'rejected') throw results[0].reason;
|
|
165
|
+
if (results[1].status === 'rejected') throw results[1].reason;
|
|
166
|
+
publicSeconds = results[0].value;
|
|
167
|
+
fullSeconds = results[1].value;
|
|
168
|
+
} else {
|
|
169
|
+
publicSeconds = await start(true);
|
|
170
|
+
fullSeconds = await start(false);
|
|
171
|
+
}
|
|
172
|
+
if (failure) throw failure;
|
|
173
|
+
return { publicSeconds, fullSeconds };
|
|
174
|
+
} catch (error) {
|
|
175
|
+
cancel(error instanceof Error ? error : new Error(String(error)));
|
|
176
|
+
throw error;
|
|
177
|
+
} finally {
|
|
178
|
+
await Promise.allSettled(tasks);
|
|
179
|
+
await Promise.all(terminations);
|
|
180
|
+
process.removeListener('SIGINT', interrupt);
|
|
181
|
+
process.removeListener('SIGTERM', terminateSignal);
|
|
182
|
+
options.signal?.removeEventListener('abort', abort);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
/** Keep one edition from pruning the other edition's compiled source assets. @param {string} site */
|
|
4
|
+
export function compiledAssetsDirectory(site) {
|
|
5
|
+
return path.join(
|
|
6
|
+
site,
|
|
7
|
+
process.env.VITE_PUBLIC_EDITION === 'true'
|
|
8
|
+
? 'build/generated/public-assets'
|
|
9
|
+
: 'build/generated/assets'
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** @param {string} site */
|
|
14
|
+
export function staticDirectory(site) {
|
|
15
|
+
return path.join(
|
|
16
|
+
site,
|
|
17
|
+
process.env.VITE_PUBLIC_EDITION === 'true' ? 'build/generated/public-static' : 'static'
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @param {string} site */
|
|
22
|
+
export function courseFilesDirectory(site) {
|
|
23
|
+
return path.join(
|
|
24
|
+
site,
|
|
25
|
+
process.env.VITE_PUBLIC_EDITION === 'true'
|
|
26
|
+
? 'build/generated/public-files'
|
|
27
|
+
: 'build/generated/assets/_files'
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/** Empty tag indexes have no concrete dynamic pages for SvelteKit to prerender.
|
|
5
|
+
* @param {string} site
|
|
6
|
+
* @param {string[]} routes
|
|
7
|
+
*/
|
|
8
|
+
export function emptyTagRoute(site, routes) {
|
|
9
|
+
if (routes.length === 0 || !routes.every((route) => route === '/tags/[...tag]')) return false;
|
|
10
|
+
try {
|
|
11
|
+
const manifest = JSON.parse(
|
|
12
|
+
readFileSync(path.join(site, 'build/generated/content-manifest.json'), 'utf8')
|
|
13
|
+
);
|
|
14
|
+
return (
|
|
15
|
+
manifest?.tags !== null &&
|
|
16
|
+
typeof manifest?.tags === 'object' &&
|
|
17
|
+
!Array.isArray(manifest.tags) &&
|
|
18
|
+
Object.keys(manifest.tags).length === 0
|
|
19
|
+
);
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { stageFingerprint } from '../fingerprint';
|
|
6
|
+
import { createRevisionLookup } from './git-revisions';
|
|
7
|
+
import type { CourseFile } from '../../src/lib/files';
|
|
8
|
+
import type { FileBlame, FileChange, FileCommit, FileHistory } from '../../src/lib/file-history';
|
|
9
|
+
|
|
10
|
+
const historyVersion = await stageFingerprint('history');
|
|
11
|
+
const maxAssetBytes = 25 * 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
export function parseBlame(output: string): FileBlame {
|
|
14
|
+
const commits: Record<string, FileCommit> = {};
|
|
15
|
+
const ranges: FileBlame['ranges'] = [];
|
|
16
|
+
let commit: FileCommit | undefined;
|
|
17
|
+
for (const line of output.split('\n')) {
|
|
18
|
+
const header = /^([a-f0-9]{40,64}) \d+ (\d+) (\d+)$/.exec(line);
|
|
19
|
+
if (header) {
|
|
20
|
+
const [, id, start, count] = header;
|
|
21
|
+
commit = commits[id] ??= { id, author: '', date: '', subject: '' };
|
|
22
|
+
ranges.push([Number(start), Number(start) + Number(count) - 1, id]);
|
|
23
|
+
} else if (commit) {
|
|
24
|
+
if (line.startsWith('author ')) commit.author = line.slice(7);
|
|
25
|
+
else if (line.startsWith('author-time '))
|
|
26
|
+
commit.date = new Date(Number(line.slice(12)) * 1000).toISOString();
|
|
27
|
+
else if (line.startsWith('summary ')) commit.subject = line.slice(8);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
ranges.sort((a, b) => a[0] - b[0]);
|
|
31
|
+
return { commits, ranges };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createFileHistoryBuilder(
|
|
35
|
+
repo: string,
|
|
36
|
+
output: string,
|
|
37
|
+
cache: string,
|
|
38
|
+
allowed: (file: string) => boolean,
|
|
39
|
+
policyKey = allowed.toString(),
|
|
40
|
+
retain?: (file: string) => void,
|
|
41
|
+
urlPrefix = '/_files/history'
|
|
42
|
+
) {
|
|
43
|
+
if (!existsSync(path.join(repo, '.git'))) return;
|
|
44
|
+
const git = (...args: string[]) =>
|
|
45
|
+
execFileSync('git', ['-C', repo, ...args], { maxBuffer: 128 * 1024 * 1024, encoding: 'utf8' });
|
|
46
|
+
const head = git('rev-parse', 'HEAD').trim();
|
|
47
|
+
if (git('rev-parse', '--is-shallow-repository').trim() === 'true')
|
|
48
|
+
throw new Error(`Full Git history required: ${repo}`);
|
|
49
|
+
mkdirSync(cache, { recursive: true });
|
|
50
|
+
mkdirSync(path.join(output, 'history'), { recursive: true });
|
|
51
|
+
const revisionFor = createRevisionLookup(
|
|
52
|
+
head,
|
|
53
|
+
cache,
|
|
54
|
+
(file) => git('log', '-1', '--first-parent', '--format=%H', head, '--', file).trim() || head,
|
|
55
|
+
historyVersion
|
|
56
|
+
);
|
|
57
|
+
const commits = new Map<string, { commit: FileCommit; parent?: string; changes: string[] }>();
|
|
58
|
+
function details(id: string) {
|
|
59
|
+
let cached = commits.get(id);
|
|
60
|
+
if (cached) return cached;
|
|
61
|
+
const [author, date, subject, parents] = git(
|
|
62
|
+
'show',
|
|
63
|
+
'-s',
|
|
64
|
+
'--format=%an%x00%aI%x00%s%x00%P',
|
|
65
|
+
id
|
|
66
|
+
)
|
|
67
|
+
.trimEnd()
|
|
68
|
+
.split('\0');
|
|
69
|
+
const parent = parents?.split(' ')[0] || undefined;
|
|
70
|
+
const changes = (
|
|
71
|
+
parent
|
|
72
|
+
? git('diff', '--name-status', '-z', '-M', parent, id)
|
|
73
|
+
: git('diff-tree', '--root', '-r', '--name-status', '-z', '--no-commit-id', id)
|
|
74
|
+
).split('\0');
|
|
75
|
+
cached = { commit: { id, author, date, subject }, parent, changes };
|
|
76
|
+
commits.set(id, cached);
|
|
77
|
+
return cached;
|
|
78
|
+
}
|
|
79
|
+
function publish(name: string, data?: unknown) {
|
|
80
|
+
const target = path.join(cache, name);
|
|
81
|
+
if (data !== undefined) {
|
|
82
|
+
const serialized = typeof data === 'string' ? data : JSON.stringify(data);
|
|
83
|
+
if (Buffer.byteLength(serialized) > maxAssetBytes) return;
|
|
84
|
+
writeFileSync(target, serialized);
|
|
85
|
+
}
|
|
86
|
+
const destination = path.join(output, 'history', name);
|
|
87
|
+
retain?.(destination);
|
|
88
|
+
if (!existsSync(destination)) copyFileSync(target, destination);
|
|
89
|
+
return `${urlPrefix}/${name}`;
|
|
90
|
+
}
|
|
91
|
+
return (file: CourseFile, bytes: Uint8Array) => {
|
|
92
|
+
// Unrelated commits do not change this file's history or blame.
|
|
93
|
+
const revision = revisionFor(file.path);
|
|
94
|
+
const key = createHash('sha256')
|
|
95
|
+
.update(
|
|
96
|
+
`${historyVersion}\0${policyKey}\0${urlPrefix}\0${revision}\0${file.kind}\0${file.path}\0`
|
|
97
|
+
)
|
|
98
|
+
.update(bytes)
|
|
99
|
+
.digest('hex');
|
|
100
|
+
const name = `${key}.json`;
|
|
101
|
+
const cached = path.join(cache, name);
|
|
102
|
+
try {
|
|
103
|
+
const history: FileHistory = JSON.parse(readFileSync(cached, 'utf8'));
|
|
104
|
+
const assets = [history.blame, ...history.commits.map((commit) => commit.diff)].filter(
|
|
105
|
+
(url): url is string => Boolean(url)
|
|
106
|
+
);
|
|
107
|
+
if (
|
|
108
|
+
history.revision === revision &&
|
|
109
|
+
history.commits.every(
|
|
110
|
+
(commit) => allowed(commit.path) && (!commit.previousPath || allowed(commit.previousPath))
|
|
111
|
+
) &&
|
|
112
|
+
assets.every((url) => existsSync(path.join(cache, path.basename(url))))
|
|
113
|
+
) {
|
|
114
|
+
for (const url of assets) publish(path.basename(url));
|
|
115
|
+
return publish(name);
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
// Missing, truncated, or incomplete records are rebuilt from Git.
|
|
119
|
+
}
|
|
120
|
+
const history: FileHistory = { revision, commits: [] };
|
|
121
|
+
const workingBlob = execFileSync('git', ['-C', repo, 'hash-object', '--stdin'], {
|
|
122
|
+
input: bytes,
|
|
123
|
+
encoding: 'utf8'
|
|
124
|
+
}).trim();
|
|
125
|
+
const committedBlob = git('ls-tree', '-z', revision, '--', file.path)
|
|
126
|
+
.split('\t')[0]
|
|
127
|
+
?.split(' ')[2];
|
|
128
|
+
const clean = workingBlob === committedBlob;
|
|
129
|
+
if (!clean)
|
|
130
|
+
history.notice =
|
|
131
|
+
'Working copy differs from the committed file. Blame is unavailable; history shows committed changes only.';
|
|
132
|
+
let currentPath = file.path;
|
|
133
|
+
let restricted = false;
|
|
134
|
+
for (const id of git(
|
|
135
|
+
'log',
|
|
136
|
+
'--follow',
|
|
137
|
+
'--first-parent',
|
|
138
|
+
'--format=%H',
|
|
139
|
+
revision,
|
|
140
|
+
'--',
|
|
141
|
+
file.path
|
|
142
|
+
)
|
|
143
|
+
.trim()
|
|
144
|
+
.split('\n')
|
|
145
|
+
.filter(Boolean)) {
|
|
146
|
+
const { commit, parent, changes } = details(id);
|
|
147
|
+
let previousPath = currentPath;
|
|
148
|
+
let added = false;
|
|
149
|
+
for (let i = 0; i < changes.length; ) {
|
|
150
|
+
const status = changes[i++];
|
|
151
|
+
const before = changes[i++];
|
|
152
|
+
const after = /^[RC]/.test(status) ? changes[i++] : before;
|
|
153
|
+
if (after !== currentPath) continue;
|
|
154
|
+
if (status.startsWith('R')) previousPath = before;
|
|
155
|
+
added = status === 'A';
|
|
156
|
+
}
|
|
157
|
+
const change: FileChange = { ...commit, path: currentPath };
|
|
158
|
+
if (previousPath !== currentPath) change.previousPath = previousPath;
|
|
159
|
+
if (!allowed(previousPath)) {
|
|
160
|
+
delete change.previousPath;
|
|
161
|
+
change.unavailable = 'Earlier history belongs to an excluded path.';
|
|
162
|
+
history.commits.push(change);
|
|
163
|
+
restricted = true;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
if (parent && !added) {
|
|
167
|
+
const diff = git(
|
|
168
|
+
'diff',
|
|
169
|
+
'--no-ext-diff',
|
|
170
|
+
'--no-textconv',
|
|
171
|
+
'--no-color',
|
|
172
|
+
'-M',
|
|
173
|
+
parent,
|
|
174
|
+
id,
|
|
175
|
+
'--',
|
|
176
|
+
previousPath,
|
|
177
|
+
currentPath
|
|
178
|
+
);
|
|
179
|
+
if (
|
|
180
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,})/.test(
|
|
181
|
+
diff
|
|
182
|
+
)
|
|
183
|
+
) {
|
|
184
|
+
change.unavailable = 'This change contains a potential credential and was not exported.';
|
|
185
|
+
} else {
|
|
186
|
+
change.diff = publish(`${key}-${id}.diff`, diff);
|
|
187
|
+
if (!change.diff) change.unavailable = 'This diff exceeds the hosting size limit.';
|
|
188
|
+
}
|
|
189
|
+
} else change.unavailable = 'Initial version. No earlier version to compare.';
|
|
190
|
+
history.commits.push(change);
|
|
191
|
+
currentPath = previousPath;
|
|
192
|
+
if (added) break;
|
|
193
|
+
}
|
|
194
|
+
if (restricted) history.notice = 'History stops at an excluded path. Blame is unavailable.';
|
|
195
|
+
if (clean && !restricted && file.kind === 'text') {
|
|
196
|
+
const blame = parseBlame(git('blame', '--incremental', '--root', revision, '--', file.path));
|
|
197
|
+
history.blame = publish(`${key}-blame.json`, blame);
|
|
198
|
+
}
|
|
199
|
+
return publish(name, history);
|
|
200
|
+
};
|
|
201
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { writeChanged, copyChanged, pruneOutputs } from './output';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { courseFilesDirectory } from './edition-paths.js';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { generateManifest } from 'material-icon-theme';
|
|
7
|
+
import type { Manifest } from 'material-icon-theme';
|
|
8
|
+
import type { FileIconTheme, IconVariants, FolderIconVariants } from '../../src/lib/file-icons';
|
|
9
|
+
|
|
10
|
+
const manifest = generateManifest();
|
|
11
|
+
|
|
12
|
+
function association(
|
|
13
|
+
key: string,
|
|
14
|
+
base?: Record<string, string>,
|
|
15
|
+
override?: Record<string, string>
|
|
16
|
+
) {
|
|
17
|
+
if (override && Object.hasOwn(override, key)) return override[key];
|
|
18
|
+
if (base && Object.hasOwn(base, key)) return base[key];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resolveIcon(name: string, folder = false, expanded = false, light = false) {
|
|
22
|
+
const key = name.toLowerCase();
|
|
23
|
+
const variant: Manifest = light ? (manifest.light ?? {}) : {};
|
|
24
|
+
if (folder) {
|
|
25
|
+
return expanded
|
|
26
|
+
? (association(key, manifest.folderNamesExpanded, variant.folderNamesExpanded) ??
|
|
27
|
+
variant.folderExpanded ??
|
|
28
|
+
manifest.folderExpanded!)
|
|
29
|
+
: (association(key, manifest.folderNames, variant.folderNames) ??
|
|
30
|
+
variant.folder ??
|
|
31
|
+
manifest.folder!);
|
|
32
|
+
}
|
|
33
|
+
const named = association(key, manifest.fileNames, variant.fileNames);
|
|
34
|
+
if (named) return named;
|
|
35
|
+
const parts = key.split('.');
|
|
36
|
+
for (let i = 1; i < parts.length; i++) {
|
|
37
|
+
const extension = association(
|
|
38
|
+
parts.slice(i).join('.'),
|
|
39
|
+
manifest.fileExtensions,
|
|
40
|
+
variant.fileExtensions
|
|
41
|
+
);
|
|
42
|
+
if (extension) return extension;
|
|
43
|
+
}
|
|
44
|
+
return variant.file ?? manifest.file!;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildFileIcons(siteDir: string, files: string[]) {
|
|
48
|
+
const output = path.join(courseFilesDirectory(siteDir), 'icons');
|
|
49
|
+
const moduleDir = path.join(siteDir, 'src/lib/generated');
|
|
50
|
+
const packageDir = path.dirname(
|
|
51
|
+
fileURLToPath(import.meta.resolve('material-icon-theme/package.json'))
|
|
52
|
+
);
|
|
53
|
+
const used = new Set<string>();
|
|
54
|
+
function variants(name: string, folder = false, expanded = false): IconVariants {
|
|
55
|
+
const light = resolveIcon(name, folder, expanded, true);
|
|
56
|
+
const dark = resolveIcon(name, folder, expanded);
|
|
57
|
+
used.add(light);
|
|
58
|
+
used.add(dark);
|
|
59
|
+
return { light, dark };
|
|
60
|
+
}
|
|
61
|
+
function folderVariants(name: string): FolderIconVariants {
|
|
62
|
+
return { ...variants(name, true), expanded: variants(name, true, true) };
|
|
63
|
+
}
|
|
64
|
+
const theme: FileIconTheme = {
|
|
65
|
+
file: variants(''),
|
|
66
|
+
folder: folderVariants(''),
|
|
67
|
+
files: Object.create(null),
|
|
68
|
+
folders: Object.create(null)
|
|
69
|
+
};
|
|
70
|
+
for (const file of files) {
|
|
71
|
+
const parts = file.toLowerCase().split('/');
|
|
72
|
+
const name = parts.pop()!;
|
|
73
|
+
theme.files[name] ??= variants(name);
|
|
74
|
+
for (const folder of parts) theme.folders[folder] ??= folderVariants(folder);
|
|
75
|
+
}
|
|
76
|
+
mkdirSync(output, { recursive: true });
|
|
77
|
+
mkdirSync(moduleDir, { recursive: true });
|
|
78
|
+
for (const icon of used) {
|
|
79
|
+
const source = path.basename(manifest.iconDefinitions![icon].iconPath);
|
|
80
|
+
copyChanged(path.join(packageDir, 'icons', source), path.join(output, `${icon}.svg`));
|
|
81
|
+
}
|
|
82
|
+
copyChanged(path.join(packageDir, 'LICENSE'), path.join(output, 'LICENSE.txt'));
|
|
83
|
+
writeChanged(path.join(moduleDir, 'file-icons.json'), JSON.stringify(theme));
|
|
84
|
+
pruneOutputs(
|
|
85
|
+
output,
|
|
86
|
+
new Set(
|
|
87
|
+
[...used]
|
|
88
|
+
.map((icon) => path.join(output, `${icon}.svg`))
|
|
89
|
+
.concat(path.join(output, 'LICENSE.txt'))
|
|
90
|
+
)
|
|
91
|
+
);
|
|
92
|
+
console.log(`icons: ${used.size} local file and folder icons`);
|
|
93
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const excludedDirectories = new Set([
|
|
4
|
+
'node_modules',
|
|
5
|
+
'vendor',
|
|
6
|
+
'build',
|
|
7
|
+
'dist',
|
|
8
|
+
'target',
|
|
9
|
+
'__pycache__'
|
|
10
|
+
]);
|
|
11
|
+
const excludedNames =
|
|
12
|
+
/^(?:credentials?|secrets?|id_rsa|id_ed25519)(?:[._-]|$)|\.(?:pem|key|p12|pfx|keystore)$/i;
|
|
13
|
+
|
|
14
|
+
/** Shared path policy without loading course history or R preview rendering. */
|
|
15
|
+
export function browsablePath(file: string) {
|
|
16
|
+
return (
|
|
17
|
+
!file.includes('\\') &&
|
|
18
|
+
!file.split('/').includes('publish.yaml') &&
|
|
19
|
+
!/[\x00-\x1f\x7f]/.test(file) &&
|
|
20
|
+
file
|
|
21
|
+
.split('/')
|
|
22
|
+
.every(
|
|
23
|
+
(segment) =>
|
|
24
|
+
segment.length > 0 &&
|
|
25
|
+
!segment.startsWith('.') &&
|
|
26
|
+
!excludedDirectories.has(segment.toLowerCase()) &&
|
|
27
|
+
!excludedNames.test(segment)
|
|
28
|
+
)
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function fileHistoryPolicyKey() {
|
|
33
|
+
return createHash('sha256')
|
|
34
|
+
.update(browsablePath.toString())
|
|
35
|
+
.update(JSON.stringify([...excludedDirectories].sort()))
|
|
36
|
+
.update(excludedNames.source)
|
|
37
|
+
.digest('hex');
|
|
38
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** File names remain searchable without generating an HTML page for each path.
|
|
2
|
+
* @param {{ course: string; file: string }} entry
|
|
3
|
+
*/
|
|
4
|
+
export function fileSearchRecord({ course, file }) {
|
|
5
|
+
const route = `/${encodeURIComponent(course)}/files${file ? '/' + file.split('/').map(encodeURIComponent).join('/') : ''}`;
|
|
6
|
+
return {
|
|
7
|
+
url: route,
|
|
8
|
+
content: `${course} ${file}`,
|
|
9
|
+
language: 'en',
|
|
10
|
+
meta: { title: file.split('/').at(-1) || course },
|
|
11
|
+
filters: { course: [course] }
|
|
12
|
+
};
|
|
13
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Keep Git's path-limited history semantics, including merges and renames.
|
|
5
|
+
// A course HEAD identifies immutable answers, even when its working tree is dirty.
|
|
6
|
+
export function createRevisionLookup(
|
|
7
|
+
head: string,
|
|
8
|
+
cache: string,
|
|
9
|
+
lookup: (file: string) => string,
|
|
10
|
+
version = 'v1'
|
|
11
|
+
) {
|
|
12
|
+
const target = path.join(cache, `revisions-${version}-${head}.jsonl`);
|
|
13
|
+
const revisions = new Map<string, string>();
|
|
14
|
+
try {
|
|
15
|
+
for (const line of readFileSync(target, 'utf8').split('\n').filter(Boolean)) {
|
|
16
|
+
const entry: unknown = JSON.parse(line);
|
|
17
|
+
if (
|
|
18
|
+
!Array.isArray(entry) ||
|
|
19
|
+
entry.length !== 2 ||
|
|
20
|
+
typeof entry[0] !== 'string' ||
|
|
21
|
+
typeof entry[1] !== 'string' ||
|
|
22
|
+
!/^[a-f0-9]{40,64}$/.test(entry[1])
|
|
23
|
+
)
|
|
24
|
+
throw new Error('Invalid revision cache');
|
|
25
|
+
revisions.set(entry[0], entry[1]);
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
// A partial write or damaged cache must never supply a history revision.
|
|
29
|
+
revisions.clear();
|
|
30
|
+
mkdirSync(cache, { recursive: true });
|
|
31
|
+
writeFileSync(target, '');
|
|
32
|
+
}
|
|
33
|
+
return (file: string) => {
|
|
34
|
+
const cached = revisions.get(file);
|
|
35
|
+
if (cached) return cached;
|
|
36
|
+
const revision = lookup(file);
|
|
37
|
+
revisions.set(file, revision);
|
|
38
|
+
appendFileSync(target, JSON.stringify([file, revision]) + '\n');
|
|
39
|
+
return revision;
|
|
40
|
+
};
|
|
41
|
+
}
|