@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,22 @@
|
|
|
1
|
+
export interface ParsedSearchQuery {
|
|
2
|
+
/** `null` asks Pagefind for filter-only results. */
|
|
3
|
+
term: string | null;
|
|
4
|
+
/** Quartz-compatible leading `#tag` filter. */
|
|
5
|
+
tag?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Quartz treated a leading `#tag` as a tag search. Preserve that shorthand
|
|
10
|
+
* while still allowing free text after the tag (`#cs544 spark`).
|
|
11
|
+
*/
|
|
12
|
+
export function parseSearchQuery(value: string): ParsedSearchQuery {
|
|
13
|
+
const query = value.trim();
|
|
14
|
+
const tagged = /^#([^\s]+)(?:\s+([\s\S]*))?$/.exec(query);
|
|
15
|
+
if (!tagged) return { term: query };
|
|
16
|
+
|
|
17
|
+
const remainder = tagged[2]?.trim() ?? '';
|
|
18
|
+
return {
|
|
19
|
+
term: remainder === '' ? null : remainder,
|
|
20
|
+
tag: tagged[1]
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Convert Pagefind HTML paths to extensionless routes, preserving anchors. */
|
|
2
|
+
export function cleanResultUrl(raw: string): string {
|
|
3
|
+
const hashIdx = raw.indexOf('#');
|
|
4
|
+
const anchor = hashIdx === -1 ? '' : raw.slice(hashIdx);
|
|
5
|
+
let path = hashIdx === -1 ? raw : raw.slice(0, hashIdx);
|
|
6
|
+
path = path.replace(/\.html$/, '');
|
|
7
|
+
// Pagefind already maps `index.html` -> `/`, but normalize defensively.
|
|
8
|
+
if (path.endsWith('/index')) path = path.slice(0, -'index'.length);
|
|
9
|
+
if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1);
|
|
10
|
+
if (path === '') path = '/';
|
|
11
|
+
return path + anchor;
|
|
12
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ensureIndex } from './pagefind-client.svelte';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared palette open state (ported from cca): triggers (header button,
|
|
5
|
+
* `/` and Cmd/Ctrl+K bindings) live in different layout regions from the
|
|
6
|
+
* palette itself.
|
|
7
|
+
*/
|
|
8
|
+
export const searchState = $state({ open: false });
|
|
9
|
+
|
|
10
|
+
export function openSearch(): void {
|
|
11
|
+
searchState.open = true;
|
|
12
|
+
void ensureIndex();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function closeSearch(): void {
|
|
16
|
+
searchState.open = false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function toggleSearch(): void {
|
|
20
|
+
if (searchState.open) closeSearch();
|
|
21
|
+
else openSearch();
|
|
22
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { HTMLButtonAttributes } from 'svelte/elements';
|
|
3
|
+
import { onMount, type Snippet } from 'svelte';
|
|
4
|
+
import { cn } from '$lib/utils';
|
|
5
|
+
|
|
6
|
+
type Props = HTMLButtonAttributes & {
|
|
7
|
+
'aria-label': string;
|
|
8
|
+
active?: boolean;
|
|
9
|
+
children: Snippet;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
let { class: className, active = false, children, type = 'button', ...rest }: Props = $props();
|
|
13
|
+
let ready = $state(false);
|
|
14
|
+
onMount(() => {
|
|
15
|
+
ready = true;
|
|
16
|
+
});
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<button
|
|
20
|
+
{type}
|
|
21
|
+
class={cn(
|
|
22
|
+
'inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted transition-colors hover:bg-surface hover:text-text focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
|
|
23
|
+
active && 'bg-accent-soft text-accent hover:bg-accent-soft hover:text-accent',
|
|
24
|
+
className
|
|
25
|
+
)}
|
|
26
|
+
{...rest}
|
|
27
|
+
disabled={!ready || rest.disabled}
|
|
28
|
+
>
|
|
29
|
+
{@render children()}
|
|
30
|
+
</button>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Moon from '@lucide/svelte/icons/moon';
|
|
3
|
+
import Sun from '@lucide/svelte/icons/sun';
|
|
4
|
+
import { mode, toggleMode } from 'mode-watcher';
|
|
5
|
+
import IconButton from './IconButton.svelte';
|
|
6
|
+
import type { ClassValue } from 'svelte/elements';
|
|
7
|
+
|
|
8
|
+
let { class: className }: { class?: ClassValue } = $props();
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<IconButton
|
|
12
|
+
onclick={toggleMode}
|
|
13
|
+
aria-label="Toggle dark mode"
|
|
14
|
+
title="Toggle theme"
|
|
15
|
+
class={className}
|
|
16
|
+
>
|
|
17
|
+
{#if mode.current === 'dark'}
|
|
18
|
+
<Sun class="size-4" />
|
|
19
|
+
{:else}
|
|
20
|
+
<Moon class="size-4" />
|
|
21
|
+
{/if}
|
|
22
|
+
</IconButton>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as ThemeToggle } from './ThemeToggle.svelte';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import metadata from './site-config.json';
|
|
2
|
+
import authentication from './auth-config.json';
|
|
3
|
+
|
|
4
|
+
/** Consumer branding is copied into the staged engine before any build or dev process. */
|
|
5
|
+
export const site = metadata;
|
|
6
|
+
|
|
7
|
+
/** Only configured server authentication exposes account controls. Static exports have no session API. */
|
|
8
|
+
export const authEnabled =
|
|
9
|
+
authentication.enabled === true && import.meta.env?.VITE_STATIC_EXPORT !== 'true';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface FileCommit {
|
|
2
|
+
id: string;
|
|
3
|
+
author: string;
|
|
4
|
+
date: string;
|
|
5
|
+
subject: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface FileChange extends FileCommit {
|
|
9
|
+
path: string;
|
|
10
|
+
previousPath?: string;
|
|
11
|
+
diff?: string;
|
|
12
|
+
unavailable?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FileHistory {
|
|
16
|
+
revision: string;
|
|
17
|
+
commits: FileChange[];
|
|
18
|
+
blame?: string;
|
|
19
|
+
notice?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface FileBlame {
|
|
23
|
+
commits: Record<string, FileCommit>;
|
|
24
|
+
ranges: [start: number, end: number, commit: string][];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function blameAt(blame: FileBlame, line: number) {
|
|
28
|
+
let low = 0,
|
|
29
|
+
high = blame.ranges.length - 1;
|
|
30
|
+
while (low <= high) {
|
|
31
|
+
const middle = (low + high) >>> 1;
|
|
32
|
+
const range = blame.ranges[middle];
|
|
33
|
+
if (line < range[0]) high = middle - 1;
|
|
34
|
+
else if (line > range[1]) low = middle + 1;
|
|
35
|
+
else return { commit: blame.commits[range[2]], start: range[0] };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface IconVariants {
|
|
2
|
+
light: string;
|
|
3
|
+
dark: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface FolderIconVariants extends IconVariants {
|
|
7
|
+
expanded: IconVariants;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface FileIconTheme {
|
|
11
|
+
file: IconVariants;
|
|
12
|
+
folder: FolderIconVariants;
|
|
13
|
+
files: Record<string, IconVariants>;
|
|
14
|
+
folders: Record<string, FolderIconVariants>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function selectFileIcon(
|
|
18
|
+
theme: FileIconTheme,
|
|
19
|
+
name: string,
|
|
20
|
+
folder = false,
|
|
21
|
+
expanded = false
|
|
22
|
+
): IconVariants {
|
|
23
|
+
const key = name.toLowerCase();
|
|
24
|
+
if (!folder) return Object.hasOwn(theme.files, key) ? theme.files[key] : theme.file;
|
|
25
|
+
const icon = Object.hasOwn(theme.folders, key) ? theme.folders[key] : theme.folder;
|
|
26
|
+
return expanded ? icon.expanded : icon;
|
|
27
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export interface FileTab {
|
|
2
|
+
course: string;
|
|
3
|
+
path: string;
|
|
4
|
+
pinned: boolean;
|
|
5
|
+
top: number;
|
|
6
|
+
left: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function sameFile(a: Pick<FileTab, 'course' | 'path'>, b: Pick<FileTab, 'course' | 'path'>) {
|
|
10
|
+
return a.course === b.course && a.path === b.path;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function openFile(tabs: FileTab[], course: string, path: string, pinned = false): FileTab[] {
|
|
14
|
+
const existing = tabs.findIndex((tab) => sameFile(tab, { course, path }));
|
|
15
|
+
if (existing >= 0)
|
|
16
|
+
return tabs.map((tab, i) => (i === existing && pinned ? { ...tab, pinned: true } : tab));
|
|
17
|
+
const next = { course, path, pinned, top: 0, left: 0 };
|
|
18
|
+
const preview = tabs.findIndex((tab) => !tab.pinned);
|
|
19
|
+
return preview < 0 ? [...tabs, next] : tabs.map((tab, i) => (i === preview ? next : tab));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function restoreTabs(value: string | null): FileTab[] {
|
|
23
|
+
try {
|
|
24
|
+
const parsed: unknown = JSON.parse(value ?? '[]');
|
|
25
|
+
if (!Array.isArray(parsed)) return [];
|
|
26
|
+
const tabs: FileTab[] = [];
|
|
27
|
+
for (const tab of parsed.slice(0, 100)) {
|
|
28
|
+
if (
|
|
29
|
+
!tab ||
|
|
30
|
+
typeof tab.course !== 'string' ||
|
|
31
|
+
typeof tab.path !== 'string' ||
|
|
32
|
+
!tab.course ||
|
|
33
|
+
!tab.path ||
|
|
34
|
+
tab.course.length > 200 ||
|
|
35
|
+
tab.path.length > 2000
|
|
36
|
+
)
|
|
37
|
+
continue;
|
|
38
|
+
if (tabs.some((existing) => sameFile(existing, tab))) continue;
|
|
39
|
+
tabs.push({
|
|
40
|
+
course: tab.course,
|
|
41
|
+
path: tab.path,
|
|
42
|
+
pinned: tab.pinned === true || tabs.some((item) => !item.pinned),
|
|
43
|
+
top: Number.isFinite(tab.top) ? Math.max(0, tab.top) : 0,
|
|
44
|
+
left: Number.isFinite(tab.left) ? Math.max(0, tab.left) : 0
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return tabs;
|
|
48
|
+
} catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function moveTab(tabs: FileTab[], tab: FileTab, destination: number): FileTab[] {
|
|
54
|
+
const index = tabs.findIndex((item) => sameFile(item, tab));
|
|
55
|
+
if (index < 0 || !Number.isInteger(destination)) return tabs;
|
|
56
|
+
if (index === destination && tabs[index].pinned) return tabs;
|
|
57
|
+
const reordered = [...tabs];
|
|
58
|
+
const [moved] = reordered.splice(index, 1);
|
|
59
|
+
reordered.splice(Math.max(0, Math.min(destination, reordered.length)), 0, {
|
|
60
|
+
...moved,
|
|
61
|
+
pinned: true
|
|
62
|
+
});
|
|
63
|
+
return reordered;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function tabLabel(tab: FileTab, tabs: FileTab[]) {
|
|
67
|
+
const name = tab.path.split('/').at(-1)!;
|
|
68
|
+
const duplicate = tabs.some(
|
|
69
|
+
(other) => !sameFile(tab, other) && other.path.split('/').at(-1) === name
|
|
70
|
+
);
|
|
71
|
+
return duplicate ? `${name} · ${tab.course}/${tab.path.split('/').slice(0, -1).join('/')}` : name;
|
|
72
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export interface CourseFile {
|
|
2
|
+
locked?: boolean;
|
|
3
|
+
license?: import('./content-license').ContentLicense;
|
|
4
|
+
path: string;
|
|
5
|
+
size: number;
|
|
6
|
+
kind: 'text' | 'image' | 'pdf' | 'binary';
|
|
7
|
+
download?: string;
|
|
8
|
+
history?: string;
|
|
9
|
+
rmdPreview?: string;
|
|
10
|
+
note?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface RmdPreview {
|
|
14
|
+
title: string;
|
|
15
|
+
html: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface FileNode {
|
|
19
|
+
name: string;
|
|
20
|
+
path: string;
|
|
21
|
+
children?: FileNode[];
|
|
22
|
+
file?: CourseFile;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function fileRoute(course: string, path = '') {
|
|
26
|
+
return `/${encodeURIComponent(course)}/files${path ? '/' + path.split('/').map(encodeURIComponent).join('/') : ''}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function fileIndexUrl(course: string) {
|
|
30
|
+
return `/_files/index/${encodeURIComponent(course)}.json`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function fileTree(files: CourseFile[]): FileNode[] {
|
|
34
|
+
const root: FileNode[] = [];
|
|
35
|
+
for (const file of files) {
|
|
36
|
+
const segments = file.path.split('/');
|
|
37
|
+
let children = root;
|
|
38
|
+
for (let i = 0; i < segments.length; i++) {
|
|
39
|
+
const name = segments[i];
|
|
40
|
+
let node = children.find((entry) => entry.name === name);
|
|
41
|
+
if (!node) {
|
|
42
|
+
node = { name, path: segments.slice(0, i + 1).join('/') };
|
|
43
|
+
children.push(node);
|
|
44
|
+
}
|
|
45
|
+
if (i === segments.length - 1) node.file = file;
|
|
46
|
+
else children = node.children ??= [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function sort(nodes: FileNode[]) {
|
|
50
|
+
nodes.sort(
|
|
51
|
+
(a, b) =>
|
|
52
|
+
Number(!!b.children) - Number(!!a.children) ||
|
|
53
|
+
a.name.localeCompare(b.name, undefined, { numeric: true })
|
|
54
|
+
);
|
|
55
|
+
for (const node of nodes) if (node.children) sort(node.children);
|
|
56
|
+
}
|
|
57
|
+
sort(root);
|
|
58
|
+
return root;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function directoryEntries(tree: FileNode[], path: string): FileNode[] | undefined {
|
|
62
|
+
let entries: FileNode[] | undefined = tree;
|
|
63
|
+
for (const segment of path.split('/').filter(Boolean)) {
|
|
64
|
+
entries = entries?.find((node) => node.name === segment)?.children;
|
|
65
|
+
}
|
|
66
|
+
return entries;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function fileSize(bytes: number) {
|
|
70
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
71
|
+
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
72
|
+
return `${(bytes / 1048576).toFixed(1)} MB`;
|
|
73
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export const githubUsername = /^[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?$/i;
|
|
2
|
+
type Lookup = { user: { id: string; login: string } } | { status: number; error: string };
|
|
3
|
+
|
|
4
|
+
export async function lookupGithubUser(
|
|
5
|
+
value: FormDataEntryValue | null,
|
|
6
|
+
fetcher = fetch
|
|
7
|
+
): Promise<Lookup> {
|
|
8
|
+
const username = typeof value === 'string' ? value.trim().replace(/^@/, '') : '';
|
|
9
|
+
if (!githubUsername.test(username))
|
|
10
|
+
return { status: 400, error: 'Enter a valid GitHub username.' };
|
|
11
|
+
try {
|
|
12
|
+
const response = await fetcher(`https://api.github.com/users/${encodeURIComponent(username)}`, {
|
|
13
|
+
headers: {
|
|
14
|
+
Accept: 'application/vnd.github+json',
|
|
15
|
+
'X-GitHub-Api-Version': '2026-03-10'
|
|
16
|
+
},
|
|
17
|
+
credentials: 'omit',
|
|
18
|
+
redirect: 'error',
|
|
19
|
+
signal: AbortSignal.timeout(5000)
|
|
20
|
+
});
|
|
21
|
+
if (response.status === 404)
|
|
22
|
+
return { status: 404, error: 'That GitHub account was not found.' };
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
console.warn('GitHub user lookup rejected', {
|
|
25
|
+
status: response.status,
|
|
26
|
+
requestId: response.headers.get('x-github-request-id'),
|
|
27
|
+
rateLimitRemaining: response.headers.get('x-ratelimit-remaining'),
|
|
28
|
+
rateLimitReset: response.headers.get('x-ratelimit-reset'),
|
|
29
|
+
retryAfter: response.headers.get('retry-after')
|
|
30
|
+
});
|
|
31
|
+
const rateLimited =
|
|
32
|
+
response.status === 429 ||
|
|
33
|
+
(response.status === 403 &&
|
|
34
|
+
(response.headers.get('x-ratelimit-remaining') === '0' ||
|
|
35
|
+
response.headers.has('retry-after')));
|
|
36
|
+
return {
|
|
37
|
+
status: 503,
|
|
38
|
+
error: rateLimited
|
|
39
|
+
? 'GitHub has temporarily rate-limited account lookups. Please try again later.'
|
|
40
|
+
: `GitHub lookup failed (HTTP ${response.status}). Please try again later.`
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const profile = (await response.json()) as { id?: number; login?: string; type?: string };
|
|
44
|
+
if (
|
|
45
|
+
!Number.isSafeInteger(profile.id) ||
|
|
46
|
+
profile.id! <= 0 ||
|
|
47
|
+
typeof profile.login !== 'string' ||
|
|
48
|
+
!githubUsername.test(profile.login) ||
|
|
49
|
+
profile.type !== 'User'
|
|
50
|
+
) {
|
|
51
|
+
return {
|
|
52
|
+
status: 400,
|
|
53
|
+
error: 'Choose a personal GitHub account, not an organization or bot.'
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return { user: { id: String(profile.id), login: profile.login } };
|
|
57
|
+
} catch (cause) {
|
|
58
|
+
const kind = cause instanceof Error ? cause.name : 'UnknownError';
|
|
59
|
+
console.warn('GitHub user lookup failed', { kind });
|
|
60
|
+
return {
|
|
61
|
+
status: 503,
|
|
62
|
+
error:
|
|
63
|
+
kind === 'TimeoutError' || kind === 'AbortError'
|
|
64
|
+
? 'GitHub lookup timed out. Please try again.'
|
|
65
|
+
: kind === 'SyntaxError'
|
|
66
|
+
? 'GitHub returned an unreadable response. Please try again later.'
|
|
67
|
+
: 'Could not connect to GitHub for account lookup. Please try again later.'
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { site } from './config';
|
|
2
|
+
import { socialImageUrl } from './social-image';
|
|
3
|
+
import type { ManifestPage } from './types';
|
|
4
|
+
|
|
5
|
+
export interface ContentAuthor {
|
|
6
|
+
name: string;
|
|
7
|
+
url?: string;
|
|
8
|
+
type: 'Person' | 'Organization';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function configuredCurator(): ContentAuthor | undefined {
|
|
12
|
+
return site.author.name.trim()
|
|
13
|
+
? {
|
|
14
|
+
name: site.author.name,
|
|
15
|
+
...(site.author.url ? { url: site.author.url } : {}),
|
|
16
|
+
type: 'Person'
|
|
17
|
+
}
|
|
18
|
+
: undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** UI fallback follows the consumer-provided author, when configured. */
|
|
22
|
+
export const curator = configuredCurator();
|
|
23
|
+
|
|
24
|
+
export function contentAuthor(value: unknown): ContentAuthor | undefined {
|
|
25
|
+
if (typeof value === 'string')
|
|
26
|
+
return value.trim() ? { name: value.trim(), type: 'Person' } : undefined;
|
|
27
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return;
|
|
28
|
+
const author = value as Record<string, unknown>;
|
|
29
|
+
if (typeof author.name !== 'string' || !author.name.trim()) return;
|
|
30
|
+
let url: string | undefined;
|
|
31
|
+
if (typeof author.url === 'string') {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = new URL(author.url);
|
|
34
|
+
if (['https:', 'http:'].includes(parsed.protocol)) url = parsed.href;
|
|
35
|
+
} catch {}
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
name: author.name.trim(),
|
|
39
|
+
type: author.type === 'Organization' ? 'Organization' : 'Person',
|
|
40
|
+
...(url ? { url } : {})
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function pageAuthor(
|
|
45
|
+
page: Pick<ManifestPage, 'slug' | 'license'> & { author?: ContentAuthor }
|
|
46
|
+
) {
|
|
47
|
+
return (
|
|
48
|
+
page.author ??
|
|
49
|
+
(/^[^/]+\/README$/.test(page.slug) && !page.license ? configuredCurator() : undefined)
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function breadcrumbs(route: string, title: string) {
|
|
54
|
+
const segments = route
|
|
55
|
+
.replace(/^\/|\/$/g, '')
|
|
56
|
+
.split('/')
|
|
57
|
+
.filter(Boolean);
|
|
58
|
+
if (!segments.length) return [];
|
|
59
|
+
return [
|
|
60
|
+
{ label: 'Home', href: '/' },
|
|
61
|
+
...segments.map((segment, index) => ({
|
|
62
|
+
label: index === segments.length - 1 ? title : segment,
|
|
63
|
+
href: '/' + segments.slice(0, index + 1).join('/')
|
|
64
|
+
}))
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function canonicalUrl(route: string) {
|
|
69
|
+
return new URL('/' + route.replace(/^\/+/, ''), site.url).href;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function serializeSchema(schema: Record<string, unknown>) {
|
|
73
|
+
return JSON.stringify(schema)
|
|
74
|
+
.replaceAll('<', '\\u003c')
|
|
75
|
+
.replaceAll('>', '\\u003e')
|
|
76
|
+
.replaceAll('&', '\\u0026');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function personSchema(author: ContentAuthor) {
|
|
80
|
+
return { '@type': author.type, name: author.name, ...(author.url ? { url: author.url } : {}) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function collectionSchema(route: string, title: string, description: string) {
|
|
84
|
+
const url = canonicalUrl(route);
|
|
85
|
+
const website = {
|
|
86
|
+
'@context': 'https://schema.org',
|
|
87
|
+
'@type': 'WebSite',
|
|
88
|
+
'@id': `${site.url}/#website`,
|
|
89
|
+
url: `${site.url}/`,
|
|
90
|
+
name: site.name,
|
|
91
|
+
description: site.description,
|
|
92
|
+
inLanguage: site.language,
|
|
93
|
+
...(configuredCurator() ? { publisher: personSchema(configuredCurator()!) } : {})
|
|
94
|
+
};
|
|
95
|
+
const collection = {
|
|
96
|
+
'@context': 'https://schema.org',
|
|
97
|
+
'@type': 'CollectionPage',
|
|
98
|
+
'@id': `${url}#webpage`,
|
|
99
|
+
url,
|
|
100
|
+
name: title,
|
|
101
|
+
image: socialImageUrl(),
|
|
102
|
+
description,
|
|
103
|
+
inLanguage: site.language,
|
|
104
|
+
isPartOf: { '@id': website['@id'] }
|
|
105
|
+
};
|
|
106
|
+
return [website, collection, ...breadcrumbSchema(route, title)];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function breadcrumbSchema(route: string, title: string) {
|
|
110
|
+
const trail = breadcrumbs(route, title);
|
|
111
|
+
return trail.length
|
|
112
|
+
? [
|
|
113
|
+
{
|
|
114
|
+
'@context': 'https://schema.org',
|
|
115
|
+
'@type': 'BreadcrumbList',
|
|
116
|
+
itemListElement: trail.map((crumb, index) => ({
|
|
117
|
+
'@type': 'ListItem',
|
|
118
|
+
position: index + 1,
|
|
119
|
+
name: crumb.label,
|
|
120
|
+
item: canonicalUrl(crumb.href)
|
|
121
|
+
}))
|
|
122
|
+
}
|
|
123
|
+
]
|
|
124
|
+
: [];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function noteSchema(route: string, page: Omit<ManifestPage, 'backlinks'>) {
|
|
128
|
+
if (page.locked || !page.publication.public) return [];
|
|
129
|
+
const url = canonicalUrl(route);
|
|
130
|
+
const author = pageAuthor(page);
|
|
131
|
+
const collection =
|
|
132
|
+
page.slug === 'index' || page.slug.endsWith('/index') || /^[^/]+\/README$/.test(page.slug);
|
|
133
|
+
const schema = {
|
|
134
|
+
'@context': 'https://schema.org',
|
|
135
|
+
'@type': collection ? 'CollectionPage' : 'Article',
|
|
136
|
+
'@id': `${url}#${collection ? 'webpage' : 'article'}`,
|
|
137
|
+
url,
|
|
138
|
+
name: page.title,
|
|
139
|
+
image: [socialImageUrl(page.slug)],
|
|
140
|
+
headline: page.title,
|
|
141
|
+
description: page.description,
|
|
142
|
+
mainEntityOfPage: url,
|
|
143
|
+
inLanguage: site.language,
|
|
144
|
+
isPartOf: { '@id': `${site.url}/#website` },
|
|
145
|
+
...(author ? { author: personSchema(author) } : {}),
|
|
146
|
+
...(configuredCurator() ? { editor: personSchema(configuredCurator()!) } : {}),
|
|
147
|
+
...(page.dates.created ? { dateCreated: page.dates.created } : {}),
|
|
148
|
+
...(page.dates.modified ? { dateModified: page.dates.modified } : {}),
|
|
149
|
+
...(page.dates.published ? { datePublished: page.dates.published } : {}),
|
|
150
|
+
...(page.tags.length ? { keywords: page.tags } : {}),
|
|
151
|
+
...(!collection ? { wordCount: page.wordCount } : {}),
|
|
152
|
+
isAccessibleForFree: true,
|
|
153
|
+
...(page.license
|
|
154
|
+
? {
|
|
155
|
+
license: page.license.url,
|
|
156
|
+
creditText: page.license.attribution,
|
|
157
|
+
isBasedOn: page.license.source
|
|
158
|
+
}
|
|
159
|
+
: {})
|
|
160
|
+
};
|
|
161
|
+
return [
|
|
162
|
+
collectionSchema('', site.name, site.description)[0],
|
|
163
|
+
schema,
|
|
164
|
+
...breadcrumbSchema(route, page.title)
|
|
165
|
+
];
|
|
166
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { fileRoute, type CourseFile } from './files';
|
|
2
|
+
import type { NavNode } from './types';
|
|
3
|
+
|
|
4
|
+
/** Add executable worksheets to Notes without treating datasets as notes. */
|
|
5
|
+
export function addNotebookNavigation(nav: NavNode[], course: string, files: CourseFile[]) {
|
|
6
|
+
const notebooks = files.filter((file) => /\.rmd$/i.test(file.path));
|
|
7
|
+
if (!notebooks.length) return;
|
|
8
|
+
let root = nav.find((node) => node.segment === course);
|
|
9
|
+
if (!root) {
|
|
10
|
+
root = { segment: course, title: course, route: fileRoute(course), children: [] };
|
|
11
|
+
nav.push(root);
|
|
12
|
+
}
|
|
13
|
+
for (const file of notebooks) {
|
|
14
|
+
const parts = file.path.split('/');
|
|
15
|
+
let children = root.children;
|
|
16
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
17
|
+
const segment = parts[i];
|
|
18
|
+
let folder = children.find((node) => node.segment === segment);
|
|
19
|
+
if (!folder) {
|
|
20
|
+
folder = {
|
|
21
|
+
segment,
|
|
22
|
+
title: segment.charAt(0).toUpperCase() + segment.slice(1),
|
|
23
|
+
route: fileRoute(course, parts.slice(0, i + 1).join('/')),
|
|
24
|
+
notebook: true,
|
|
25
|
+
children: []
|
|
26
|
+
};
|
|
27
|
+
children.push(folder);
|
|
28
|
+
}
|
|
29
|
+
children = folder.children;
|
|
30
|
+
}
|
|
31
|
+
const segment = parts.at(-1)!;
|
|
32
|
+
if (!children.some((node) => node.segment === segment))
|
|
33
|
+
children.push({
|
|
34
|
+
segment,
|
|
35
|
+
title:
|
|
36
|
+
segment
|
|
37
|
+
.replace(/\.rmd$/i, '')
|
|
38
|
+
.replace(/[-_]/g, ' ')
|
|
39
|
+
.replace(/^./, (c) => c.toUpperCase()) + ' (R worksheet)',
|
|
40
|
+
route: fileRoute(course, file.path),
|
|
41
|
+
notebook: true,
|
|
42
|
+
locked: file.locked,
|
|
43
|
+
children: []
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
function sort(nodes: NavNode[]) {
|
|
47
|
+
nodes.sort(
|
|
48
|
+
(a, b) =>
|
|
49
|
+
Number(!!b.children.length) - Number(!!a.children.length) ||
|
|
50
|
+
a.title.localeCompare(b.title, undefined, { numeric: true })
|
|
51
|
+
);
|
|
52
|
+
for (const node of nodes) sort(node.children);
|
|
53
|
+
}
|
|
54
|
+
sort(root.children);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function isNotebookRoute(nodes: NavNode[], path: string): boolean {
|
|
58
|
+
return nodes.some(
|
|
59
|
+
(node) =>
|
|
60
|
+
(node.notebook && decodeURI(node.route ?? '') === path.replace(/\/$/, '')) ||
|
|
61
|
+
isNotebookRoute(node.children, path)
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const publicEdition = import.meta.env.VITE_PUBLIC_EDITION === 'true';
|