@twango/twiki 0.0.0-stage → 0.1.0-rc.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/.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,244 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstatSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import type { FullSlug } from './slug';
|
|
5
|
+
import { writeChanged } from './output';
|
|
6
|
+
|
|
7
|
+
export type ContentEdition = 'public' | 'full';
|
|
8
|
+
export interface ContentSummary {
|
|
9
|
+
slug: FullSlug;
|
|
10
|
+
rel: string;
|
|
11
|
+
frontmatter: Record<string, unknown> & { title: string };
|
|
12
|
+
heading?: string;
|
|
13
|
+
title: string;
|
|
14
|
+
description: string;
|
|
15
|
+
tags: string[];
|
|
16
|
+
toc: { depth: number; text: string; slug: string }[];
|
|
17
|
+
draft: boolean;
|
|
18
|
+
hasMermaid: boolean;
|
|
19
|
+
wordCount: number;
|
|
20
|
+
}
|
|
21
|
+
export interface ContentCachePage {
|
|
22
|
+
parseKey: string;
|
|
23
|
+
summary: ContentSummary;
|
|
24
|
+
outgoing: string[];
|
|
25
|
+
transcludes: { target: string; selector?: string }[];
|
|
26
|
+
parseWarnings: string[];
|
|
27
|
+
unknownLanguages?: Record<string, number>;
|
|
28
|
+
linkWarnings: string[];
|
|
29
|
+
transclusionWarnings: string[];
|
|
30
|
+
body?: string;
|
|
31
|
+
document?: string;
|
|
32
|
+
documentKey?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface ContentCacheIndex {
|
|
35
|
+
schema: 1;
|
|
36
|
+
renderer: string;
|
|
37
|
+
context: string;
|
|
38
|
+
pages: Record<string, ContentCachePage>;
|
|
39
|
+
}
|
|
40
|
+
export interface ContentBody {
|
|
41
|
+
html: string;
|
|
42
|
+
markdown: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function contentHash(bytes: string | Uint8Array) {
|
|
46
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const digest = /^[a-f0-9]{64}$/;
|
|
50
|
+
export const contentObjectPath = /^content\/(?:public|full)\/(?:bodies|docs)\/[a-f0-9]{64}\.json$/;
|
|
51
|
+
const strings = (value: unknown): value is string[] =>
|
|
52
|
+
Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
53
|
+
|
|
54
|
+
/** Refuse symlinks at every level; restored manifests contain portable paths only. */
|
|
55
|
+
function readRegular(cache: string, relative: string): Buffer | undefined {
|
|
56
|
+
try {
|
|
57
|
+
const root = realpathSync(cache);
|
|
58
|
+
if (root !== path.resolve(cache)) return;
|
|
59
|
+
const file = path.join(root, relative);
|
|
60
|
+
if (realpathSync(file) !== file || !lstatSync(file).isFile()) return;
|
|
61
|
+
return readFileSync(file);
|
|
62
|
+
} catch {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function writeOwned(cache: string, relative: string, bytes: string | Uint8Array) {
|
|
68
|
+
const root = path.resolve(cache);
|
|
69
|
+
mkdirSync(root, { recursive: true });
|
|
70
|
+
if (realpathSync(root) !== root)
|
|
71
|
+
throw new Error('Content cache must have owned directory ancestors');
|
|
72
|
+
let directory = root;
|
|
73
|
+
for (const segment of relative.split('/').slice(0, -1)) {
|
|
74
|
+
directory = path.join(directory, segment);
|
|
75
|
+
const info = lstatSync(directory, { throwIfNoEntry: false });
|
|
76
|
+
if (info && (!info.isDirectory() || realpathSync(directory) !== directory))
|
|
77
|
+
throw new Error('Content cache must have owned directory ancestors');
|
|
78
|
+
if (!info) mkdirSync(directory);
|
|
79
|
+
}
|
|
80
|
+
const target = path.join(root, relative);
|
|
81
|
+
const info = lstatSync(target, { throwIfNoEntry: false });
|
|
82
|
+
if (info && (!info.isFile() || realpathSync(target) !== target))
|
|
83
|
+
throw new Error('Content cache entries must be owned regular files');
|
|
84
|
+
writeChanged(target, bytes);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function ownedObject(value: unknown, edition: ContentEdition, kind?: 'bodies' | 'docs') {
|
|
88
|
+
return (
|
|
89
|
+
typeof value === 'string' &&
|
|
90
|
+
contentObjectPath.test(value) &&
|
|
91
|
+
value.startsWith(`content/${edition}/${kind ?? ''}`)
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function readContentObject(cache: string, relative: string, edition: ContentEdition) {
|
|
96
|
+
if (!ownedObject(relative, edition)) return;
|
|
97
|
+
const bytes = readRegular(cache, relative);
|
|
98
|
+
if (!bytes || contentHash(bytes) !== path.basename(relative, '.json')) return;
|
|
99
|
+
return bytes;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function readContentBody(cache: string, relative: string, edition: ContentEdition) {
|
|
103
|
+
if (!ownedObject(relative, edition, 'bodies')) return;
|
|
104
|
+
try {
|
|
105
|
+
const bytes = readContentObject(cache, relative, edition);
|
|
106
|
+
if (!bytes) return;
|
|
107
|
+
const body: unknown = JSON.parse(bytes.toString());
|
|
108
|
+
if (
|
|
109
|
+
body &&
|
|
110
|
+
typeof body === 'object' &&
|
|
111
|
+
'html' in body &&
|
|
112
|
+
typeof body.html === 'string' &&
|
|
113
|
+
'markdown' in body &&
|
|
114
|
+
typeof body.markdown === 'string'
|
|
115
|
+
)
|
|
116
|
+
return body as ContentBody;
|
|
117
|
+
} catch {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function validPage(value: unknown, edition: ContentEdition): value is ContentCachePage {
|
|
123
|
+
if (!value || typeof value !== 'object') return false;
|
|
124
|
+
const page = value as ContentCachePage;
|
|
125
|
+
const summary = page.summary;
|
|
126
|
+
return (
|
|
127
|
+
digest.test(page.parseKey) &&
|
|
128
|
+
!!summary &&
|
|
129
|
+
typeof summary === 'object' &&
|
|
130
|
+
typeof summary.rel === 'string' &&
|
|
131
|
+
typeof summary.slug === 'string' &&
|
|
132
|
+
typeof summary.title === 'string' &&
|
|
133
|
+
typeof summary.description === 'string' &&
|
|
134
|
+
!!summary.frontmatter &&
|
|
135
|
+
typeof summary.frontmatter === 'object' &&
|
|
136
|
+
!Array.isArray(summary.frontmatter) &&
|
|
137
|
+
typeof summary.draft === 'boolean' &&
|
|
138
|
+
typeof summary.hasMermaid === 'boolean' &&
|
|
139
|
+
Number.isFinite(summary.wordCount) &&
|
|
140
|
+
strings(summary.tags) &&
|
|
141
|
+
Array.isArray(summary.toc) &&
|
|
142
|
+
summary.toc.every(
|
|
143
|
+
(entry) =>
|
|
144
|
+
entry &&
|
|
145
|
+
Number.isFinite(entry.depth) &&
|
|
146
|
+
typeof entry.text === 'string' &&
|
|
147
|
+
typeof entry.slug === 'string'
|
|
148
|
+
) &&
|
|
149
|
+
strings(page.outgoing) &&
|
|
150
|
+
strings(page.parseWarnings) &&
|
|
151
|
+
strings(page.linkWarnings) &&
|
|
152
|
+
strings(page.transclusionWarnings) &&
|
|
153
|
+
(page.unknownLanguages === undefined ||
|
|
154
|
+
(!!page.unknownLanguages &&
|
|
155
|
+
typeof page.unknownLanguages === 'object' &&
|
|
156
|
+
!Array.isArray(page.unknownLanguages) &&
|
|
157
|
+
Object.values(page.unknownLanguages).every(
|
|
158
|
+
(count) => Number.isInteger(count) && count > 0
|
|
159
|
+
))) &&
|
|
160
|
+
Array.isArray(page.transcludes) &&
|
|
161
|
+
page.transcludes.every(
|
|
162
|
+
(entry) =>
|
|
163
|
+
entry &&
|
|
164
|
+
typeof entry.target === 'string' &&
|
|
165
|
+
(entry.selector === undefined || typeof entry.selector === 'string')
|
|
166
|
+
) &&
|
|
167
|
+
(page.body === undefined || ownedObject(page.body, edition, 'bodies')) &&
|
|
168
|
+
(page.document === undefined || ownedObject(page.document, edition, 'docs')) &&
|
|
169
|
+
(page.documentKey === undefined || digest.test(page.documentKey))
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function readContentIndex(cache: string, edition: ContentEdition, renderer: string) {
|
|
174
|
+
try {
|
|
175
|
+
const bytes = readRegular(cache, `content-current-${edition}.json`);
|
|
176
|
+
if (!bytes) return;
|
|
177
|
+
const { checksum, ...index } = JSON.parse(bytes.toString());
|
|
178
|
+
if (
|
|
179
|
+
index.schema !== 1 ||
|
|
180
|
+
index.renderer !== renderer ||
|
|
181
|
+
!digest.test(index.context) ||
|
|
182
|
+
checksum !== contentHash(JSON.stringify(index)) ||
|
|
183
|
+
!index.pages ||
|
|
184
|
+
typeof index.pages !== 'object' ||
|
|
185
|
+
Array.isArray(index.pages) ||
|
|
186
|
+
!Object.entries(index.pages).every(
|
|
187
|
+
([relative, page]) => validPage(page, edition) && page.summary.rel === relative
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
return;
|
|
191
|
+
return index as ContentCacheIndex;
|
|
192
|
+
} catch {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function writeContentObject(
|
|
198
|
+
cache: string,
|
|
199
|
+
edition: ContentEdition,
|
|
200
|
+
kind: 'bodies' | 'docs',
|
|
201
|
+
bytes: string | Uint8Array
|
|
202
|
+
) {
|
|
203
|
+
const relative = `content/${edition}/${kind}/${contentHash(bytes)}.json`;
|
|
204
|
+
writeOwned(cache, relative, bytes);
|
|
205
|
+
return relative;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Commit the current selection only after the compiler completed all output checks. */
|
|
209
|
+
export function writeContentIndex(
|
|
210
|
+
cache: string,
|
|
211
|
+
edition: ContentEdition,
|
|
212
|
+
index: ContentCacheIndex
|
|
213
|
+
) {
|
|
214
|
+
const payload = JSON.stringify(index);
|
|
215
|
+
writeOwned(
|
|
216
|
+
cache,
|
|
217
|
+
`content-current-${edition}.json`,
|
|
218
|
+
JSON.stringify({ ...index, checksum: contentHash(payload) })
|
|
219
|
+
);
|
|
220
|
+
const files = new Set<string>([`content-current-${edition}.json`]);
|
|
221
|
+
for (const page of Object.values(index.pages)) {
|
|
222
|
+
if (page.body) files.add(page.body);
|
|
223
|
+
if (page.document) files.add(page.document);
|
|
224
|
+
}
|
|
225
|
+
writeOwned(cache, `content-files-current-${edition}.json`, JSON.stringify([...files].sort()));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Earlier targets may already be expanded, so the safe reverse closure is not depth limited. */
|
|
229
|
+
export function transclusionClosure(pages: Iterable<ContentCachePage>, changed: Set<string>) {
|
|
230
|
+
const records = [...pages];
|
|
231
|
+
const affected = new Set(changed);
|
|
232
|
+
let added: boolean;
|
|
233
|
+
do {
|
|
234
|
+
added = false;
|
|
235
|
+
for (const page of records) {
|
|
236
|
+
if (affected.has(page.summary.slug)) continue;
|
|
237
|
+
if (page.transcludes.some(({ target }) => affected.has(target))) {
|
|
238
|
+
affected.add(page.summary.slug);
|
|
239
|
+
added = true;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} while (added);
|
|
243
|
+
return affected;
|
|
244
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { visitElements } from './visit-elements';
|
|
2
|
+
import type { Root } from 'hast';
|
|
3
|
+
import { simplifySlug, stripSlashes, type FullSlug } from './slug';
|
|
4
|
+
|
|
5
|
+
export function htmlAssetPaths(assets: { rel: string; slug: FullSlug }[]): Map<string, string> {
|
|
6
|
+
return new Map(
|
|
7
|
+
assets
|
|
8
|
+
.filter((asset) => asset.rel.toLowerCase().endsWith('.html'))
|
|
9
|
+
.map((asset) => [`/${simplifySlug(asset.slug)}`.replace(/\/$/, ''), `/${asset.slug}.html`])
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isLocalContentUrl(value: string): boolean {
|
|
14
|
+
return !(
|
|
15
|
+
/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value) ||
|
|
16
|
+
value.startsWith('//') ||
|
|
17
|
+
value.startsWith('#')
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function resolveContentUrl(
|
|
22
|
+
value: string,
|
|
23
|
+
pageUrl: URL,
|
|
24
|
+
assetPaths: ReadonlyMap<string, string>
|
|
25
|
+
): string {
|
|
26
|
+
if (!isLocalContentUrl(value)) return value;
|
|
27
|
+
|
|
28
|
+
const url = new URL(value, pageUrl);
|
|
29
|
+
const route = url.pathname.replace(/\/$/, '') || '/';
|
|
30
|
+
const assetPath = assetPaths.get(decodeURIComponent(route));
|
|
31
|
+
const pathname = assetPath ? assetPath.split('/').map(encodeURIComponent).join('/') : route;
|
|
32
|
+
return pathname + url.search + url.hash;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function rewriteContentUrls(
|
|
36
|
+
tree: Root,
|
|
37
|
+
slug: FullSlug,
|
|
38
|
+
assetPaths: ReadonlyMap<string, string>
|
|
39
|
+
) {
|
|
40
|
+
const pagePath = stripSlashes(simplifySlug(slug), true);
|
|
41
|
+
const pageUrl = new URL(`/${pagePath}`, 'https://content.invalid');
|
|
42
|
+
|
|
43
|
+
visitElements(tree, (node) => {
|
|
44
|
+
for (const attribute of ['href', 'src']) {
|
|
45
|
+
const value = node.properties[attribute];
|
|
46
|
+
if (typeof value === 'string')
|
|
47
|
+
node.properties[attribute] = resolveContentUrl(value, pageUrl, assetPaths);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { lstatSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import type { CourseFile } from '../../src/lib/files';
|
|
6
|
+
import { writeChanged } from './output';
|
|
7
|
+
import { browsablePath } from './file-policy';
|
|
8
|
+
|
|
9
|
+
const digest = (bytes: string | Uint8Array) => createHash('sha256').update(bytes).digest('hex');
|
|
10
|
+
const sha = /^[a-f0-9]{64}$/;
|
|
11
|
+
const courseName = /^[\w-]+$/;
|
|
12
|
+
const historyName = /^[a-f0-9]{64}(?:\.json|-blame\.json|-[a-f0-9]{40,64}\.diff)$/;
|
|
13
|
+
const blobName = /^blobs\/[a-f0-9]{64}\.(?:bin|pdf|png|jpg|jpeg|gif|webp|avif)$/;
|
|
14
|
+
type Origin =
|
|
15
|
+
| { kind: 'index' }
|
|
16
|
+
| { kind: 'source'; path: string }
|
|
17
|
+
| { kind: 'history'; cachePath: string };
|
|
18
|
+
export type CourseCacheOutput = { path: string; bytes: number; sha256: string; origin: Origin };
|
|
19
|
+
export type CourseCacheRecord = {
|
|
20
|
+
version: 2;
|
|
21
|
+
course: string;
|
|
22
|
+
fingerprint: string;
|
|
23
|
+
files: CourseFile[];
|
|
24
|
+
outputs: CourseCacheOutput[];
|
|
25
|
+
payloadSha256: string;
|
|
26
|
+
};
|
|
27
|
+
export type CourseCacheContext = {
|
|
28
|
+
repo: string;
|
|
29
|
+
contentRoot?: string;
|
|
30
|
+
course: string;
|
|
31
|
+
output: string;
|
|
32
|
+
cache: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function regularBytes(root: string, relative: string) {
|
|
36
|
+
if (
|
|
37
|
+
!relative ||
|
|
38
|
+
/[\\\x00-\x1f\x7f]/.test(relative) ||
|
|
39
|
+
relative.split('/').some((part) => !part || part === '.' || part === '..')
|
|
40
|
+
)
|
|
41
|
+
return;
|
|
42
|
+
const file = path.join(root, relative);
|
|
43
|
+
try {
|
|
44
|
+
if (!lstatSync(file).isFile() || realpathSync(file) !== path.join(realpathSync(root), relative))
|
|
45
|
+
return;
|
|
46
|
+
return readFileSync(file);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (['ENOENT', 'ENOTDIR'].includes((error as NodeJS.ErrnoException).code ?? '')) return;
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Portable identity includes tracked source bytes, offline edits, deletions and publication rules. */
|
|
54
|
+
export function courseFingerprint(
|
|
55
|
+
repo: string,
|
|
56
|
+
course: string,
|
|
57
|
+
tracked: string[],
|
|
58
|
+
version: string,
|
|
59
|
+
notes: string,
|
|
60
|
+
contentRoot?: string
|
|
61
|
+
) {
|
|
62
|
+
const sourceRoot = contentRoot ?? path.join(repo, 'content');
|
|
63
|
+
if (!courseName.test(course)) throw new Error('Unsafe course name');
|
|
64
|
+
const hash = createHash('sha256').update(
|
|
65
|
+
JSON.stringify(['course-files-v2', course, version, notes])
|
|
66
|
+
);
|
|
67
|
+
try {
|
|
68
|
+
hash.update(
|
|
69
|
+
execFileSync('git', ['-C', path.join(sourceRoot, course), 'rev-parse', 'HEAD'], {
|
|
70
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
71
|
+
})
|
|
72
|
+
);
|
|
73
|
+
} catch {
|
|
74
|
+
hash.update('unborn');
|
|
75
|
+
}
|
|
76
|
+
const prefix = contentRoot === undefined ? `content/${course}/` : `${course}/`;
|
|
77
|
+
const inputs = [
|
|
78
|
+
...new Set([
|
|
79
|
+
...tracked.filter(
|
|
80
|
+
(file) => file.startsWith(prefix) && browsablePath(file.slice(prefix.length))
|
|
81
|
+
),
|
|
82
|
+
`${prefix}publish.yaml`
|
|
83
|
+
])
|
|
84
|
+
].sort();
|
|
85
|
+
for (const file of inputs) {
|
|
86
|
+
const bytes = regularBytes(contentRoot === undefined ? repo : sourceRoot, file);
|
|
87
|
+
hash.update(JSON.stringify([file, bytes ? [bytes.length, digest(bytes)] : null]));
|
|
88
|
+
}
|
|
89
|
+
return hash.digest('hex');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function payload(saved: Omit<CourseCacheRecord, 'payloadSha256'>) {
|
|
93
|
+
return JSON.stringify([
|
|
94
|
+
saved.version,
|
|
95
|
+
saved.course,
|
|
96
|
+
saved.fingerprint,
|
|
97
|
+
saved.files,
|
|
98
|
+
saved.outputs
|
|
99
|
+
]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Validate portable metadata before output restoration or transport selection. */
|
|
103
|
+
export function parseCourseCache(value: unknown, course: string): CourseCacheRecord | undefined {
|
|
104
|
+
const saved = value as CourseCacheRecord;
|
|
105
|
+
if (
|
|
106
|
+
!saved ||
|
|
107
|
+
saved.version !== 2 ||
|
|
108
|
+
saved.course !== course ||
|
|
109
|
+
!courseName.test(course) ||
|
|
110
|
+
typeof saved.fingerprint !== 'string' ||
|
|
111
|
+
!sha.test(saved.fingerprint) ||
|
|
112
|
+
!Array.isArray(saved.files) ||
|
|
113
|
+
!Array.isArray(saved.outputs) ||
|
|
114
|
+
typeof saved.payloadSha256 !== 'string' ||
|
|
115
|
+
!sha.test(saved.payloadSha256) ||
|
|
116
|
+
digest(payload(saved)) !== saved.payloadSha256
|
|
117
|
+
)
|
|
118
|
+
return;
|
|
119
|
+
if (
|
|
120
|
+
!saved.files.every(
|
|
121
|
+
(file) =>
|
|
122
|
+
file &&
|
|
123
|
+
typeof file.path === 'string' &&
|
|
124
|
+
browsablePath(file.path) &&
|
|
125
|
+
Number.isSafeInteger(file.size) &&
|
|
126
|
+
file.size >= 0 &&
|
|
127
|
+
['text', 'image', 'pdf', 'binary'].includes(file.kind) &&
|
|
128
|
+
[file.download, file.history, file.rmdPreview, file.note].every(
|
|
129
|
+
(value) => value === undefined || typeof value === 'string'
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
return;
|
|
134
|
+
const paths = new Set<string>();
|
|
135
|
+
for (const output of saved.outputs) {
|
|
136
|
+
if (
|
|
137
|
+
!output ||
|
|
138
|
+
typeof output.path !== 'string' ||
|
|
139
|
+
!browsablePath(output.path) ||
|
|
140
|
+
paths.has(output.path) ||
|
|
141
|
+
!Number.isSafeInteger(output.bytes) ||
|
|
142
|
+
output.bytes < 0 ||
|
|
143
|
+
typeof output.sha256 !== 'string' ||
|
|
144
|
+
!sha.test(output.sha256) ||
|
|
145
|
+
!output.origin
|
|
146
|
+
)
|
|
147
|
+
return;
|
|
148
|
+
paths.add(output.path);
|
|
149
|
+
if (output.origin.kind === 'index') {
|
|
150
|
+
if (output.path !== `index/${course}.json`) return;
|
|
151
|
+
} else if (output.origin.kind === 'history') {
|
|
152
|
+
const name = output.path.slice('history/'.length);
|
|
153
|
+
if (
|
|
154
|
+
!output.path.startsWith('history/') ||
|
|
155
|
+
!historyName.test(name) ||
|
|
156
|
+
output.origin.cachePath !== `file-history/${course}/${name}`
|
|
157
|
+
)
|
|
158
|
+
return;
|
|
159
|
+
} else if (output.origin.kind === 'source') {
|
|
160
|
+
const source = output.origin.path;
|
|
161
|
+
if (
|
|
162
|
+
!blobName.test(output.path) ||
|
|
163
|
+
output.path.slice(6, 70) !== output.sha256 ||
|
|
164
|
+
typeof source !== 'string' ||
|
|
165
|
+
!browsablePath(source) ||
|
|
166
|
+
!saved.files.some(
|
|
167
|
+
(file) =>
|
|
168
|
+
!file.locked &&
|
|
169
|
+
file.path === source &&
|
|
170
|
+
file.size === output.bytes &&
|
|
171
|
+
file.download === `/_files/${output.path}`
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
return;
|
|
175
|
+
} else return;
|
|
176
|
+
}
|
|
177
|
+
if (saved.files.length && !paths.has(`index/${course}.json`)) return;
|
|
178
|
+
for (const file of saved.files) {
|
|
179
|
+
if (
|
|
180
|
+
file.download &&
|
|
181
|
+
(file.locked ||
|
|
182
|
+
!file.download.startsWith('/_files/blobs/') ||
|
|
183
|
+
!paths.has(file.download.slice('/_files/'.length)))
|
|
184
|
+
)
|
|
185
|
+
return;
|
|
186
|
+
if (
|
|
187
|
+
file.history &&
|
|
188
|
+
(file.locked ||
|
|
189
|
+
!file.history.startsWith('/_files/history/') ||
|
|
190
|
+
!paths.has(file.history.slice('/_files/'.length)))
|
|
191
|
+
)
|
|
192
|
+
return;
|
|
193
|
+
if (file.rmdPreview) return; // R courses remain owned by the renderer preflight.
|
|
194
|
+
}
|
|
195
|
+
return saved;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function safeParents(root: string, relative: string) {
|
|
199
|
+
for (let parent = path.dirname(relative); parent !== '.'; parent = path.dirname(parent)) {
|
|
200
|
+
const target = path.join(root, parent);
|
|
201
|
+
const stat = lstatSync(target, { throwIfNoEntry: false });
|
|
202
|
+
if (
|
|
203
|
+
stat &&
|
|
204
|
+
(!stat.isDirectory() || realpathSync(target) !== path.join(realpathSync(root), parent))
|
|
205
|
+
)
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function readCourseCache(file: string, fingerprint: string, context: CourseCacheContext) {
|
|
212
|
+
try {
|
|
213
|
+
if (!lstatSync(file).isFile() || realpathSync(file) !== path.resolve(file)) return;
|
|
214
|
+
const saved = parseCourseCache(JSON.parse(readFileSync(file, 'utf8')), context.course);
|
|
215
|
+
if (!saved || saved.fingerprint !== fingerprint) return;
|
|
216
|
+
const pending: CourseCacheOutput[] = [];
|
|
217
|
+
const recoveryBytes = (output: CourseCacheOutput) => {
|
|
218
|
+
if (output.origin.kind === 'index') return Buffer.from(JSON.stringify(saved.files));
|
|
219
|
+
if (output.origin.kind === 'source')
|
|
220
|
+
return regularBytes(
|
|
221
|
+
path.join(context.contentRoot ?? path.join(context.repo, 'content'), context.course),
|
|
222
|
+
output.origin.path
|
|
223
|
+
);
|
|
224
|
+
return regularBytes(context.cache, output.origin.cachePath);
|
|
225
|
+
};
|
|
226
|
+
for (const output of saved.outputs) {
|
|
227
|
+
if (!safeParents(context.output, output.path)) return;
|
|
228
|
+
const existing = regularBytes(context.output, output.path);
|
|
229
|
+
if (existing && existing.length === output.bytes && digest(existing) === output.sha256)
|
|
230
|
+
continue;
|
|
231
|
+
const bytes = recoveryBytes(output);
|
|
232
|
+
if (!bytes || bytes.length !== output.bytes || digest(bytes) !== output.sha256) return;
|
|
233
|
+
pending.push(output);
|
|
234
|
+
}
|
|
235
|
+
// Check the complete closure before writing; unavailable products rebuild normally.
|
|
236
|
+
for (const output of pending) {
|
|
237
|
+
// Recheck bytes at the write boundary without retaining whole courses in memory.
|
|
238
|
+
const bytes = recoveryBytes(output);
|
|
239
|
+
if (
|
|
240
|
+
!bytes ||
|
|
241
|
+
bytes.length !== output.bytes ||
|
|
242
|
+
digest(bytes) !== output.sha256 ||
|
|
243
|
+
!safeParents(context.output, output.path)
|
|
244
|
+
)
|
|
245
|
+
return;
|
|
246
|
+
const target = path.join(context.output, output.path);
|
|
247
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
248
|
+
const stat = lstatSync(target, { throwIfNoEntry: false });
|
|
249
|
+
if (stat && !stat.isFile()) return;
|
|
250
|
+
writeChanged(target, bytes);
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
files: saved.files,
|
|
254
|
+
outputs: new Set(saved.outputs.map((output) => path.join(context.output, output.path)))
|
|
255
|
+
};
|
|
256
|
+
} catch {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function saveCourseCache(
|
|
262
|
+
file: string,
|
|
263
|
+
fingerprint: string,
|
|
264
|
+
files: CourseFile[],
|
|
265
|
+
outputs: Set<string>,
|
|
266
|
+
context: CourseCacheContext
|
|
267
|
+
) {
|
|
268
|
+
const records: CourseCacheOutput[] = [...outputs].sort().map((target) => {
|
|
269
|
+
const relative = path.relative(context.output, target).split(path.sep).join('/');
|
|
270
|
+
const bytes = regularBytes(context.output, relative);
|
|
271
|
+
if (!bytes) throw new Error('Invalid course cache output');
|
|
272
|
+
let origin: Origin;
|
|
273
|
+
if (relative === `index/${context.course}.json`) origin = { kind: 'index' };
|
|
274
|
+
else if (relative.startsWith('history/'))
|
|
275
|
+
origin = {
|
|
276
|
+
kind: 'history',
|
|
277
|
+
cachePath: `file-history/${context.course}/${path.basename(relative)}`
|
|
278
|
+
};
|
|
279
|
+
else {
|
|
280
|
+
const source = files.find(
|
|
281
|
+
(entry) => !entry.locked && entry.download === `/_files/${relative}`
|
|
282
|
+
);
|
|
283
|
+
if (!source) throw new Error('Course output has no tracked source');
|
|
284
|
+
origin = { kind: 'source', path: source.path };
|
|
285
|
+
}
|
|
286
|
+
return { path: relative, bytes: bytes.length, sha256: digest(bytes), origin };
|
|
287
|
+
});
|
|
288
|
+
const saved = {
|
|
289
|
+
version: 2 as const,
|
|
290
|
+
course: context.course,
|
|
291
|
+
fingerprint,
|
|
292
|
+
files,
|
|
293
|
+
outputs: records
|
|
294
|
+
};
|
|
295
|
+
const record = { ...saved, payloadSha256: digest(payload(saved)) };
|
|
296
|
+
if (!parseCourseCache(record, context.course)) throw new Error('Invalid portable course cache');
|
|
297
|
+
writeChanged(file, JSON.stringify(record));
|
|
298
|
+
}
|