@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,113 @@
1
+ <script lang="ts">
2
+ import { site } from '$lib/config';
3
+ import { socialImageSize, socialImageUrl } from '$lib/social-image';
4
+ import {
5
+ canonicalUrl as resolveCanonical,
6
+ collectionSchema,
7
+ serializeSchema,
8
+ type ContentAuthor
9
+ } from '$lib/metadata';
10
+
11
+ interface Props {
12
+ title?: string;
13
+ description?: string;
14
+ canonical?: string;
15
+ type?: 'website' | 'article';
16
+ image?: string;
17
+ imageAlt?: string;
18
+ published?: string;
19
+ modified?: string;
20
+ author?: ContentAuthor;
21
+ tags?: string[];
22
+ noindex?: boolean;
23
+ /** JSON-LD structured data (single object or array). */
24
+ jsonLd?: Record<string, unknown> | Record<string, unknown>[];
25
+ }
26
+
27
+ const {
28
+ title,
29
+ description = site.description,
30
+ canonical,
31
+ type = 'website',
32
+ image,
33
+ imageAlt = image ? (title ?? site.name) : site.description,
34
+ published,
35
+ modified,
36
+ author,
37
+ tags = [],
38
+ noindex = false,
39
+ jsonLd
40
+ }: Props = $props();
41
+
42
+ const fullTitle = $derived(title ? `${title} | ${site.name}` : site.name);
43
+ const previewImage = $derived(image ?? socialImageUrl());
44
+ const canonicalUrl = $derived(canonical ? resolveCanonical(canonical) : undefined);
45
+ const schemas = $derived(
46
+ jsonLd ?? (canonical ? collectionSchema(canonical, title ?? site.name, description) : [])
47
+ );
48
+ const jsonLdScripts = $derived(noindex ? [] : Array.isArray(schemas) ? schemas : [schemas]);
49
+
50
+ // Build a <script type="application/ld+json"> tag. Split the literal closing
51
+ // tag so the Svelte parser doesn't terminate this script block early. The
52
+ // payload is our own serialized structured data, not user input.
53
+ function jsonLdTag(schema: Record<string, unknown>): string {
54
+ const open = '<' + 'script type="application/ld+json">';
55
+ const close = '<' + '/script>';
56
+ return open + serializeSchema(schema) + close;
57
+ }
58
+ </script>
59
+
60
+ <svelte:head>
61
+ <title>{fullTitle}</title>
62
+ <meta name="description" content={description} />
63
+ {#if noindex}
64
+ <meta name="robots" content="noindex, nofollow" />
65
+ {:else}
66
+ <meta name="robots" content="max-image-preview:large" />
67
+ {/if}
68
+ {#if canonicalUrl}
69
+ <link rel="canonical" href={canonicalUrl} />
70
+ {/if}
71
+
72
+ <!-- Open Graph -->
73
+ <meta property="og:title" content={fullTitle} />
74
+ <meta property="og:description" content={description} />
75
+ <meta property="og:type" content={type} />
76
+ <meta property="og:site_name" content={site.name} />
77
+ <meta property="og:locale" content={site.locale} />
78
+ {#if canonicalUrl}
79
+ <meta property="og:url" content={canonicalUrl} />
80
+ {/if}
81
+ {#if previewImage}
82
+ <meta property="og:image" content={previewImage} />
83
+ <meta property="og:image:secure_url" content={previewImage} />
84
+ <meta property="og:image:type" content="image/png" />
85
+ <meta property="og:image:width" content={String(socialImageSize.width)} />
86
+ <meta property="og:image:height" content={String(socialImageSize.height)} />
87
+ <meta property="og:image:alt" content={imageAlt} />
88
+ {/if}
89
+ {#if !noindex}
90
+ {#if author}<meta name="author" content={author.name} />{/if}
91
+ {#if published}<meta property="article:published_time" content={published} />{/if}
92
+ {#if modified}<meta property="article:modified_time" content={modified} />{/if}
93
+ {#if type === 'article'}
94
+ {#if author?.url}<meta property="article:author" content={author.url} />{/if}
95
+ {#each tags as tag}<meta property="article:tag" content={tag} />{/each}
96
+ {/if}
97
+ {/if}
98
+
99
+ <!-- Twitter Card -->
100
+ <meta name="twitter:card" content={previewImage ? 'summary_large_image' : 'summary'} />
101
+ <meta name="twitter:title" content={fullTitle} />
102
+ <meta name="twitter:description" content={description} />
103
+ {#if previewImage}
104
+ <meta name="twitter:image" content={previewImage} />
105
+ <meta name="twitter:image:alt" content={imageAlt} />
106
+ {/if}
107
+
108
+ <!-- JSON-LD -->
109
+ {#each jsonLdScripts as schema (schema)}
110
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
111
+ {@html jsonLdTag(schema)}
112
+ {/each}
113
+ </svelte:head>
@@ -0,0 +1,27 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import { authEnabled } from '$lib/config';
4
+ import UsersRound from '@lucide/svelte/icons/users-round';
5
+
6
+ let owner = $state(false);
7
+ onMount(() => {
8
+ if (!authEnabled) return;
9
+ const controller = new AbortController();
10
+ fetch('/api/access', { signal: controller.signal })
11
+ .then(async (response) => {
12
+ if (response.ok) owner = (await response.json()).role === 'owner';
13
+ })
14
+ .catch(() => {});
15
+ return () => controller.abort();
16
+ });
17
+ </script>
18
+
19
+ {#if owner}
20
+ <a
21
+ href="/admin/access"
22
+ aria-label="Manage access"
23
+ title="Manage access"
24
+ class="inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-muted hover:bg-surface hover:text-text focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none"
25
+ ><UsersRound size={16} /></a
26
+ >
27
+ {/if}
@@ -0,0 +1,59 @@
1
+ <script lang="ts">
2
+ // Backlinks panel — pages that link to the current one, styled as
3
+ // cca-aesthetic cards (1px border-border, rounded, surface hover, accent on
4
+ // hover) to match DocPager.
5
+ import CornerDownRight from '@lucide/svelte/icons/corner-down-right';
6
+ import type { BacklinkRef } from '$lib/types';
7
+
8
+ interface Props {
9
+ backlinks: BacklinkRef[];
10
+ }
11
+
12
+ const { backlinks }: Props = $props();
13
+
14
+ /** Backlink slugs are display-form: '/' = home, folder pages end in '/'. */
15
+ function href(slug: string): string {
16
+ if (slug === '/') return '/';
17
+ return '/' + slug.replace(/\/$/, '');
18
+ }
19
+
20
+ /** Parent path of the linking page, shown as context under the title. */
21
+ function context(slug: string): string {
22
+ if (slug === '/') return '';
23
+ const segments = slug.replace(/\/$/, '').split('/');
24
+ return segments.slice(0, -1).join(' / ');
25
+ }
26
+ </script>
27
+
28
+ {#if backlinks.length > 0}
29
+ <section class="mt-8 border-t border-border pt-4" data-island="backlinks" aria-label="Backlinks">
30
+ <h2 class="m-0 mb-3 text-xs font-semibold tracking-[0.08em] text-muted uppercase">
31
+ Linked to this page
32
+ </h2>
33
+ <ul class="m-0 grid list-none grid-cols-2 gap-2 p-0 max-[640px]:grid-cols-1">
34
+ {#each backlinks as link (link.slug)}
35
+ <li class="min-w-0">
36
+ <a
37
+ class="group flex items-center gap-3 rounded-md border border-border px-3 py-2 no-underline transition-colors hover:border-accent hover:bg-surface focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none"
38
+ href={href(link.slug)}
39
+ >
40
+ <CornerDownRight
41
+ class="size-4 shrink-0 text-muted transition-colors group-hover:text-accent"
42
+ aria-hidden="true"
43
+ />
44
+ <span class="min-w-0 flex-1">
45
+ <span
46
+ class="block truncate text-sm leading-snug font-medium text-text group-hover:text-accent"
47
+ >
48
+ {link.title}
49
+ </span>
50
+ {#if context(link.slug)}
51
+ <span class="block truncate text-xs text-muted">{context(link.slug)}</span>
52
+ {/if}
53
+ </span>
54
+ </a>
55
+ </li>
56
+ {/each}
57
+ </ul>
58
+ </section>
59
+ {/if}
@@ -0,0 +1,29 @@
1
+ <script lang="ts">
2
+ import { breadcrumbs } from '$lib/metadata';
3
+ interface Props {
4
+ /** Display route of the current page, no leading slash ('' = home). */
5
+ route: string;
6
+ /** Title for the final (unlinked) crumb. */
7
+ current: string;
8
+ }
9
+
10
+ const { route, current }: Props = $props();
11
+
12
+ const crumbs = $derived(breadcrumbs(route, current).slice(1, -1));
13
+ </script>
14
+
15
+ {#if route !== ''}
16
+ <nav class="mb-3 text-[0.8125rem] text-muted" aria-label="Breadcrumbs">
17
+ <ol class="m-0 flex list-none flex-wrap items-center gap-1 p-0">
18
+ <li><a class="text-muted no-underline hover:text-accent" href="/">Home</a></li>
19
+ {#each crumbs as crumb (crumb.href)}
20
+ <li aria-hidden="true" class="select-none">❯</li>
21
+ <li>
22
+ <a class="text-muted no-underline hover:text-accent" href={crumb.href}>{crumb.label}</a>
23
+ </li>
24
+ {/each}
25
+ <li aria-hidden="true" class="select-none">❯</li>
26
+ <li aria-current="page" class="text-text">{current}</li>
27
+ </ol>
28
+ </nav>
29
+ {/if}
@@ -0,0 +1,61 @@
1
+ <script lang="ts">
2
+ import PublicationBadge from './PublicationBadge.svelte';
3
+ import type { PagePublication } from '$lib/types';
4
+ import type { ContentAuthor } from '$lib/metadata';
5
+ interface Props {
6
+ modified?: string;
7
+ published?: string;
8
+ author?: ContentAuthor;
9
+ curated?: boolean;
10
+ /** Reading time in minutes. */
11
+ readingTime?: number;
12
+ publication?: PagePublication;
13
+ }
14
+
15
+ const {
16
+ modified,
17
+ published,
18
+ author,
19
+ curated = false,
20
+ readingTime,
21
+ publication
22
+ }: Props = $props();
23
+ const publishedLabel = $derived(
24
+ published
25
+ ? new Date(published).toLocaleDateString('en-US', {
26
+ year: 'numeric',
27
+ month: 'short',
28
+ day: 'numeric',
29
+ timeZone: 'UTC'
30
+ })
31
+ : undefined
32
+ );
33
+
34
+ const formatted = $derived(
35
+ modified
36
+ ? new Date(modified).toLocaleDateString('en-US', {
37
+ year: 'numeric',
38
+ month: 'short',
39
+ day: 'numeric',
40
+ timeZone: 'UTC'
41
+ })
42
+ : undefined
43
+ );
44
+ </script>
45
+
46
+ {#if formatted || readingTime || publication}
47
+ <p class="m-0 flex flex-wrap items-center gap-x-2 gap-y-1 text-[0.8125rem] text-muted">
48
+ {#if author}
49
+ <span
50
+ >{curated ? 'Curated by' : 'By'}
51
+ {#if author.url}<a href={author.url} rel="author">{author.name}</a>{:else}{author.name}{/if}
52
+ </span>
53
+ {/if}
54
+ {#if publishedLabel}<span>Published: <time datetime={published}>{publishedLabel}</time></span
55
+ >{/if}
56
+ {#if formatted}<span>Last modified: <time datetime={modified}>{formatted}</time></span>{/if}
57
+ {#if formatted && readingTime}<span aria-hidden="true"> · </span>{/if}
58
+ {#if readingTime}<span>{readingTime} min read</span>{/if}
59
+ {#if publication}<PublicationBadge {publication} />{/if}
60
+ </p>
61
+ {/if}
@@ -0,0 +1,91 @@
1
+ <script lang="ts">
2
+ import { page } from '$app/state';
3
+ import ChevronLeft from '@lucide/svelte/icons/chevron-left';
4
+ import ChevronRight from '@lucide/svelte/icons/chevron-right';
5
+ import type { NavNode } from '$lib/types';
6
+ import { cn } from '$lib/utils';
7
+
8
+ interface Props {
9
+ nav: NavNode[];
10
+ }
11
+
12
+ interface PagerItem {
13
+ title: string;
14
+ route: string;
15
+ }
16
+
17
+ const { nav }: Props = $props();
18
+
19
+ const pages = $derived(flattenNav(nav));
20
+ const currentRoute = $derived(normalizeRoute(page.url.pathname));
21
+ const currentIndex = $derived(pages.findIndex((item) => item.route === currentRoute));
22
+ const previousPage = $derived(currentIndex > 0 ? pages[currentIndex - 1] : undefined);
23
+ const nextPage = $derived(
24
+ currentIndex >= 0 && currentIndex < pages.length - 1 ? pages[currentIndex + 1] : undefined
25
+ );
26
+
27
+ function flattenNav(nodes: NavNode[]): PagerItem[] {
28
+ const items: PagerItem[] = [];
29
+ const seenRoutes = new Set<string>();
30
+
31
+ function visit(node: NavNode): void {
32
+ if (node.route) {
33
+ const route = normalizeRoute(node.route);
34
+ if (!seenRoutes.has(route)) {
35
+ seenRoutes.add(route);
36
+ items.push({ title: node.title, route });
37
+ }
38
+ }
39
+
40
+ for (const child of node.children) visit(child);
41
+ }
42
+
43
+ for (const node of nodes) visit(node);
44
+ return items;
45
+ }
46
+
47
+ /** trailingSlash is 'never' on this site: compare routes without it. */
48
+ function normalizeRoute(path: string): string {
49
+ let normalized = path.startsWith('/') ? path : `/${path}`;
50
+ if (normalized.length > 1 && normalized.endsWith('/')) normalized = normalized.slice(0, -1);
51
+ return normalized;
52
+ }
53
+
54
+ const linkClass =
55
+ 'group flex min-h-16 items-center gap-3 rounded-md border border-border px-4 py-3 no-underline transition-colors hover:border-accent hover:bg-surface focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none';
56
+ const labelClass = 'block text-xs font-semibold uppercase text-muted';
57
+ const titleClass = 'block text-sm font-semibold leading-snug text-text group-hover:text-accent';
58
+ </script>
59
+
60
+ {#if previousPage || nextPage}
61
+ <nav
62
+ class="mt-8 grid grid-cols-2 gap-3 border-t border-border pt-4 max-[640px]:grid-cols-1"
63
+ aria-label="Page navigation"
64
+ >
65
+ {#if previousPage}
66
+ <a class={linkClass} href={previousPage.route}>
67
+ <ChevronLeft class="size-5 shrink-0 text-muted transition-colors group-hover:text-accent" />
68
+ <span class="min-w-0">
69
+ <span class={labelClass}>Previous</span>
70
+ <span class={titleClass}>{previousPage.title}</span>
71
+ </span>
72
+ </a>
73
+ {:else}
74
+ <div class="max-[640px]:hidden" aria-hidden="true"></div>
75
+ {/if}
76
+
77
+ {#if nextPage}
78
+ <a class={cn(linkClass, 'justify-end text-right')} href={nextPage.route}>
79
+ <span class="min-w-0">
80
+ <span class={labelClass}>Next</span>
81
+ <span class={titleClass}>{nextPage.title}</span>
82
+ </span>
83
+ <ChevronRight
84
+ class="size-5 shrink-0 text-muted transition-colors group-hover:text-accent"
85
+ />
86
+ </a>
87
+ {:else}
88
+ <div class="max-[640px]:hidden" aria-hidden="true"></div>
89
+ {/if}
90
+ </nav>
91
+ {/if}
@@ -0,0 +1,115 @@
1
+ <script lang="ts">
2
+ import { afterNavigate } from '$app/navigation';
3
+ import { page } from '$app/state';
4
+ import Menu from '@lucide/svelte/icons/menu';
5
+ import { onMount, type Snippet } from 'svelte';
6
+ import { provideFileWorkspace } from '$lib/components/files/file-workspace.svelte';
7
+ import type { NavNode } from '$lib/types';
8
+ import { site } from '$lib/config';
9
+ import { ThemeToggle } from '$lib/components/ui';
10
+ import IconButton from '../ui/IconButton.svelte';
11
+ import { SearchButton, SearchPalette } from '$lib/components/search';
12
+ import GraphPanel from '$lib/components/graph/GraphPanel.svelte';
13
+ import DocPager from './DocPager.svelte';
14
+ import ReaderModeToggle from './ReaderModeToggle.svelte';
15
+ import Sidebar from './Sidebar.svelte';
16
+ import Toc from './Toc.svelte';
17
+ import { readerMode } from './reader-mode.svelte';
18
+ import { createHeadingTracker } from './heading-tracker.svelte';
19
+
20
+ interface Props {
21
+ nav: NavNode[];
22
+ children: Snippet;
23
+ }
24
+
25
+ const { nav, children }: Props = $props();
26
+ const workspace = provideFileWorkspace();
27
+ onMount(() => workspace.restore());
28
+
29
+ let mobileNavOpen = $state(false);
30
+ let navigationReady = $state(false);
31
+ type RailPageData = { kind?: string };
32
+ const railPageData = $derived(page.data as RailPageData);
33
+ const showContentRail = $derived(railPageData.kind === 'page');
34
+ const heading = createHeadingTracker(() => page.data.toc ?? []);
35
+
36
+ afterNavigate(() => {
37
+ mobileNavOpen = false;
38
+ navigationReady = true;
39
+ });
40
+ </script>
41
+
42
+ <div
43
+ class={[
44
+ 'doc-shell min-h-screen',
45
+ railPageData.kind === 'file-browser' && 'doc-shell-files',
46
+ !showContentRail && 'doc-shell-listing',
47
+ readerMode.enabled && 'doc-shell-reader'
48
+ ]}
49
+ >
50
+ <header
51
+ class="doc-mobile-header sticky top-0 z-30 items-center gap-3 border-b border-border bg-bg px-4 py-2"
52
+ >
53
+ <IconButton
54
+ class="size-8 rounded-md border border-border bg-transparent text-text"
55
+ aria-label="Toggle navigation"
56
+ aria-expanded={mobileNavOpen}
57
+ disabled={!navigationReady}
58
+ onclick={() => (mobileNavOpen = !mobileNavOpen)}
59
+ >
60
+ <Menu class="size-5" />
61
+ </IconButton>
62
+ <a class="font-bold text-text no-underline" href="/">{site.name}</a>
63
+ <div class="ml-auto flex items-center gap-1">
64
+ <SearchButton variant="icon" class="size-8" />
65
+ <ReaderModeToggle class="size-8" />
66
+ <ThemeToggle class="size-8" />
67
+ </div>
68
+ </header>
69
+
70
+ <aside class={['doc-sidebar-left border-r border-border bg-bg', mobileNavOpen && 'doc-nav-open']}>
71
+ <Sidebar {nav} />
72
+ </aside>
73
+
74
+ {#if readerMode.enabled}
75
+ <!-- floating controls so reader mode can be exited once the rails are gone -->
76
+ <div
77
+ class="doc-reader-controls fixed top-3 right-3 z-30 flex items-center gap-1 rounded-lg border border-border bg-bg/90 p-1 backdrop-blur"
78
+ >
79
+ <ReaderModeToggle class="size-8" />
80
+ <ThemeToggle class="size-8" />
81
+ </div>
82
+ {/if}
83
+
84
+ {#if mobileNavOpen}
85
+ <button
86
+ class="doc-nav-backdrop fixed inset-0 z-40 cursor-pointer border-0 bg-black/40"
87
+ type="button"
88
+ aria-label="Close navigation"
89
+ onclick={() => (mobileNavOpen = false)}
90
+ ></button>
91
+ {/if}
92
+
93
+ <main class="doc-main min-w-0" class:doc-main-files={railPageData.kind === 'file-browser'}>
94
+ <div class={railPageData.kind === 'file-browser' ? 'h-full w-full' : 'mx-auto max-w-3xl'}>
95
+ {@render children()}
96
+ <DocPager {nav} />
97
+ </div>
98
+ </main>
99
+
100
+ {#if !readerMode.enabled && showContentRail}
101
+ <aside class="doc-sidebar-right" aria-label="Page tools">
102
+ <div class="doc-sidebar-right-inner">
103
+ <GraphPanel />
104
+ {#if (page.data.toc?.length ?? 0) > 0}
105
+ <details class="doc-compact-outline">
106
+ <summary class="cursor-pointer text-sm text-muted">On this page</summary>
107
+ <Toc active={heading.active} />
108
+ </details>
109
+ {/if}
110
+ <div class="doc-toc"><Toc active={heading.active} /></div>
111
+ </div>
112
+ </aside>
113
+ {/if}
114
+ </div>
115
+ <SearchPalette />
@@ -0,0 +1,26 @@
1
+ <script lang="ts">
2
+ import type { ContentLicense } from '$lib/content-license';
3
+ const { license }: { license: ContentLicense } = $props();
4
+ </script>
5
+
6
+ <svelte:head><link rel="license" href={license.url} /></svelte:head>
7
+
8
+ <aside
9
+ aria-label="Content license"
10
+ class="border-t border-border py-3 text-xs leading-relaxed text-muted"
11
+ data-pagefind-ignore
12
+ >
13
+ <a
14
+ href={license.source}
15
+ class="text-muted underline decoration-border underline-offset-2 hover:text-text"
16
+ >{license.attribution}</a
17
+ >
18
+ ·
19
+ <a
20
+ href={license.url}
21
+ rel="license"
22
+ class="text-muted underline decoration-border underline-offset-2 hover:text-text"
23
+ >{license.name}</a
24
+ >, unless otherwise noted.
25
+ {#if license.changes}<p class="m-0 mt-1">{license.changes}</p>{/if}
26
+ </aside>
@@ -0,0 +1,29 @@
1
+ <script lang="ts">
2
+ import LockKeyhole from '@lucide/svelte/icons/lock-keyhole';
3
+ import { page } from '$app/state';
4
+ import { authEnabled } from '$lib/config';
5
+ </script>
6
+
7
+ <div
8
+ class="flex items-start gap-3 rounded-md border border-border p-4 text-sm"
9
+ data-pagefind-ignore
10
+ >
11
+ <LockKeyhole class="mt-0.5 size-4 shrink-0 text-muted" />
12
+ <div>
13
+ <p class="m-0 font-medium">Content locked</p>
14
+ <p class="mt-1 mb-3 text-muted">
15
+ {#if authEnabled}
16
+ The catalog is public. Reading this content requires approved access.
17
+ {:else}
18
+ This content is unavailable on this site.
19
+ {/if}
20
+ </p>
21
+ {#if authEnabled}
22
+ <a
23
+ data-sveltekit-reload
24
+ class="text-accent hover:underline"
25
+ href={`/login?next=${encodeURIComponent(page.url.pathname)}`}>Sign in to read</a
26
+ >
27
+ {/if}
28
+ </div>
29
+ </div>