@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,24 @@
|
|
|
1
|
+
import { error } from '@sveltejs/kit';
|
|
2
|
+
import type { EntryGenerator, PageServerLoad } from './$types';
|
|
3
|
+
import { requestContent } from '$lib/server/runtime-content';
|
|
4
|
+
import type { TocEntry } from '$lib/types';
|
|
5
|
+
|
|
6
|
+
export const entries: EntryGenerator = async () => {
|
|
7
|
+
if (import.meta.env.VITE_STATIC_EXPORT !== 'true') return [];
|
|
8
|
+
return (await requestContent(undefined).model()).contentEntries().map((slug) => ({ slug }));
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const load: PageServerLoad = async ({ params, platform }) => {
|
|
12
|
+
const route = params.slug.replace(/^\/+|\/+$/g, '');
|
|
13
|
+
const content = requestContent(platform);
|
|
14
|
+
const model = await content.model();
|
|
15
|
+
const slug = model.pageSlugForRoute(route);
|
|
16
|
+
if (slug) {
|
|
17
|
+
const { markdown: _markdown, ...page } = await content.page(slug);
|
|
18
|
+
const toc: TocEntry[] = page.toc.map((t) => ({ id: t.slug, text: t.text, level: t.depth + 1 }));
|
|
19
|
+
return { kind: 'page' as const, route, page, toc };
|
|
20
|
+
}
|
|
21
|
+
const listing = model.folderListing(route);
|
|
22
|
+
if (listing) return { kind: 'folder' as const, route, listing, toc: [] as TocEntry[] };
|
|
23
|
+
error(404, 'Page not found');
|
|
24
|
+
};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Folder from '@lucide/svelte/icons/folder';
|
|
3
|
+
import FileText from '@lucide/svelte/icons/file-text';
|
|
4
|
+
import NoteActions from '$lib/components/doc/NoteActions.svelte';
|
|
5
|
+
import { textExportUrl } from '$lib/text-exports';
|
|
6
|
+
import SEO from '$lib/components/SEO.svelte';
|
|
7
|
+
import Backlinks from '$lib/components/doc/Backlinks.svelte';
|
|
8
|
+
import Breadcrumbs from '$lib/components/doc/Breadcrumbs.svelte';
|
|
9
|
+
import ContentMeta from '$lib/components/doc/ContentMeta.svelte';
|
|
10
|
+
import LicenseNotice from '$lib/components/doc/LicenseNotice.svelte';
|
|
11
|
+
import LockedContent from '$lib/components/doc/LockedContent.svelte';
|
|
12
|
+
import { enhanceArticle } from '$lib/components/doc/enhancements';
|
|
13
|
+
import { linkPopovers } from '$lib/components/popover';
|
|
14
|
+
import type { PageData } from './$types';
|
|
15
|
+
import { curator, noteSchema, pageAuthor } from '$lib/metadata';
|
|
16
|
+
import { socialImageUrl } from '$lib/social-image';
|
|
17
|
+
|
|
18
|
+
interface Props {
|
|
19
|
+
data: PageData;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { data }: Props = $props();
|
|
23
|
+
|
|
24
|
+
const articleKey = $derived(data.kind === 'page' ? JSON.stringify(data.page) : '');
|
|
25
|
+
|
|
26
|
+
const canonical = $derived(data.route === '' ? '/' : `/${data.route}`);
|
|
27
|
+
const author = $derived(data.kind === 'page' ? pageAuthor(data.page) : undefined);
|
|
28
|
+
// Split only a leading H1, retaining its original markup and deep-link id.
|
|
29
|
+
const heading = $derived(
|
|
30
|
+
data.kind === 'page' ? (data.page.html.match(/^\s*<h1\b[^>]*>[\s\S]*?<\/h1>/i)?.[0] ?? '') : ''
|
|
31
|
+
);
|
|
32
|
+
const body = $derived(data.kind === 'page' ? data.page.html.slice(heading.length) : '');
|
|
33
|
+
|
|
34
|
+
// Pagefind permits only the final comma-separated filter to use inline
|
|
35
|
+
// `key:value` syntax. Emit one hidden capture element per value instead so
|
|
36
|
+
// course and every tag become independent, repeatable filters.
|
|
37
|
+
const pagefindCourse = $derived.by(() => {
|
|
38
|
+
if (data.route === '') return undefined;
|
|
39
|
+
return data.route.split('/')[0];
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function shortDate(iso?: string): string {
|
|
43
|
+
return iso
|
|
44
|
+
? new Date(iso).toLocaleDateString('en-US', {
|
|
45
|
+
year: 'numeric',
|
|
46
|
+
month: 'short',
|
|
47
|
+
day: 'numeric'
|
|
48
|
+
})
|
|
49
|
+
: '';
|
|
50
|
+
}
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<svelte:head>
|
|
54
|
+
<link rel="alternate" type="text/plain" title="LLM index" href="/llms.txt" />
|
|
55
|
+
{#if data.kind === 'page' && !data.page.locked}
|
|
56
|
+
<link rel="alternate" type="text/markdown" href={textExportUrl(data.page.slug)} />
|
|
57
|
+
<link rel="alternate" type="text/plain" href={textExportUrl(data.page.slug, 'txt')} />
|
|
58
|
+
{/if}
|
|
59
|
+
</svelte:head>
|
|
60
|
+
|
|
61
|
+
{#if data.kind === 'page'}
|
|
62
|
+
<SEO
|
|
63
|
+
title={data.route === '' ? undefined : data.page.title}
|
|
64
|
+
description={data.page.description}
|
|
65
|
+
{canonical}
|
|
66
|
+
type="article"
|
|
67
|
+
noindex={!data.page.publication.public}
|
|
68
|
+
jsonLd={noteSchema(canonical, data.page)}
|
|
69
|
+
image={socialImageUrl(data.page.slug)}
|
|
70
|
+
modified={data.page.dates.modified}
|
|
71
|
+
published={data.page.dates.published}
|
|
72
|
+
{author}
|
|
73
|
+
tags={data.page.tags}
|
|
74
|
+
/>
|
|
75
|
+
|
|
76
|
+
<Breadcrumbs route={data.route} current={data.page.title} />
|
|
77
|
+
|
|
78
|
+
{#key articleKey}
|
|
79
|
+
<article
|
|
80
|
+
class="prose dark:prose-invert max-w-none"
|
|
81
|
+
data-pagefind-body={data.page.locked ? undefined : true}
|
|
82
|
+
data-pagefind-ignore={data.page.locked ? true : undefined}
|
|
83
|
+
{@attach enhanceArticle(articleKey)}
|
|
84
|
+
{@attach linkPopovers(articleKey)}
|
|
85
|
+
>
|
|
86
|
+
{#if data.route !== ''}
|
|
87
|
+
<div class="hidden" data-pagefind-ignore>
|
|
88
|
+
<span data-pagefind-filter="course">{pagefindCourse}</span>
|
|
89
|
+
{#each data.page.tags as tag (tag)}
|
|
90
|
+
<span data-pagefind-filter="tag">{tag}</span>
|
|
91
|
+
{/each}
|
|
92
|
+
</div>
|
|
93
|
+
<!-- Frontmatter title beats Pagefind's h1-scraping (some pages lack an h1). -->
|
|
94
|
+
<span class="sr-only" data-pagefind-meta="title">{data.page.title}</span>
|
|
95
|
+
{/if}
|
|
96
|
+
{#if heading}
|
|
97
|
+
{@html heading}
|
|
98
|
+
{:else if data.kind === 'page'}
|
|
99
|
+
<h1>{data.page.title}</h1>
|
|
100
|
+
{/if}
|
|
101
|
+
<div class="doc-meta" data-pagefind-ignore>
|
|
102
|
+
<ContentMeta
|
|
103
|
+
modified={data.route ? data.page.dates?.modified : undefined}
|
|
104
|
+
published={data.route ? data.page.dates?.published : undefined}
|
|
105
|
+
author={data.page.locked ? undefined : (author ?? curator)}
|
|
106
|
+
curated={!author}
|
|
107
|
+
readingTime={data.route ? data.page.readingTime : undefined}
|
|
108
|
+
publication={data.page.publication}
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
{#if data.page.locked}
|
|
112
|
+
<LockedContent />
|
|
113
|
+
{#if data.page.toc.length}
|
|
114
|
+
<div class="mt-6 space-y-3" aria-label="Page outline">
|
|
115
|
+
{#each data.page.toc as heading (heading.slug)}
|
|
116
|
+
<div
|
|
117
|
+
id={heading.slug}
|
|
118
|
+
class="scroll-mt-20 border-l border-border pl-3 text-sm text-muted"
|
|
119
|
+
style:margin-left={`${heading.depth * 0.75}rem`}
|
|
120
|
+
>
|
|
121
|
+
{heading.text}
|
|
122
|
+
</div>
|
|
123
|
+
{/each}
|
|
124
|
+
</div>
|
|
125
|
+
{/if}
|
|
126
|
+
{:else}
|
|
127
|
+
{#key data.page.slug}
|
|
128
|
+
<NoteActions slug={data.page.slug} />
|
|
129
|
+
{/key}
|
|
130
|
+
{@html body}
|
|
131
|
+
{/if}
|
|
132
|
+
</article>
|
|
133
|
+
{/key}
|
|
134
|
+
{#if data.page.license}
|
|
135
|
+
<div class="mt-6"><LicenseNotice license={data.page.license} /></div>
|
|
136
|
+
{/if}
|
|
137
|
+
|
|
138
|
+
<Backlinks backlinks={data.page.backlinks} />
|
|
139
|
+
{:else}
|
|
140
|
+
<SEO
|
|
141
|
+
title={data.listing.name}
|
|
142
|
+
description={`Browse ${data.listing.name} course notes and files. Public material is readable; other content requires access.`}
|
|
143
|
+
{canonical}
|
|
144
|
+
/>
|
|
145
|
+
|
|
146
|
+
<Breadcrumbs route={data.route} current={data.listing.name} />
|
|
147
|
+
|
|
148
|
+
<article class="prose dark:prose-invert max-w-none" data-pagefind-body>
|
|
149
|
+
{#if pagefindCourse}
|
|
150
|
+
<span class="hidden" data-pagefind-ignore data-pagefind-filter="course">{pagefindCourse}</span
|
|
151
|
+
>
|
|
152
|
+
{/if}
|
|
153
|
+
<h1>{data.listing.name}</h1>
|
|
154
|
+
<p class="text-muted">
|
|
155
|
+
{data.listing.pageCount}
|
|
156
|
+
{data.listing.pageCount === 1 ? 'item' : 'items'} under this folder.
|
|
157
|
+
</p>
|
|
158
|
+
</article>
|
|
159
|
+
|
|
160
|
+
<ul class="m-0 mt-4 list-none p-0">
|
|
161
|
+
{#each data.listing.entries as entry (entry.route)}
|
|
162
|
+
<li>
|
|
163
|
+
<a
|
|
164
|
+
class="flex items-center gap-3 rounded-md px-3 py-2 text-sm text-text no-underline hover:bg-surface"
|
|
165
|
+
href={entry.route}
|
|
166
|
+
>
|
|
167
|
+
{#if entry.isFolder}
|
|
168
|
+
<Folder class="size-4 shrink-0 text-muted" aria-hidden="true" />
|
|
169
|
+
{:else}
|
|
170
|
+
<FileText class="size-4 shrink-0 text-muted" aria-hidden="true" />
|
|
171
|
+
{/if}
|
|
172
|
+
<span class="min-w-0 flex-1 truncate font-medium">{entry.title}</span>
|
|
173
|
+
{#if entry.modified}
|
|
174
|
+
<span class="shrink-0 text-xs text-muted">{shortDate(entry.modified)}</span>
|
|
175
|
+
{/if}
|
|
176
|
+
</a>
|
|
177
|
+
</li>
|
|
178
|
+
{/each}
|
|
179
|
+
</ul>
|
|
180
|
+
{/if}
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import SEO from '$lib/components/SEO.svelte';
|
|
3
|
+
import LicenseNotice from '$lib/components/doc/LicenseNotice.svelte';
|
|
4
|
+
import LockedContent from '$lib/components/doc/LockedContent.svelte';
|
|
5
|
+
import BookOpen from '@lucide/svelte/icons/book-open';
|
|
6
|
+
import Check from '@lucide/svelte/icons/check';
|
|
7
|
+
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
|
8
|
+
import Copy from '@lucide/svelte/icons/copy';
|
|
9
|
+
import Download from '@lucide/svelte/icons/download';
|
|
10
|
+
import Pin from '@lucide/svelte/icons/pin';
|
|
11
|
+
import File from '@lucide/svelte/icons/file';
|
|
12
|
+
import ArrowUpRight from '@lucide/svelte/icons/arrow-up-right';
|
|
13
|
+
import History from '@lucide/svelte/icons/history';
|
|
14
|
+
import GitCommitHorizontal from '@lucide/svelte/icons/git-commit-horizontal';
|
|
15
|
+
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
|
16
|
+
import { fileRoute, fileSize } from '$lib/files';
|
|
17
|
+
import FileIcon from '$lib/components/files/FileIcon.svelte';
|
|
18
|
+
import FileTabs from '$lib/components/files/FileTabs.svelte';
|
|
19
|
+
import FileCode from '$lib/components/files/FileCode.svelte';
|
|
20
|
+
import FileHistory from '$lib/components/files/FileHistory.svelte';
|
|
21
|
+
import type { FileBlame, FileChange } from '$lib/file-history';
|
|
22
|
+
import { fileWorkspace } from '$lib/components/files/file-workspace.svelte';
|
|
23
|
+
import { sameFile } from '$lib/file-tabs';
|
|
24
|
+
import { beforeNavigate } from '$app/navigation';
|
|
25
|
+
import { onMount, tick, untrack } from 'svelte';
|
|
26
|
+
import type { PageData } from './$types';
|
|
27
|
+
let { data }: { data: PageData } = $props();
|
|
28
|
+
const workspace = fileWorkspace();
|
|
29
|
+
let viewport = $state<HTMLElement>();
|
|
30
|
+
let historyOpen = $state(false);
|
|
31
|
+
let blaming = $state(false);
|
|
32
|
+
let blame = $state<FileBlame>();
|
|
33
|
+
let selectedCommit = $state('');
|
|
34
|
+
let sourceText = $state<string>();
|
|
35
|
+
let rmdMode = $state<'read' | 'interactive' | 'source'>(
|
|
36
|
+
untrack(() => (data.rmd ? 'read' : 'source'))
|
|
37
|
+
);
|
|
38
|
+
let diff = $state<{ text: string; commit: FileChange }>();
|
|
39
|
+
$effect(() => {
|
|
40
|
+
data.course;
|
|
41
|
+
data.path;
|
|
42
|
+
diff = undefined;
|
|
43
|
+
blame = undefined;
|
|
44
|
+
selectedCommit = '';
|
|
45
|
+
sourceText = undefined;
|
|
46
|
+
rmdMode = data.rmd ? 'read' : 'source';
|
|
47
|
+
});
|
|
48
|
+
const activeTab = $derived(workspace.tabs.find((tab) => sameFile(tab, data)));
|
|
49
|
+
function rememberPosition() {
|
|
50
|
+
if (data.file && viewport && !diff) workspace.remember(data.course, data.path, viewport);
|
|
51
|
+
}
|
|
52
|
+
beforeNavigate(rememberPosition);
|
|
53
|
+
onMount(() => {
|
|
54
|
+
window.addEventListener('pagehide', rememberPosition);
|
|
55
|
+
return () => window.removeEventListener('pagehide', rememberPosition);
|
|
56
|
+
});
|
|
57
|
+
$effect(() => {
|
|
58
|
+
const { course, path, file } = data;
|
|
59
|
+
if (!workspace.ready || !viewport) return;
|
|
60
|
+
const position = untrack(() => {
|
|
61
|
+
if (file) workspace.open(course, path);
|
|
62
|
+
return workspace.tabs.find((tab) => sameFile(tab, { course, path }));
|
|
63
|
+
});
|
|
64
|
+
void tick().then(() => {
|
|
65
|
+
if (viewport && data.course === course && data.path === path)
|
|
66
|
+
viewport.scrollTo({
|
|
67
|
+
top: position?.top ?? 0,
|
|
68
|
+
left: position?.left ?? 0,
|
|
69
|
+
behavior: 'instant'
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
let copied = $state(false);
|
|
74
|
+
let copyFailed = $state(false);
|
|
75
|
+
const name = $derived(data.path.split('/').at(-1) || data.course);
|
|
76
|
+
const segments = $derived(data.path.split('/').filter(Boolean));
|
|
77
|
+
const action =
|
|
78
|
+
'inline-flex items-center gap-1.5 rounded px-2 py-1.5 text-xs text-muted no-underline hover:bg-surface hover:text-text';
|
|
79
|
+
$effect(() => {
|
|
80
|
+
data.path;
|
|
81
|
+
copied = false;
|
|
82
|
+
copyFailed = false;
|
|
83
|
+
});
|
|
84
|
+
async function copy() {
|
|
85
|
+
try {
|
|
86
|
+
await navigator.clipboard.writeText(diff?.text ?? sourceText ?? '');
|
|
87
|
+
copied = true;
|
|
88
|
+
copyFailed = false;
|
|
89
|
+
} catch {
|
|
90
|
+
copyFailed = true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
</script>
|
|
94
|
+
|
|
95
|
+
<SEO
|
|
96
|
+
title={`${name} · Files`}
|
|
97
|
+
description=""
|
|
98
|
+
canonical={fileRoute(data.course, data.path)}
|
|
99
|
+
noindex
|
|
100
|
+
/>
|
|
101
|
+
|
|
102
|
+
<div class="flex h-full min-h-0 flex-col" data-pagefind-ignore>
|
|
103
|
+
<div class="flex shrink-0 flex-col border-b border-border">
|
|
104
|
+
<nav
|
|
105
|
+
class="flex items-center gap-1 overflow-x-auto px-3 py-1.5 text-xs whitespace-nowrap text-muted"
|
|
106
|
+
aria-label="File breadcrumbs"
|
|
107
|
+
>
|
|
108
|
+
<a class="hover:text-text" href={`/${encodeURIComponent(data.course)}`}>{data.course}</a>
|
|
109
|
+
<ChevronRight size={12} />
|
|
110
|
+
<a class="hover:text-text" href={fileRoute(data.course)}>Files</a>
|
|
111
|
+
{#each segments as segment, i}
|
|
112
|
+
<ChevronRight size={12} />
|
|
113
|
+
<a
|
|
114
|
+
class="hover:text-text"
|
|
115
|
+
href={fileRoute(data.course, segments.slice(0, i + 1).join('/'))}
|
|
116
|
+
aria-current={i === segments.length - 1 ? 'page' : undefined}>{segment}</a
|
|
117
|
+
>
|
|
118
|
+
{/each}
|
|
119
|
+
</nav>
|
|
120
|
+
<header
|
|
121
|
+
class="order-first flex min-w-0 items-center border-b border-border bg-surface pr-2 max-sm:flex-col max-sm:items-stretch"
|
|
122
|
+
>
|
|
123
|
+
{#if !data.file && !workspace.tabs.length}<div
|
|
124
|
+
class="flex min-w-0 items-center gap-2 border-r border-border bg-bg px-3 py-2"
|
|
125
|
+
>
|
|
126
|
+
<FileIcon {name} folder={!data.file} expanded={!data.file} />
|
|
127
|
+
<h1 class="m-0 break-all font-mono text-xs font-medium">{name}</h1>
|
|
128
|
+
</div>{:else}<h1 class="sr-only">{name}</h1>{/if}
|
|
129
|
+
<FileTabs course={data.course} path={data.path} isFile={!!data.file} />
|
|
130
|
+
{#if data.file}
|
|
131
|
+
<div class="flex shrink-0 items-center gap-1 overflow-x-auto">
|
|
132
|
+
{#if /\.rmd$/i.test(data.path) && !data.file.locked && !diff}
|
|
133
|
+
<div
|
|
134
|
+
class="flex shrink-0 rounded border border-border"
|
|
135
|
+
role="group"
|
|
136
|
+
aria-label="Worksheet view"
|
|
137
|
+
>
|
|
138
|
+
{#each [['read', 'Read'], ['interactive', 'Interactive'], ['source', 'Source']] as [mode, label]}
|
|
139
|
+
<button
|
|
140
|
+
class={action}
|
|
141
|
+
disabled={mode === 'read' && !data.rmd}
|
|
142
|
+
class:opacity-50={mode === 'read' && !data.rmd}
|
|
143
|
+
class:bg-bg={rmdMode === mode}
|
|
144
|
+
aria-pressed={rmdMode === mode}
|
|
145
|
+
onclick={() => (rmdMode = mode as typeof rmdMode)}>{label}</button
|
|
146
|
+
>
|
|
147
|
+
{/each}
|
|
148
|
+
</div>
|
|
149
|
+
{/if}
|
|
150
|
+
{#if data.file.history}
|
|
151
|
+
{#if data.file.kind === 'text'}<button
|
|
152
|
+
class={action}
|
|
153
|
+
title="Toggle blame"
|
|
154
|
+
aria-label="Toggle blame"
|
|
155
|
+
aria-pressed={blaming}
|
|
156
|
+
onclick={() => (blaming = !blaming)}><GitCommitHorizontal size={14} /></button
|
|
157
|
+
>{/if}
|
|
158
|
+
<button
|
|
159
|
+
class={action}
|
|
160
|
+
title="File history"
|
|
161
|
+
aria-label="File history"
|
|
162
|
+
aria-expanded={historyOpen}
|
|
163
|
+
onclick={() => (historyOpen = !historyOpen)}><History size={14} /></button
|
|
164
|
+
>
|
|
165
|
+
{/if}
|
|
166
|
+
{#if !activeTab?.pinned}<button
|
|
167
|
+
class={action}
|
|
168
|
+
title="Keep this preview open"
|
|
169
|
+
aria-label="Keep file open"
|
|
170
|
+
disabled={!workspace.ready}
|
|
171
|
+
onclick={() => workspace.open(data.course, data.path, true)}><Pin size={14} /></button
|
|
172
|
+
>{/if}
|
|
173
|
+
{#if data.file.note}<a class={action} href={data.file.note}
|
|
174
|
+
><BookOpen size={14} />Read note</a
|
|
175
|
+
>{/if}
|
|
176
|
+
{#if data.file.kind === 'text' || diff}<button
|
|
177
|
+
class={action}
|
|
178
|
+
disabled={!diff && sourceText === undefined}
|
|
179
|
+
onclick={copy}
|
|
180
|
+
>{#if copied}<Check size={14} />Copied{:else}<Copy size={14} />Copy{/if}</button
|
|
181
|
+
>{/if}
|
|
182
|
+
{#if data.file.download}
|
|
183
|
+
{#if data.file.kind === 'pdf' || data.file.kind === 'image'}<a
|
|
184
|
+
class={action}
|
|
185
|
+
href={data.file.download}
|
|
186
|
+
target="_blank"
|
|
187
|
+
rel="noopener noreferrer"><ArrowUpRight size={14} />Open</a
|
|
188
|
+
>{/if}
|
|
189
|
+
<a class={action} href={data.file.download} download={name}
|
|
190
|
+
><Download size={14} />Download</a
|
|
191
|
+
>
|
|
192
|
+
{/if}
|
|
193
|
+
</div>
|
|
194
|
+
{/if}
|
|
195
|
+
</header>
|
|
196
|
+
</div>
|
|
197
|
+
{#if copyFailed}<p class="px-3 py-2 text-xs text-muted" role="status">
|
|
198
|
+
Clipboard unavailable. Select and copy the code below.
|
|
199
|
+
</p>{/if}
|
|
200
|
+
{#if diff}<div
|
|
201
|
+
class="flex items-center gap-2 border-b border-border px-3 py-1.5 text-xs text-muted"
|
|
202
|
+
>
|
|
203
|
+
<button
|
|
204
|
+
class="flex shrink-0 items-center gap-1 text-accent"
|
|
205
|
+
onclick={() => {
|
|
206
|
+
diff = undefined;
|
|
207
|
+
selectedCommit = '';
|
|
208
|
+
}}><ArrowLeft size={12} />Back to file</button
|
|
209
|
+
>
|
|
210
|
+
<span class="truncate">{diff.commit.subject} · {diff.commit.id.slice(0, 7)}</span>
|
|
211
|
+
</div>{/if}
|
|
212
|
+
<div class="relative flex min-h-0 min-w-0 flex-1 overflow-hidden">
|
|
213
|
+
{#if data.file?.locked}
|
|
214
|
+
<div class="min-w-0 flex-1 overflow-auto p-4"><LockedContent /></div>
|
|
215
|
+
{:else if rmdMode === 'read' && data.rmd && !diff}
|
|
216
|
+
<div class="min-h-0 min-w-0 flex-1 overflow-auto bg-surface p-4 sm:p-8" bind:this={viewport}>
|
|
217
|
+
<article
|
|
218
|
+
class="rmd-reading prose mx-auto max-w-3xl rounded border border-border bg-bg p-5 shadow-sm sm:p-10 dark:prose-invert"
|
|
219
|
+
aria-label="Rendered worksheet"
|
|
220
|
+
>
|
|
221
|
+
<h1>{data.rmd.title}</h1>
|
|
222
|
+
{@html data.rmd.html}
|
|
223
|
+
</article>
|
|
224
|
+
</div>
|
|
225
|
+
{:else if rmdMode === 'interactive' && /\.rmd$/i.test(data.path) && data.file?.download && !diff}
|
|
226
|
+
<div class="min-h-0 min-w-0 flex-1">
|
|
227
|
+
{#key `${data.course}/${data.path}`}
|
|
228
|
+
{#await import('$lib/components/files/FileRmd.svelte')}
|
|
229
|
+
<p class="p-6 text-sm text-muted" role="status">Opening interactive worksheet…</p>
|
|
230
|
+
{:then component}
|
|
231
|
+
<component.default
|
|
232
|
+
url={data.file.download}
|
|
233
|
+
course={data.course}
|
|
234
|
+
path={data.path}
|
|
235
|
+
files={data.files}
|
|
236
|
+
onload={(text) => (sourceText = text)}
|
|
237
|
+
/>
|
|
238
|
+
{:catch}
|
|
239
|
+
<p class="p-6 text-sm text-muted" role="alert">
|
|
240
|
+
Could not open the interactive worksheet. Switch to Read or reload to try again.
|
|
241
|
+
</p>
|
|
242
|
+
{/await}
|
|
243
|
+
{/key}
|
|
244
|
+
</div>
|
|
245
|
+
{:else if data.file?.kind === 'text' || diff}
|
|
246
|
+
<div class="min-h-0 min-w-0 flex-1">
|
|
247
|
+
{#key `${data.course}/${data.path}/${diff?.commit.id ?? ''}`}
|
|
248
|
+
<FileCode
|
|
249
|
+
text={diff?.text}
|
|
250
|
+
url={data.file?.download}
|
|
251
|
+
onload={(text) => {
|
|
252
|
+
if (!diff) sourceText = text;
|
|
253
|
+
}}
|
|
254
|
+
filename={diff ? 'change.diff' : name}
|
|
255
|
+
blame={diff ? undefined : blame}
|
|
256
|
+
oncommit={(id) => {
|
|
257
|
+
selectedCommit = id;
|
|
258
|
+
historyOpen = true;
|
|
259
|
+
}}
|
|
260
|
+
onready={(element) => {
|
|
261
|
+
if (!diff) viewport = element;
|
|
262
|
+
}}
|
|
263
|
+
/>
|
|
264
|
+
{/key}
|
|
265
|
+
</div>
|
|
266
|
+
{:else}
|
|
267
|
+
<div class="file-content min-h-0 flex-1 overflow-auto" bind:this={viewport}>
|
|
268
|
+
{#if data.file?.kind === 'pdf'}
|
|
269
|
+
<iframe class="block h-full w-full border-0" title={name} src={data.file.download}
|
|
270
|
+
></iframe>
|
|
271
|
+
{:else if data.file?.kind === 'image'}
|
|
272
|
+
<div class="flex h-full justify-center bg-surface">
|
|
273
|
+
<img class="max-h-full max-w-full object-contain" src={data.file.download} alt={name} />
|
|
274
|
+
</div>
|
|
275
|
+
{:else if data.file}
|
|
276
|
+
<div
|
|
277
|
+
class="flex h-full flex-col items-center justify-center px-3 text-center text-sm text-muted"
|
|
278
|
+
>
|
|
279
|
+
<File class="mx-auto mb-3" size={28} />
|
|
280
|
+
<p>
|
|
281
|
+
{data.file.download
|
|
282
|
+
? 'No preview for this file type. Download it to open locally.'
|
|
283
|
+
: 'This file exceeds the hosting size limit and is not available for download.'}
|
|
284
|
+
</p>
|
|
285
|
+
</div>
|
|
286
|
+
{:else}
|
|
287
|
+
<ul class="m-0 list-none divide-y divide-border p-0" aria-label="Directory contents">
|
|
288
|
+
{#each data.entries ?? [] as entry (entry.path)}
|
|
289
|
+
<li>
|
|
290
|
+
<a
|
|
291
|
+
class="flex min-w-0 items-center gap-3 px-3 py-2.5 text-sm text-text no-underline hover:bg-surface"
|
|
292
|
+
href={fileRoute(data.course, entry.path)}
|
|
293
|
+
onclick={(event) => {
|
|
294
|
+
if (entry.file) workspace.activate(event, data.course, entry.path);
|
|
295
|
+
}}
|
|
296
|
+
>
|
|
297
|
+
<FileIcon name={entry.name} folder={!!entry.children} />
|
|
298
|
+
<span class="min-w-0 flex-1 truncate">{entry.name}</span><span
|
|
299
|
+
class="shrink-0 text-xs text-muted"
|
|
300
|
+
>{entry.file
|
|
301
|
+
? entry.file.locked
|
|
302
|
+
? 'Locked'
|
|
303
|
+
: fileSize(entry.file.size)
|
|
304
|
+
: `${entry.children?.length} items`}</span
|
|
305
|
+
>
|
|
306
|
+
</a>
|
|
307
|
+
</li>
|
|
308
|
+
{:else}<li class="p-4 text-sm text-muted">No browsable files in this course.</li>{/each}
|
|
309
|
+
</ul>
|
|
310
|
+
{/if}
|
|
311
|
+
</div>
|
|
312
|
+
{/if}
|
|
313
|
+
{#if data.file?.history}
|
|
314
|
+
{#key data.file.history}
|
|
315
|
+
<FileHistory
|
|
316
|
+
url={data.file.history}
|
|
317
|
+
bind:open={historyOpen}
|
|
318
|
+
bind:blaming
|
|
319
|
+
bind:selected={selectedCommit}
|
|
320
|
+
onblame={(value) => (blame = value)}
|
|
321
|
+
onview={(text, commit) => {
|
|
322
|
+
rememberPosition();
|
|
323
|
+
diff = { text, commit };
|
|
324
|
+
}}
|
|
325
|
+
/>
|
|
326
|
+
{/key}
|
|
327
|
+
{/if}
|
|
328
|
+
</div>
|
|
329
|
+
{#if data.file?.license}
|
|
330
|
+
<details class="shrink-0 border-t border-border px-3 text-xs text-muted">
|
|
331
|
+
<summary class="cursor-pointer py-1.5">{data.file.license.name} · Attribution</summary>
|
|
332
|
+
<LicenseNotice license={data.file.license} />
|
|
333
|
+
</details>
|
|
334
|
+
{/if}
|
|
335
|
+
<footer
|
|
336
|
+
class="flex shrink-0 items-center justify-between border-t border-border bg-surface px-3 py-1 text-[0.6875rem] text-muted"
|
|
337
|
+
>
|
|
338
|
+
<span
|
|
339
|
+
>{diff
|
|
340
|
+
? `${diff.text.split('\n').length} lines · Diff`
|
|
341
|
+
: sourceText !== undefined
|
|
342
|
+
? `${sourceText.split('\n').length} lines · UTF-8`
|
|
343
|
+
: data.file?.kind === 'pdf'
|
|
344
|
+
? 'PDF'
|
|
345
|
+
: data.file?.kind === 'image'
|
|
346
|
+
? 'Image'
|
|
347
|
+
: 'Files'}</span
|
|
348
|
+
>
|
|
349
|
+
<span
|
|
350
|
+
>{data.file
|
|
351
|
+
? `${fileSize(data.file.size)} · Read-only`
|
|
352
|
+
: `${data.entries?.length ?? 0} items`}</span
|
|
353
|
+
>
|
|
354
|
+
</footer>
|
|
355
|
+
</div>
|
|
356
|
+
|
|
357
|
+
<style>
|
|
358
|
+
.rmd-reading {
|
|
359
|
+
font-family: 'KaTeX_Main', Georgia, 'Times New Roman', serif;
|
|
360
|
+
line-height: 1.8;
|
|
361
|
+
}
|
|
362
|
+
.rmd-reading :global(:is(h1, h2, h3, h4, h5, h6)) {
|
|
363
|
+
font-family: inherit;
|
|
364
|
+
}
|
|
365
|
+
.rmd-reading :global(pre) {
|
|
366
|
+
white-space: pre-wrap;
|
|
367
|
+
overflow-wrap: anywhere;
|
|
368
|
+
}
|
|
369
|
+
.rmd-reading :global(img) {
|
|
370
|
+
margin-inline: auto;
|
|
371
|
+
background: white;
|
|
372
|
+
}
|
|
373
|
+
</style>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { error } from '@sveltejs/kit';
|
|
2
|
+
import {
|
|
3
|
+
directoryEntries,
|
|
4
|
+
fileIndexUrl,
|
|
5
|
+
fileTree,
|
|
6
|
+
type CourseFile,
|
|
7
|
+
type RmdPreview
|
|
8
|
+
} from '$lib/files';
|
|
9
|
+
import type { PageLoad } from './$types';
|
|
10
|
+
|
|
11
|
+
// File routes share a static shell; only their catalog and selected assets are loaded.
|
|
12
|
+
export const prerender = false;
|
|
13
|
+
export const ssr = false;
|
|
14
|
+
|
|
15
|
+
export const load: PageLoad = async ({ params, fetch }) => {
|
|
16
|
+
const response = await fetch(fileIndexUrl(params.course));
|
|
17
|
+
if (!response.ok) error(response.status === 404 ? 404 : 503, 'Files unavailable');
|
|
18
|
+
const files: CourseFile[] = await response.json();
|
|
19
|
+
const file = files.find((entry) => entry.path === params.file);
|
|
20
|
+
const entries = file ? undefined : directoryEntries(fileTree(files), params.file);
|
|
21
|
+
if (!file && !entries) error(404, 'File not found');
|
|
22
|
+
let rmd: RmdPreview | undefined;
|
|
23
|
+
if (file?.rmdPreview && !file.locked) {
|
|
24
|
+
const preview = await fetch(file.rmdPreview);
|
|
25
|
+
if (!preview.ok) error(503, 'Worksheet preview unavailable');
|
|
26
|
+
rmd = await preview.json();
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
kind: 'file-browser' as const,
|
|
30
|
+
course: params.course,
|
|
31
|
+
path: params.file,
|
|
32
|
+
files,
|
|
33
|
+
file,
|
|
34
|
+
rmd,
|
|
35
|
+
entries,
|
|
36
|
+
toc: []
|
|
37
|
+
};
|
|
38
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { githubUsername } from '$lib/github-user';
|
|
2
|
+
import { fail } from '@sveltejs/kit';
|
|
3
|
+
import { requireOwner, revokeAccess } from '$lib/server/access-admin';
|
|
4
|
+
import type { Actions, PageServerLoad } from './$types';
|
|
5
|
+
import { sql } from 'drizzle-orm';
|
|
6
|
+
import { database } from '../../../../database';
|
|
7
|
+
import { siteAccess } from '../../../../database/schema';
|
|
8
|
+
|
|
9
|
+
export const prerender = false;
|
|
10
|
+
|
|
11
|
+
export const load: PageServerLoad = async (event) => {
|
|
12
|
+
const env = await requireOwner(event);
|
|
13
|
+
const grants = await database(env.DB)
|
|
14
|
+
.select()
|
|
15
|
+
.from(siteAccess)
|
|
16
|
+
.orderBy(sql`${siteAccess.githubLogin} COLLATE NOCASE`);
|
|
17
|
+
return { grants, ownerId: env.OWNER_GITHUB_ID };
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const actions: Actions = {
|
|
21
|
+
add: async (event) => {
|
|
22
|
+
const env = await requireOwner(event);
|
|
23
|
+
const form = await event.request.formData();
|
|
24
|
+
// The authenticated owner may grant any GitHub ID. The browser resolves the
|
|
25
|
+
// profile; the ID controls access and the login is only a display label.
|
|
26
|
+
const id = form.get('githubId');
|
|
27
|
+
const login = form.get('githubLogin');
|
|
28
|
+
if (
|
|
29
|
+
typeof id !== 'string' ||
|
|
30
|
+
!/^[1-9]\d*$/.test(id) ||
|
|
31
|
+
!Number.isSafeInteger(Number(id)) ||
|
|
32
|
+
typeof login !== 'string' ||
|
|
33
|
+
!githubUsername.test(login)
|
|
34
|
+
)
|
|
35
|
+
return fail(400, { error: 'Resolve a valid GitHub account before adding access.' });
|
|
36
|
+
if (id === env.OWNER_GITHUB_ID)
|
|
37
|
+
return fail(400, { error: 'You already have permanent owner access.' });
|
|
38
|
+
await database(env.DB)
|
|
39
|
+
.insert(siteAccess)
|
|
40
|
+
.values({ githubId: id, githubLogin: login, createdAt: Date.now() })
|
|
41
|
+
.onConflictDoUpdate({ target: siteAccess.githubId, set: { githubLogin: login } });
|
|
42
|
+
return { message: `@${login} can now sign in and read all courses.` };
|
|
43
|
+
},
|
|
44
|
+
revoke: async (event) => {
|
|
45
|
+
const env = await requireOwner(event);
|
|
46
|
+
const id = (await event.request.formData()).get('githubId');
|
|
47
|
+
if (typeof id !== 'string' || !/^[1-9]\d*$/.test(id))
|
|
48
|
+
return fail(400, { error: 'Invalid GitHub account ID.' });
|
|
49
|
+
if (id === env.OWNER_GITHUB_ID) return fail(400, { error: 'Owner access cannot be revoked.' });
|
|
50
|
+
await revokeAccess(env, id);
|
|
51
|
+
return { message: 'Access revoked. Existing sessions have been signed out.' };
|
|
52
|
+
}
|
|
53
|
+
};
|