@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.
Files changed (225) hide show
  1. package/.github/actions/product-cache/action.yml +32 -0
  2. package/.github/actions/product-cache/index.mjs +450 -0
  3. package/LICENSE +21 -0
  4. package/README.md +146 -2
  5. package/bun.lock +1701 -0
  6. package/cli/runtime.test.ts +420 -0
  7. package/cli/runtime.ts +385 -0
  8. package/cli/twiki.ts +305 -0
  9. package/package.json +138 -4
  10. package/site/_headers +7 -0
  11. package/site/assets/fonts/OFL.txt +93 -0
  12. package/site/assets/fonts/OverusedGrotesk-SemiBold.ttf +0 -0
  13. package/site/assets/social-background.svg +38 -0
  14. package/site/bun.lock +1701 -0
  15. package/site/database/index.ts +7 -0
  16. package/site/database/schema.ts +95 -0
  17. package/site/drizzle.config.ts +7 -0
  18. package/site/migrations/0001_auth.sql +14 -0
  19. package/site/migrations/0002_site_access.sql +5 -0
  20. package/site/migrations/0003_github_username.sql +1 -0
  21. package/site/migrations/meta/0002_snapshot.json +432 -0
  22. package/site/migrations/meta/0003_snapshot.json +439 -0
  23. package/site/migrations/meta/_journal.json +27 -0
  24. package/site/package.json +120 -0
  25. package/site/src/app.d.ts +14 -0
  26. package/site/src/app.html +11 -0
  27. package/site/src/hooks.server.ts +12 -0
  28. package/site/src/lib/analytics-config.json +1 -0
  29. package/site/src/lib/analytics.ts +40 -0
  30. package/site/src/lib/auth-config.json +3 -0
  31. package/site/src/lib/components/SEO.svelte +113 -0
  32. package/site/src/lib/components/doc/AccessLink.svelte +27 -0
  33. package/site/src/lib/components/doc/Backlinks.svelte +59 -0
  34. package/site/src/lib/components/doc/Breadcrumbs.svelte +29 -0
  35. package/site/src/lib/components/doc/ContentMeta.svelte +61 -0
  36. package/site/src/lib/components/doc/DocPager.svelte +91 -0
  37. package/site/src/lib/components/doc/DocShell.svelte +115 -0
  38. package/site/src/lib/components/doc/LicenseNotice.svelte +26 -0
  39. package/site/src/lib/components/doc/LockedContent.svelte +29 -0
  40. package/site/src/lib/components/doc/NavTree.svelte +158 -0
  41. package/site/src/lib/components/doc/NoteActions.svelte +139 -0
  42. package/site/src/lib/components/doc/PublicationBadge.svelte +49 -0
  43. package/site/src/lib/components/doc/ReaderModeToggle.svelte +19 -0
  44. package/site/src/lib/components/doc/Sidebar.svelte +351 -0
  45. package/site/src/lib/components/doc/Toc.svelte +339 -0
  46. package/site/src/lib/components/doc/enhancements.ts +146 -0
  47. package/site/src/lib/components/doc/heading-tracker.svelte.ts +39 -0
  48. package/site/src/lib/components/doc/image-preview.ts +114 -0
  49. package/site/src/lib/components/doc/reader-mode.svelte.ts +40 -0
  50. package/site/src/lib/components/embeds/mermaid.ts +353 -0
  51. package/site/src/lib/components/files/FileCode.svelte +81 -0
  52. package/site/src/lib/components/files/FileExplorer.svelte +74 -0
  53. package/site/src/lib/components/files/FileHistory.svelte +170 -0
  54. package/site/src/lib/components/files/FileIcon.svelte +28 -0
  55. package/site/src/lib/components/files/FileRmd.svelte +209 -0
  56. package/site/src/lib/components/files/FileTabs.svelte +180 -0
  57. package/site/src/lib/components/files/FileTree.svelte +67 -0
  58. package/site/src/lib/components/files/blame-gutter.ts +45 -0
  59. package/site/src/lib/components/files/code-view.ts +98 -0
  60. package/site/src/lib/components/files/file-workspace.svelte.ts +100 -0
  61. package/site/src/lib/components/graph/GraphPanel.svelte +275 -0
  62. package/site/src/lib/components/graph/GraphView.svelte +100 -0
  63. package/site/src/lib/components/graph/graph-data.ts +137 -0
  64. package/site/src/lib/components/graph/graph-model.ts +103 -0
  65. package/site/src/lib/components/graph/index.ts +3 -0
  66. package/site/src/lib/components/graph/render-graph.ts +626 -0
  67. package/site/src/lib/components/popover/index.ts +1 -0
  68. package/site/src/lib/components/popover/link-popovers.ts +233 -0
  69. package/site/src/lib/components/popover/popover.css +82 -0
  70. package/site/src/lib/components/search/SearchButton.svelte +57 -0
  71. package/site/src/lib/components/search/SearchPalette.svelte +189 -0
  72. package/site/src/lib/components/search/index.ts +3 -0
  73. package/site/src/lib/components/search/pagefind-client.svelte.ts +129 -0
  74. package/site/src/lib/components/search/pagefind-query.ts +22 -0
  75. package/site/src/lib/components/search/pagefind-url.ts +12 -0
  76. package/site/src/lib/components/search/search-state.svelte.ts +22 -0
  77. package/site/src/lib/components/ui/IconButton.svelte +30 -0
  78. package/site/src/lib/components/ui/ThemeToggle.svelte +22 -0
  79. package/site/src/lib/components/ui/index.ts +1 -0
  80. package/site/src/lib/config.ts +9 -0
  81. package/site/src/lib/content-license.ts +7 -0
  82. package/site/src/lib/file-history.ts +37 -0
  83. package/site/src/lib/file-icons.ts +27 -0
  84. package/site/src/lib/file-tabs.ts +72 -0
  85. package/site/src/lib/files.ts +73 -0
  86. package/site/src/lib/github-user.ts +70 -0
  87. package/site/src/lib/metadata.ts +166 -0
  88. package/site/src/lib/notebook-nav.ts +63 -0
  89. package/site/src/lib/publication.ts +1 -0
  90. package/site/src/lib/rmd.ts +156 -0
  91. package/site/src/lib/server/access-admin.ts +33 -0
  92. package/site/src/lib/server/content-model.ts +253 -0
  93. package/site/src/lib/server/content-provider.ts +266 -0
  94. package/site/src/lib/server/content.ts +277 -0
  95. package/site/src/lib/server/runtime-content.ts +34 -0
  96. package/site/src/lib/site-config.json +9 -0
  97. package/site/src/lib/social-image.ts +13 -0
  98. package/site/src/lib/styles/article.css +607 -0
  99. package/site/src/lib/styles/foundation.css +138 -0
  100. package/site/src/lib/styles/print.css +115 -0
  101. package/site/src/lib/styles/shell.css +182 -0
  102. package/site/src/lib/text-exports.ts +5 -0
  103. package/site/src/lib/types.ts +140 -0
  104. package/site/src/lib/utils.ts +6 -0
  105. package/site/src/routes/+error.svelte +16 -0
  106. package/site/src/routes/+layout.server.ts +10 -0
  107. package/site/src/routes/+layout.svelte +72 -0
  108. package/site/src/routes/+layout.ts +3 -0
  109. package/site/src/routes/404/+page.svelte +13 -0
  110. package/site/src/routes/[...slug]/+page.server.ts +24 -0
  111. package/site/src/routes/[...slug]/+page.svelte +180 -0
  112. package/site/src/routes/[course]/files/[...file]/+page.svelte +373 -0
  113. package/site/src/routes/[course]/files/[...file]/+page.ts +38 -0
  114. package/site/src/routes/admin/access/+page.server.ts +53 -0
  115. package/site/src/routes/admin/access/+page.svelte +118 -0
  116. package/site/src/routes/graph.json/+server.ts +7 -0
  117. package/site/src/routes/index.xml/+server.ts +7 -0
  118. package/site/src/routes/layout.css +10 -0
  119. package/site/src/routes/robots.txt/+server.ts +9 -0
  120. package/site/src/routes/sitemap.xml/+server.ts +7 -0
  121. package/site/src/routes/tags/+page.server.ts +5 -0
  122. package/site/src/routes/tags/+page.svelte +38 -0
  123. package/site/src/routes/tags/[...tag]/+page.server.ts +22 -0
  124. package/site/src/routes/tags/[...tag]/+page.svelte +52 -0
  125. package/site/static/favicon.png +0 -0
  126. package/site/static/fonts/JetBrainsMono-Italic-VF.woff2 +0 -0
  127. package/site/static/fonts/JetBrainsMono-VF.woff2 +0 -0
  128. package/site/static/fonts/OverusedGrotesk-VF.woff2 +0 -0
  129. package/site/static/fonts/OverusedGroteskItalic-VF.woff2 +0 -0
  130. package/site/static/fonts/Virgil.woff2 +0 -0
  131. package/site/svelte.config.js +71 -0
  132. package/site/tooling/LICENSE-quartz +25 -0
  133. package/site/tooling/adapter.js +55 -0
  134. package/site/tooling/assets.ts +323 -0
  135. package/site/tooling/benchmark.ts +191 -0
  136. package/site/tooling/build.ts +407 -0
  137. package/site/tooling/cache.ts +133 -0
  138. package/site/tooling/check-diagrams.ts +164 -0
  139. package/site/tooling/check-links.ts +86 -0
  140. package/site/tooling/check-targets.ts +139 -0
  141. package/site/tooling/compiler.ts +1386 -0
  142. package/site/tooling/deploy.ts +112 -0
  143. package/site/tooling/fingerprint.ts +81 -0
  144. package/site/tooling/lib/app-version.js +92 -0
  145. package/site/tooling/lib/application-asset-urls.ts +27 -0
  146. package/site/tooling/lib/application-cache.ts +269 -0
  147. package/site/tooling/lib/asset-hash-cache.ts +116 -0
  148. package/site/tooling/lib/autotag.ts +99 -0
  149. package/site/tooling/lib/build-assembly.ts +148 -0
  150. package/site/tooling/lib/build-workspace.ts +386 -0
  151. package/site/tooling/lib/cache-selection.ts +70 -0
  152. package/site/tooling/lib/cloudflare-prewarm.js +40 -0
  153. package/site/tooling/lib/comments.ts +32 -0
  154. package/site/tooling/lib/content-cache.ts +244 -0
  155. package/site/tooling/lib/content-urls.ts +50 -0
  156. package/site/tooling/lib/course-cache.ts +298 -0
  157. package/site/tooling/lib/course-files.ts +281 -0
  158. package/site/tooling/lib/currency.ts +33 -0
  159. package/site/tooling/lib/deployment-config.ts +222 -0
  160. package/site/tooling/lib/deployment-manifest.ts +274 -0
  161. package/site/tooling/lib/deployment-prepare.ts +325 -0
  162. package/site/tooling/lib/deployment-upload.ts +293 -0
  163. package/site/tooling/lib/dev-git-state.ts +130 -0
  164. package/site/tooling/lib/dev-history.ts +86 -0
  165. package/site/tooling/lib/dev-social-images.ts +38 -0
  166. package/site/tooling/lib/dev-state.ts +107 -0
  167. package/site/tooling/lib/edition-builds.ts +184 -0
  168. package/site/tooling/lib/edition-paths.js +29 -0
  169. package/site/tooling/lib/empty-tag-route.js +23 -0
  170. package/site/tooling/lib/file-history.ts +201 -0
  171. package/site/tooling/lib/file-icons.ts +93 -0
  172. package/site/tooling/lib/file-policy.ts +38 -0
  173. package/site/tooling/lib/file-search.js +13 -0
  174. package/site/tooling/lib/git-revisions.ts +41 -0
  175. package/site/tooling/lib/history-pool.ts +74 -0
  176. package/site/tooling/lib/history-worker.ts +22 -0
  177. package/site/tooling/lib/knit-rmd.R +38 -0
  178. package/site/tooling/lib/lastmod.ts +201 -0
  179. package/site/tooling/lib/markdown-files.ts +51 -0
  180. package/site/tooling/lib/ofm.ts +634 -0
  181. package/site/tooling/lib/output.ts +60 -0
  182. package/site/tooling/lib/product-cache.ts +1006 -0
  183. package/site/tooling/lib/project-config.ts +288 -0
  184. package/site/tooling/lib/public-assets.ts +38 -0
  185. package/site/tooling/lib/public-links.ts +56 -0
  186. package/site/tooling/lib/publishing.ts +156 -0
  187. package/site/tooling/lib/r-runtime.ts +257 -0
  188. package/site/tooling/lib/rebuild-queue.ts +25 -0
  189. package/site/tooling/lib/rmd-preflight.ts +166 -0
  190. package/site/tooling/lib/rmd-preview-cache.ts +93 -0
  191. package/site/tooling/lib/rmd-previews.ts +129 -0
  192. package/site/tooling/lib/runtime-data-modules.ts +30 -0
  193. package/site/tooling/lib/runtime-packaging.ts +321 -0
  194. package/site/tooling/lib/search-cache.js +165 -0
  195. package/site/tooling/lib/search-inputs.js +129 -0
  196. package/site/tooling/lib/slug.ts +377 -0
  197. package/site/tooling/lib/social-images.ts +210 -0
  198. package/site/tooling/lib/text-exports.ts +51 -0
  199. package/site/tooling/lib/visit-elements.ts +17 -0
  200. package/site/tooling/live-test.ts +138 -0
  201. package/site/tooling/pipeline.ts +103 -0
  202. package/site/tooling/prepare.ts +3 -0
  203. package/site/tooling/publish.ts +28 -0
  204. package/site/tooling/r-cache.ts +45 -0
  205. package/site/tooling/r-render-profile.json +7 -0
  206. package/site/tooling/search.js +94 -0
  207. package/site/tooling/verify-deployment.ts +109 -0
  208. package/site/tooling/vite-content.ts +240 -0
  209. package/site/tooling/wrangler-paths.js +25 -0
  210. package/site/tooling/wrangler.ts +19 -0
  211. package/site/tsconfig.json +16 -0
  212. package/site/vite.config.ts +40 -0
  213. package/site/worker/access.ts +57 -0
  214. package/site/worker/analytics.ts +100 -0
  215. package/site/worker/auth-routes.ts +78 -0
  216. package/site/worker/auth.ts +78 -0
  217. package/site/worker/content-runtime.ts +226 -0
  218. package/site/worker/framing.ts +4 -0
  219. package/site/worker/gate.ts +147 -0
  220. package/site/worker/index.ts +9 -0
  221. package/site/worker/login.ts +62 -0
  222. package/site/worker/publication.ts +44 -0
  223. package/site/worker/runtime.ts +104 -0
  224. package/site/worker/tsconfig.json +13 -0
  225. 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,7 @@
1
+ export interface ContentLicense {
2
+ name: string;
3
+ url: string;
4
+ attribution: string;
5
+ source: string;
6
+ changes?: string;
7
+ }
@@ -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';