@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,281 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
realpathSync,
|
|
9
|
+
copyFileSync
|
|
10
|
+
} from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { writeChanged, pruneOutputs } from './output';
|
|
13
|
+
import type { CourseFile } from '../../src/lib/files';
|
|
14
|
+
import {
|
|
15
|
+
resolveProject,
|
|
16
|
+
declaredContentCourses,
|
|
17
|
+
discoverSourceFiles,
|
|
18
|
+
projectIdentity
|
|
19
|
+
} from './project-config';
|
|
20
|
+
import { slugifyFilePath, type FilePath } from './slug';
|
|
21
|
+
import { buildFileIcons } from './file-icons';
|
|
22
|
+
import { buildHistories, type HistoryJob } from './history-pool';
|
|
23
|
+
import { courseFilesDirectory } from './edition-paths.js';
|
|
24
|
+
import { stageFingerprint } from '../fingerprint';
|
|
25
|
+
import { courseFingerprint, readCourseCache, saveCourseCache } from './course-cache';
|
|
26
|
+
import { publicationFilter, courseLicenseResolver } from './publishing';
|
|
27
|
+
import { buildRmdPreviews } from './rmd-previews';
|
|
28
|
+
import { browsablePath } from './file-policy';
|
|
29
|
+
export { browsablePath, fileHistoryPolicyKey } from './file-policy';
|
|
30
|
+
const inlineImages = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif']);
|
|
31
|
+
export const assetLimit = 25 * 1024 * 1024;
|
|
32
|
+
|
|
33
|
+
export function previewText(bytes: Uint8Array): { text: string } | undefined {
|
|
34
|
+
if (Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).includes(0)) return;
|
|
35
|
+
try {
|
|
36
|
+
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
37
|
+
if (/[\x00-\x08\x0e-\x1f]/.test(text)) return;
|
|
38
|
+
return { text };
|
|
39
|
+
} catch {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type CourseOutput = { files: CourseFile[]; outputs: Set<string>; notes: string };
|
|
45
|
+
const previousBuilds = new Map<string, Map<string, CourseOutput>>();
|
|
46
|
+
|
|
47
|
+
export async function buildCourseFiles(
|
|
48
|
+
siteDir: string,
|
|
49
|
+
noteSlugs: Set<string>,
|
|
50
|
+
changedInputs?: Set<string>,
|
|
51
|
+
development = false
|
|
52
|
+
) {
|
|
53
|
+
const project = resolveProject(siteDir);
|
|
54
|
+
const repo = project.root;
|
|
55
|
+
const publicEdition = process.env.VITE_PUBLIC_EDITION === 'true';
|
|
56
|
+
const isPublished = publicationFilter(repo, project);
|
|
57
|
+
const licenseFor = courseLicenseResolver(repo, project);
|
|
58
|
+
const contentRoot = project.contentDir;
|
|
59
|
+
const output = courseFilesDirectory(siteDir);
|
|
60
|
+
const wanted = new Set<string>();
|
|
61
|
+
for (const dir of ['index', 'blobs']) mkdirSync(path.join(output, dir), { recursive: true });
|
|
62
|
+
const courses = new Map(
|
|
63
|
+
declaredContentCourses(project).map((course) => [course.name, [] as CourseFile[]])
|
|
64
|
+
);
|
|
65
|
+
const key = JSON.stringify([siteDir, projectIdentity(project), publicEdition, development]);
|
|
66
|
+
const previous = previousBuilds.get(key);
|
|
67
|
+
const active = new Set(courses.keys());
|
|
68
|
+
const outputByCourse = new Map<string, Set<string>>();
|
|
69
|
+
const noteKeys = new Map(
|
|
70
|
+
[...courses.keys()].map((course) => [
|
|
71
|
+
course,
|
|
72
|
+
JSON.stringify([...noteSlugs].filter((slug) => slug.startsWith(course + '/')).sort())
|
|
73
|
+
])
|
|
74
|
+
);
|
|
75
|
+
const paths = discoverSourceFiles(project);
|
|
76
|
+
const fingerprints = new Map<string, string>();
|
|
77
|
+
const cacheFile = (course: string) =>
|
|
78
|
+
path.join(
|
|
79
|
+
siteDir,
|
|
80
|
+
'build/generated/cache/course-files',
|
|
81
|
+
publicEdition ? 'public' : 'full',
|
|
82
|
+
`${course}.json`
|
|
83
|
+
);
|
|
84
|
+
const cacheRoot = path.join(siteDir, 'build/generated/cache');
|
|
85
|
+
const cacheContext = (course: string) => ({
|
|
86
|
+
repo,
|
|
87
|
+
contentRoot,
|
|
88
|
+
course,
|
|
89
|
+
output,
|
|
90
|
+
cache: cacheRoot
|
|
91
|
+
});
|
|
92
|
+
if (!development) {
|
|
93
|
+
const version = `${await stageFingerprint('files')}\0${publicEdition ? 'public' : 'full'}\0${project.publication}\0${project.discovery}`;
|
|
94
|
+
for (const course of courses.keys()) {
|
|
95
|
+
const tracked = paths.filter((file) => file.startsWith(`${course}/`));
|
|
96
|
+
// Let the R renderer validate its installed runtime and worksheet dependencies.
|
|
97
|
+
if (tracked.some((file) => /\.rmd$/i.test(file))) continue;
|
|
98
|
+
const fingerprint = courseFingerprint(
|
|
99
|
+
repo,
|
|
100
|
+
course,
|
|
101
|
+
tracked,
|
|
102
|
+
version,
|
|
103
|
+
noteKeys.get(course)!,
|
|
104
|
+
contentRoot
|
|
105
|
+
);
|
|
106
|
+
fingerprints.set(course, fingerprint);
|
|
107
|
+
const cached = readCourseCache(cacheFile(course), fingerprint, cacheContext(course));
|
|
108
|
+
if (!cached) continue;
|
|
109
|
+
courses.set(course, cached.files);
|
|
110
|
+
outputByCourse.set(course, cached.outputs);
|
|
111
|
+
for (const file of cached.outputs) wanted.add(file);
|
|
112
|
+
active.delete(course);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (
|
|
116
|
+
development &&
|
|
117
|
+
previous &&
|
|
118
|
+
changedInputs?.size &&
|
|
119
|
+
[...changedInputs].every((file) => file.startsWith(contentRoot + path.sep))
|
|
120
|
+
) {
|
|
121
|
+
for (const course of courses.keys()) {
|
|
122
|
+
const cached = previous.get(course);
|
|
123
|
+
if (
|
|
124
|
+
!cached ||
|
|
125
|
+
cached.notes !== noteKeys.get(course) ||
|
|
126
|
+
[...changedInputs].some(
|
|
127
|
+
(file) =>
|
|
128
|
+
file === path.join(contentRoot, course) ||
|
|
129
|
+
file.startsWith(path.join(contentRoot, course) + path.sep)
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
continue;
|
|
133
|
+
if (![...cached.outputs].every(existsSync)) continue;
|
|
134
|
+
courses.set(course, cached.files);
|
|
135
|
+
outputByCourse.set(course, cached.outputs);
|
|
136
|
+
for (const file of cached.outputs) wanted.add(file);
|
|
137
|
+
active.delete(course);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for (const course of active) outputByCourse.set(course, new Set());
|
|
141
|
+
const retain = (course: string, file: string) => {
|
|
142
|
+
wanted.add(file);
|
|
143
|
+
outputByCourse.get(course)!.add(file);
|
|
144
|
+
};
|
|
145
|
+
let count = 0;
|
|
146
|
+
const historyJobs = new Map<string, HistoryJob>(
|
|
147
|
+
(publicEdition || development ? [] : [...active]).map((course) => [
|
|
148
|
+
course,
|
|
149
|
+
{
|
|
150
|
+
repo: path.join(contentRoot, course),
|
|
151
|
+
output,
|
|
152
|
+
cache: path.join(siteDir, 'build/generated/cache/file-history', course),
|
|
153
|
+
files: []
|
|
154
|
+
}
|
|
155
|
+
])
|
|
156
|
+
);
|
|
157
|
+
const revisions = new Map(
|
|
158
|
+
(development && !publicEdition ? [...active] : []).map((course) => [
|
|
159
|
+
course,
|
|
160
|
+
execFileSync('git', ['-C', path.join(contentRoot, course), 'rev-parse', 'HEAD'], {
|
|
161
|
+
encoding: 'utf8'
|
|
162
|
+
}).trim()
|
|
163
|
+
])
|
|
164
|
+
);
|
|
165
|
+
for (const tracked of paths) {
|
|
166
|
+
const relative = tracked;
|
|
167
|
+
const [course, ...segments] = relative.split('/');
|
|
168
|
+
const files = courses.get(course);
|
|
169
|
+
if (!files || !active.has(course) || !browsablePath(segments.join('/'))) continue;
|
|
170
|
+
const source = path.join(contentRoot, tracked);
|
|
171
|
+
if (!existsSync(source) || !lstatSync(source).isFile()) continue;
|
|
172
|
+
if (realpathSync(source) !== path.join(contentRoot, relative)) continue;
|
|
173
|
+
const size = lstatSync(source).size;
|
|
174
|
+
const file: CourseFile = {
|
|
175
|
+
path: segments.join('/'),
|
|
176
|
+
size,
|
|
177
|
+
kind: 'binary',
|
|
178
|
+
license: licenseFor(relative)
|
|
179
|
+
};
|
|
180
|
+
const extension = path.extname(source).slice(1).toLowerCase();
|
|
181
|
+
const slug = slugifyFilePath(relative as FilePath);
|
|
182
|
+
if (extension === 'md' && noteSlugs.has(slug))
|
|
183
|
+
file.note = '/' + (slug.endsWith('/index') ? slug.slice(0, -6) : slug);
|
|
184
|
+
file.locked =
|
|
185
|
+
publicEdition && (!isPublished(relative) || (extension === 'md' && !noteSlugs.has(slug)));
|
|
186
|
+
files.push(file);
|
|
187
|
+
count++;
|
|
188
|
+
if (file.locked) {
|
|
189
|
+
delete file.license;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (size > assetLimit) continue;
|
|
193
|
+
const bytes = readFileSync(source);
|
|
194
|
+
const hash = createHash('sha256').update(bytes).digest('hex');
|
|
195
|
+
const suffix = extension === 'pdf' ? 'pdf' : inlineImages.has(extension) ? extension : 'bin';
|
|
196
|
+
const blob = `${hash}.${suffix}`;
|
|
197
|
+
file.download = `/_files/blobs/${blob}`;
|
|
198
|
+
retain(course, path.join(output, 'blobs', blob));
|
|
199
|
+
if (!existsSync(path.join(output, 'blobs', blob)))
|
|
200
|
+
copyFileSync(source, path.join(output, 'blobs', blob));
|
|
201
|
+
if (suffix === 'pdf') file.kind = 'pdf';
|
|
202
|
+
else if (inlineImages.has(suffix)) file.kind = 'image';
|
|
203
|
+
else if (previewText(bytes)) file.kind = 'text';
|
|
204
|
+
file.history =
|
|
205
|
+
development && !publicEdition
|
|
206
|
+
? `/__content/history?course=${encodeURIComponent(course)}&file=${encodeURIComponent(file.path)}&v=${revisions.get(course)}-${hash}`
|
|
207
|
+
: undefined;
|
|
208
|
+
historyJobs.get(course)?.files.push({ file, source: path.join(output, 'blobs', blob) });
|
|
209
|
+
}
|
|
210
|
+
await buildHistories(
|
|
211
|
+
[...historyJobs.values()].filter((job) => job.files.length),
|
|
212
|
+
(job, file) => retain(path.basename(job.repo), file)
|
|
213
|
+
);
|
|
214
|
+
const entries: { course: string; file: string }[] = [];
|
|
215
|
+
for (const [course, files] of courses) {
|
|
216
|
+
if (publicEdition && !files.length) continue;
|
|
217
|
+
if (active.has(course))
|
|
218
|
+
await buildRmdPreviews(siteDir, course, files, output, (file) => retain(course, file));
|
|
219
|
+
retain(course, path.join(output, 'index', `${course}.json`));
|
|
220
|
+
writeChanged(path.join(output, 'index', `${course}.json`), JSON.stringify(files));
|
|
221
|
+
if (publicEdition) {
|
|
222
|
+
const paths = new Set(['']);
|
|
223
|
+
for (const file of files) {
|
|
224
|
+
const segments = file.path.split('/');
|
|
225
|
+
for (let i = 1; i <= segments.length; i++) paths.add(segments.slice(0, i).join('/'));
|
|
226
|
+
}
|
|
227
|
+
entries.push(...[...paths].map((file) => ({ course, file })));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
pruneOutputs(output, wanted, (file) => file === path.join(output, 'icons'));
|
|
231
|
+
if (!publicEdition && !development) {
|
|
232
|
+
const history = [...outputByCourse].flatMap(([course, outputs]) =>
|
|
233
|
+
[...outputs]
|
|
234
|
+
.filter((file) => path.dirname(file) === path.join(output, 'history'))
|
|
235
|
+
.map((file) => `file-history/${course}/${path.basename(file)}`)
|
|
236
|
+
);
|
|
237
|
+
// Keep transport selection portable when a fresh runner restores only the cache.
|
|
238
|
+
writeChanged(
|
|
239
|
+
path.join(siteDir, 'build/generated/cache/history-current.json'),
|
|
240
|
+
JSON.stringify([...new Set(history)].sort())
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
mkdirSync(path.join(siteDir, 'src/lib/generated'), { recursive: true });
|
|
244
|
+
writeChanged(path.join(siteDir, 'src/lib/generated/file-entries.json'), JSON.stringify(entries));
|
|
245
|
+
buildFileIcons(
|
|
246
|
+
siteDir,
|
|
247
|
+
[...courses.values()].flatMap((files) => files.map((file) => file.path))
|
|
248
|
+
);
|
|
249
|
+
for (const course of active) {
|
|
250
|
+
const fingerprint = fingerprints.get(course);
|
|
251
|
+
if (fingerprint)
|
|
252
|
+
saveCourseCache(
|
|
253
|
+
cacheFile(course),
|
|
254
|
+
fingerprint,
|
|
255
|
+
courses.get(course)!,
|
|
256
|
+
outputByCourse.get(course)!,
|
|
257
|
+
cacheContext(course)
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
if (!development)
|
|
261
|
+
writeChanged(
|
|
262
|
+
path.join(cacheRoot, `course-files-current-${publicEdition ? 'public' : 'full'}.json`),
|
|
263
|
+
JSON.stringify(
|
|
264
|
+
[...fingerprints.keys()]
|
|
265
|
+
.map((course) => `course-files/${publicEdition ? 'public' : 'full'}/${course}.json`)
|
|
266
|
+
.sort()
|
|
267
|
+
)
|
|
268
|
+
);
|
|
269
|
+
previousBuilds.set(
|
|
270
|
+
key,
|
|
271
|
+
new Map(
|
|
272
|
+
[...courses].map(([course, files]) => [
|
|
273
|
+
course,
|
|
274
|
+
{ files, outputs: outputByCourse.get(course)!, notes: noteKeys.get(course)! }
|
|
275
|
+
])
|
|
276
|
+
)
|
|
277
|
+
);
|
|
278
|
+
console.log(
|
|
279
|
+
`files: ${count} tracked files across ${active.size} changed courses (${courses.size - active.size} reused)`
|
|
280
|
+
);
|
|
281
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Root } from 'mdast';
|
|
2
|
+
import type { InlineMath } from 'mdast-util-math';
|
|
3
|
+
import { visit } from 'unist-util-visit';
|
|
4
|
+
|
|
5
|
+
/** Recognize paired prices that remark-math would silently consume as inline math. */
|
|
6
|
+
export function isCurrencyMath(node: InlineMath, source: string): boolean {
|
|
7
|
+
const start = node.position?.start.offset;
|
|
8
|
+
const end = node.position?.end.offset;
|
|
9
|
+
if (start == null || end == null) return false;
|
|
10
|
+
const raw = source.slice(start, end);
|
|
11
|
+
// Only single-dollar spans starting with an amount and ending at the next amount.
|
|
12
|
+
// Explicit LaTeX and ordinary $2 + 3$ / $x$ expressions remain unchanged.
|
|
13
|
+
if (!/^\$\d/.test(raw) || !/^\d/.test(source.slice(end))) return false;
|
|
14
|
+
if (/[\\\r\n]/.test(node.value)) return false;
|
|
15
|
+
return (
|
|
16
|
+
(/\s\$$/.test(raw) && /\b[A-Za-z]{2,}\b/.test(node.value)) || /^[\d.,\s–—-]+$/.test(node.value)
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Restore the exact literal text, including both dollar signs and all spacing. */
|
|
21
|
+
export function preserveCurrency(source: string) {
|
|
22
|
+
return (tree: Root) => {
|
|
23
|
+
visit(tree, 'inlineMath', (node, index, parent) => {
|
|
24
|
+
if (parent && index != null && isCurrencyMath(node, source)) {
|
|
25
|
+
parent.children[index] = {
|
|
26
|
+
type: 'text',
|
|
27
|
+
value: source.slice(node.position!.start.offset!, node.position!.end.offset!),
|
|
28
|
+
position: node.position
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import ts from 'typescript';
|
|
5
|
+
|
|
6
|
+
export interface DeploymentConfig {
|
|
7
|
+
name: string;
|
|
8
|
+
main: string;
|
|
9
|
+
compatibility_date: string;
|
|
10
|
+
compatibility_flags: string[];
|
|
11
|
+
vars: Record<string, string>;
|
|
12
|
+
d1_databases: {
|
|
13
|
+
binding: string;
|
|
14
|
+
database_name: string;
|
|
15
|
+
database_id: string;
|
|
16
|
+
migrations_dir?: string;
|
|
17
|
+
}[];
|
|
18
|
+
r2_buckets?: { binding: string; bucket_name: string; jurisdiction?: string }[];
|
|
19
|
+
assets: {
|
|
20
|
+
binding: string;
|
|
21
|
+
directory: string;
|
|
22
|
+
html_handling: 'drop-trailing-slash';
|
|
23
|
+
not_found_handling: '404-page';
|
|
24
|
+
run_worker_first: true;
|
|
25
|
+
};
|
|
26
|
+
workers_dev: boolean;
|
|
27
|
+
preview_urls: false;
|
|
28
|
+
routes: { pattern: string; custom_domain: true }[];
|
|
29
|
+
account_id?: string;
|
|
30
|
+
minify?: boolean;
|
|
31
|
+
upload_source_maps?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function deploymentError(message: string): never {
|
|
35
|
+
// Messages deliberately exclude API bodies, credentials, private paths, and module contents.
|
|
36
|
+
throw new Error(`deployment: ${message}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function record(value: unknown): value is Record<string, unknown> {
|
|
40
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function allowedKeys(value: Record<string, unknown>, allowed: string[]): void {
|
|
44
|
+
if (Object.keys(value).some((key) => !allowed.includes(key)))
|
|
45
|
+
deploymentError('unsupported configuration');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function sha256(bytes: Uint8Array | string): string {
|
|
49
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function canonical(value: unknown): unknown {
|
|
53
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
54
|
+
if (record(value))
|
|
55
|
+
return Object.fromEntries(
|
|
56
|
+
Object.keys(value)
|
|
57
|
+
.sort()
|
|
58
|
+
.map((key) => [key, canonical(value[key])])
|
|
59
|
+
);
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function configSha256(config: DeploymentConfig): string {
|
|
64
|
+
return sha256(JSON.stringify(canonical(config)));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function validateDeploymentConfig(value: unknown): DeploymentConfig {
|
|
68
|
+
if (!record(value)) deploymentError('invalid configuration');
|
|
69
|
+
allowedKeys(value, [
|
|
70
|
+
'$schema',
|
|
71
|
+
'name',
|
|
72
|
+
'main',
|
|
73
|
+
'compatibility_date',
|
|
74
|
+
'compatibility_flags',
|
|
75
|
+
'vars',
|
|
76
|
+
'd1_databases',
|
|
77
|
+
'r2_buckets',
|
|
78
|
+
'assets',
|
|
79
|
+
'workers_dev',
|
|
80
|
+
'preview_urls',
|
|
81
|
+
'routes',
|
|
82
|
+
'account_id',
|
|
83
|
+
'minify',
|
|
84
|
+
'upload_source_maps'
|
|
85
|
+
]);
|
|
86
|
+
if (
|
|
87
|
+
typeof value.name !== 'string' ||
|
|
88
|
+
!/^[a-z0-9][a-z0-9_-]*$/.test(value.name) ||
|
|
89
|
+
typeof value.main !== 'string' ||
|
|
90
|
+
!value.main
|
|
91
|
+
)
|
|
92
|
+
deploymentError('invalid Worker identity');
|
|
93
|
+
if (
|
|
94
|
+
typeof value.compatibility_date !== 'string' ||
|
|
95
|
+
!/^\d{4}-\d{2}-\d{2}$/.test(value.compatibility_date) ||
|
|
96
|
+
!Array.isArray(value.compatibility_flags) ||
|
|
97
|
+
value.compatibility_flags.some((flag) => typeof flag !== 'string')
|
|
98
|
+
)
|
|
99
|
+
deploymentError('invalid compatibility settings');
|
|
100
|
+
if (
|
|
101
|
+
!record(value.vars) ||
|
|
102
|
+
Object.entries(value.vars).some(([key, v]) => !bindingName(key) || typeof v !== 'string')
|
|
103
|
+
)
|
|
104
|
+
deploymentError('unsupported variables');
|
|
105
|
+
if (!Array.isArray(value.d1_databases)) deploymentError('missing D1 configuration');
|
|
106
|
+
for (const database of value.d1_databases) {
|
|
107
|
+
if (!record(database)) deploymentError('invalid D1 configuration');
|
|
108
|
+
allowedKeys(database, ['binding', 'database_name', 'database_id', 'migrations_dir']);
|
|
109
|
+
if (
|
|
110
|
+
!bindingName(database.binding) ||
|
|
111
|
+
typeof database.database_name !== 'string' ||
|
|
112
|
+
typeof database.database_id !== 'string' ||
|
|
113
|
+
!/^[0-9a-f-]{36}$/.test(database.database_id) ||
|
|
114
|
+
(database.migrations_dir !== undefined && typeof database.migrations_dir !== 'string')
|
|
115
|
+
)
|
|
116
|
+
deploymentError('invalid D1 configuration');
|
|
117
|
+
}
|
|
118
|
+
if (value.r2_buckets !== undefined) {
|
|
119
|
+
if (!Array.isArray(value.r2_buckets)) deploymentError('invalid R2 configuration');
|
|
120
|
+
for (const bucket of value.r2_buckets) {
|
|
121
|
+
if (!record(bucket)) deploymentError('invalid R2 configuration');
|
|
122
|
+
allowedKeys(bucket, ['binding', 'bucket_name', 'jurisdiction']);
|
|
123
|
+
if (
|
|
124
|
+
!bindingName(bucket.binding) ||
|
|
125
|
+
typeof bucket.bucket_name !== 'string' ||
|
|
126
|
+
!bucket.bucket_name ||
|
|
127
|
+
(bucket.jurisdiction !== undefined && typeof bucket.jurisdiction !== 'string')
|
|
128
|
+
)
|
|
129
|
+
deploymentError('invalid R2 configuration');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!record(value.assets)) deploymentError('missing asset configuration');
|
|
133
|
+
allowedKeys(value.assets, [
|
|
134
|
+
'binding',
|
|
135
|
+
'directory',
|
|
136
|
+
'html_handling',
|
|
137
|
+
'not_found_handling',
|
|
138
|
+
'run_worker_first'
|
|
139
|
+
]);
|
|
140
|
+
if (
|
|
141
|
+
!bindingName(value.assets.binding) ||
|
|
142
|
+
typeof value.assets.directory !== 'string' ||
|
|
143
|
+
!value.assets.directory ||
|
|
144
|
+
value.assets.run_worker_first !== true ||
|
|
145
|
+
value.assets.html_handling !== 'drop-trailing-slash' ||
|
|
146
|
+
value.assets.not_found_handling !== '404-page'
|
|
147
|
+
)
|
|
148
|
+
deploymentError('unsupported asset routing');
|
|
149
|
+
if (
|
|
150
|
+
typeof value.workers_dev !== 'boolean' ||
|
|
151
|
+
value.preview_urls !== false ||
|
|
152
|
+
(value.workers_dev && value.vars.TWIKI_AUTH_MODE !== 'none')
|
|
153
|
+
)
|
|
154
|
+
deploymentError('public development endpoints must be disabled');
|
|
155
|
+
if (!Array.isArray(value.routes) || (!value.routes.length && !value.workers_dev))
|
|
156
|
+
deploymentError('missing custom domains');
|
|
157
|
+
for (const route of value.routes) {
|
|
158
|
+
if (!record(route)) deploymentError('invalid custom domain');
|
|
159
|
+
allowedKeys(route, ['pattern', 'custom_domain']);
|
|
160
|
+
if (
|
|
161
|
+
route.custom_domain !== true ||
|
|
162
|
+
typeof route.pattern !== 'string' ||
|
|
163
|
+
!/^[a-z0-9.-]+$/.test(route.pattern) ||
|
|
164
|
+
!route.pattern.includes('.')
|
|
165
|
+
)
|
|
166
|
+
deploymentError('unsupported custom domain');
|
|
167
|
+
}
|
|
168
|
+
if (
|
|
169
|
+
new Set(value.routes.map((route) => (route as { pattern: string }).pattern)).size !==
|
|
170
|
+
value.routes.length
|
|
171
|
+
)
|
|
172
|
+
deploymentError('duplicate custom domains');
|
|
173
|
+
if (
|
|
174
|
+
value.account_id !== undefined &&
|
|
175
|
+
(typeof value.account_id !== 'string' || !/^[0-9a-f]{32}$/.test(value.account_id))
|
|
176
|
+
)
|
|
177
|
+
deploymentError('invalid account identity');
|
|
178
|
+
for (const key of ['minify', 'upload_source_maps'])
|
|
179
|
+
if (value[key] !== undefined && typeof value[key] !== 'boolean')
|
|
180
|
+
deploymentError('invalid build settings');
|
|
181
|
+
const config = { ...value };
|
|
182
|
+
delete config.$schema;
|
|
183
|
+
const names = [
|
|
184
|
+
...Object.keys(value.vars),
|
|
185
|
+
...value.d1_databases.map((db) => (db as { binding: string }).binding),
|
|
186
|
+
...((value.r2_buckets ?? []) as { binding: string }[]).map((bucket) => bucket.binding),
|
|
187
|
+
value.assets.binding
|
|
188
|
+
];
|
|
189
|
+
if (new Set(names).size !== names.length) deploymentError('duplicate bindings');
|
|
190
|
+
return config as unknown as DeploymentConfig;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function bindingName(value: unknown): value is string {
|
|
194
|
+
return typeof value === 'string' && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function readDeploymentConfig(siteRoot: string): DeploymentConfig {
|
|
198
|
+
const parsed = ts.parseConfigFileTextToJson(
|
|
199
|
+
'wrangler.jsonc',
|
|
200
|
+
readFileSync(path.join(siteRoot, 'wrangler.jsonc'), 'utf8')
|
|
201
|
+
);
|
|
202
|
+
if (parsed.error) deploymentError('invalid JSONC configuration');
|
|
203
|
+
return validateDeploymentConfig(parsed.config);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function expectedBindings(config: DeploymentConfig): Record<string, unknown>[] {
|
|
207
|
+
return [
|
|
208
|
+
...Object.entries(config.vars).map(([name, text]) => ({ name, type: 'plain_text', text })),
|
|
209
|
+
...config.d1_databases.map(({ binding: name, database_id: id }) => ({ name, type: 'd1', id })),
|
|
210
|
+
...(config.r2_buckets ?? []).map(({ binding: name, bucket_name, jurisdiction }) => ({
|
|
211
|
+
name,
|
|
212
|
+
type: 'r2_bucket',
|
|
213
|
+
bucket_name,
|
|
214
|
+
...(jurisdiction === undefined ? {} : { jurisdiction })
|
|
215
|
+
})),
|
|
216
|
+
{ name: config.assets.binding, type: 'assets' }
|
|
217
|
+
];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function equalJSON(a: unknown, b: unknown): boolean {
|
|
221
|
+
return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
|
|
222
|
+
}
|