@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,156 @@
1
+ import wikiLink from '@flowershow/remark-wiki-link';
2
+ import { slug } from 'github-slugger';
3
+ import { unified } from 'unified';
4
+ import remarkParse from 'remark-parse';
5
+ import remarkGfm from 'remark-gfm';
6
+ import remarkMath from 'remark-math';
7
+ import remarkRehype from 'remark-rehype';
8
+ import rehypeKatex from 'rehype-katex';
9
+ import { toHtml } from 'hast-util-to-html';
10
+ import { SKIP, visit } from 'unist-util-visit';
11
+ import { parse } from 'yaml';
12
+ import type { Root as HtmlRoot } from 'hast';
13
+ import type { Root, RootContent } from 'mdast';
14
+ import { fileRoute, type CourseFile } from './files';
15
+
16
+ export type RmdBlock =
17
+ | { kind: 'markdown'; html: string }
18
+ | {
19
+ kind: 'r';
20
+ code: string;
21
+ label: string;
22
+ evaluate: boolean;
23
+ show: boolean;
24
+ };
25
+
26
+ export function relativeFile(path: string, target: string) {
27
+ const parts = path.split('/').slice(0, -1);
28
+ for (const part of target.split('/')) {
29
+ if (part === '..') {
30
+ if (!parts.length) return;
31
+ parts.pop();
32
+ } else if (part && part !== '.') parts.push(part);
33
+ }
34
+ return parts.join('/');
35
+ }
36
+
37
+ // Raw HTML is omitted, and URL schemes are restricted before producing HTML.
38
+ export async function parseRmd(source: string, course: string, path: string, files: CourseFile[]) {
39
+ let title = path.split('/').at(-1) ?? 'R Markdown';
40
+ const frontmatter = source.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
41
+ if (frontmatter) {
42
+ const metadata = parse(frontmatter[1]);
43
+ if (typeof metadata?.title === 'string') title = metadata.title;
44
+ source = source.slice(frontmatter[0].length);
45
+ }
46
+ const parser = unified()
47
+ .use(remarkParse)
48
+ .use(remarkGfm)
49
+ .use(remarkMath)
50
+ .use(wikiLink, {
51
+ urlResolver: ({ filePath: target, heading, isEmbed }) => {
52
+ // Flowershow 3.x retains the backslash before table alias separators.
53
+ target = target.replace(/\\$/, '').trim();
54
+ heading = heading.replace(/\\$/, '').trim();
55
+ if (/^(?:[a-z][a-z\d+.-]*:|\/)/i.test(target)) return '';
56
+ if (!target) return heading ? `#${slug(heading)}` : '';
57
+ const qualified = target.startsWith(`${course}/`);
58
+ const local = qualified ? target.slice(course.length + 1) : target;
59
+ const relative = qualified ? local : relativeFile(path, local);
60
+ const candidates = files.filter((file) =>
61
+ [file.path, file.path.replace(/\.md$/i, '')].some(
62
+ (name) =>
63
+ name === local ||
64
+ name === relative ||
65
+ (!local.includes('/') && name.split('/').at(-1) === local)
66
+ )
67
+ );
68
+ const file =
69
+ candidates.find((file) => file.path === local || file.path === relative) ??
70
+ (candidates.length === 1 ? candidates[0] : undefined);
71
+ if (isEmbed && file?.kind === 'image' && !file.locked && file.download)
72
+ return file.download;
73
+ const destination = file
74
+ ? file.note
75
+ ? `/${file.note.replace(/^[/\\\u0000-\u0020\u007f]+/, '')}`
76
+ : fileRoute(course, file.path)
77
+ : `/${course}/${(qualified ? local : (relative ?? local)).replace(/\.md$/i, '')}`;
78
+ return destination + (heading ? `#${slug(heading)}` : '');
79
+ }
80
+ });
81
+ const renderer = unified().use(remarkRehype).use(rehypeKatex);
82
+ const tree = parser.parse(source);
83
+ // Keep our authorization and safe-HTML policy around the library's parsed nodes.
84
+ visit(tree, (node, index, parent) => {
85
+ if (!parent || index === undefined || !['wikiLink', 'embed'].includes(node.type)) return;
86
+ const wiki = node as unknown as {
87
+ type: string;
88
+ value: string;
89
+ data: { path: string; alias?: string };
90
+ };
91
+ const url = wiki.data.path;
92
+ parent.children[index] =
93
+ wiki.type === 'embed' &&
94
+ files.some((file) => file.kind === 'image' && !file.locked && file.download === url)
95
+ ? { type: 'image', url, alt: wiki.data.alias ?? wiki.value }
96
+ : { type: 'link', url, children: [{ type: 'text', value: wiki.data.alias ?? wiki.value }] };
97
+ });
98
+
99
+ // PDF layout directives have no notebook equivalent. Match whole prose
100
+ // paragraphs so inline explanations, code examples, and R chunks survive.
101
+ visit(tree, 'paragraph', (node, index, parent) => {
102
+ const child = node.children[0];
103
+ if (
104
+ parent &&
105
+ index !== undefined &&
106
+ node.children.length === 1 &&
107
+ child.type === 'text' &&
108
+ /^\s*\\(?:vspace\*?\{[^{}\n]+\}|newpage|pagebreak(?:\[[0-4]\])?)\s*$/.test(child.value)
109
+ ) {
110
+ parent.children.splice(index, 1);
111
+ return [SKIP, index];
112
+ }
113
+ });
114
+ visit(tree, (node) => {
115
+ if (node.type !== 'image' && node.type !== 'link' && node.type !== 'definition') return;
116
+ const normalized = node.url.replace(/[\u0000-\u0020\u007f]/g, '');
117
+ if (/^[a-z][a-z\d+.-]*:/i.test(normalized) && !/^(https?:|mailto:)/i.test(normalized)) {
118
+ node.url = '';
119
+ return;
120
+ }
121
+ if (/^(?:[a-z][a-z\d+.-]*:|\/|#)/i.test(normalized)) return;
122
+ const resolved = relativeFile(path, node.url);
123
+ const file = files.find((file) => file.path === resolved);
124
+ if (file)
125
+ node.url =
126
+ node.type === 'image' && !file.locked && file.download
127
+ ? file.download
128
+ : fileRoute(course, file.path);
129
+ });
130
+ const blocks: RmdBlock[] = [];
131
+ let prose: RootContent[] = [];
132
+ async function flush() {
133
+ if (!prose.length) return;
134
+ const tree: Root = { type: 'root', children: prose };
135
+ blocks.push({ kind: 'markdown', html: toHtml((await renderer.run(tree)) as HtmlRoot) });
136
+ prose = [];
137
+ }
138
+ for (const node of tree.children) {
139
+ const header = node.type === 'code' ? `${node.lang ?? ''} ${node.meta ?? ''}`.trim() : '';
140
+ if (node.type === 'code' && /^\{r(?:[\s,}]|$)/i.test(header)) {
141
+ await flush();
142
+ const options = header.slice(2).replace(/}\s*$/, '');
143
+ blocks.push({
144
+ kind: 'r',
145
+ code: node.value,
146
+ label:
147
+ options.split(',')[0].trim() ||
148
+ `Chunk ${blocks.filter((b) => b.kind === 'r').length + 1}`,
149
+ evaluate: !/\beval\s*=\s*(FALSE|F)\b/i.test(options),
150
+ show: !/\b(include|echo)\s*=\s*(FALSE|F)\b/i.test(options)
151
+ });
152
+ } else prose.push(node);
153
+ }
154
+ await flush();
155
+ return { title, blocks };
156
+ }
@@ -0,0 +1,33 @@
1
+ import { error, type RequestEvent } from '@sveltejs/kit';
2
+ import { createAuth, type AuthEnv } from '../../../worker/auth';
3
+ import { isOwner } from '../../../worker/access';
4
+ import { and, eq, inArray } from 'drizzle-orm';
5
+ import { database } from '../../../database';
6
+ import { account, session, siteAccess } from '../../../database/schema';
7
+
8
+ export async function requireOwner(event: RequestEvent) {
9
+ const env = event.platform?.env;
10
+ if (!env) error(503, 'Authentication unavailable');
11
+ const session = await createAuth(env).api.getSession({ headers: event.request.headers });
12
+ if (!session || !(await isOwner(env.DB, session.user.id, env.OWNER_GITHUB_ID))) {
13
+ error(403, 'Only the site owner can manage access.');
14
+ }
15
+ return env;
16
+ }
17
+
18
+ export async function revokeAccess(env: AuthEnv, githubId: string) {
19
+ if (githubId === env.OWNER_GITHUB_ID) error(400, 'Owner access cannot be revoked.');
20
+ const db = database(env.DB);
21
+ await db.batch([
22
+ db.delete(siteAccess).where(eq(siteAccess.githubId, githubId)),
23
+ db.delete(session).where(
24
+ inArray(
25
+ session.userId,
26
+ db
27
+ .select({ userId: account.userId })
28
+ .from(account)
29
+ .where(and(eq(account.providerId, 'github'), eq(account.accountId, githubId)))
30
+ )
31
+ )
32
+ ]);
33
+ }
@@ -0,0 +1,253 @@
1
+ import type {
2
+ ContentManifest,
3
+ FolderEntry,
4
+ FolderListing,
5
+ ManifestPage,
6
+ NavNode,
7
+ TreeNode
8
+ } from '../types';
9
+ import { site } from '../config';
10
+
11
+ export function createContentModel(manifest: ContentManifest) {
12
+ const getManifest = () => manifest;
13
+ /**
14
+ * Canonical slug → display route (no leading/trailing slash; `''` = home).
15
+ * Trailing `index` segments are trimmed from URLs.
16
+ */
17
+ function displayRoute(slug: string): string {
18
+ if (slug === 'index') return '';
19
+ if (slug.endsWith('/index')) return slug.slice(0, -'/index'.length);
20
+ return slug;
21
+ }
22
+
23
+ /** Display route → canonical page slug, or undefined if no page lives there. */
24
+ function pageSlugForRoute(route: string): string | undefined {
25
+ const { pages } = getManifest();
26
+ if (route === '') return Object.hasOwn(pages, 'index') ? 'index' : undefined;
27
+ if (Object.hasOwn(pages, route) && !route.endsWith('/index')) return route;
28
+ if (Object.hasOwn(pages, `${route}/index`)) return `${route}/index`;
29
+ // A literal `…/index` URL is not canonical (the live site never emits it).
30
+ return undefined;
31
+ }
32
+
33
+ /** Folders that have no index page and therefore get a generated listing. */
34
+ function listingFolders(): string[] {
35
+ const m = getManifest();
36
+ return m.folders.filter(
37
+ (f) => !Object.hasOwn(m.pages, `${f}/index`) && !Object.hasOwn(m.pages, f)
38
+ );
39
+ }
40
+
41
+ /**
42
+ * Every route served by the catch-all `[...slug]` page route:
43
+ * all page display routes (including `''` = home) + generated folder listings.
44
+ * Tag routes live under `src/routes/tags/` and are not included here.
45
+ */
46
+ function contentEntries(): string[] {
47
+ const m = getManifest();
48
+ const routes = Object.keys(m.pages).map(displayRoute);
49
+ return [...new Set(['', ...routes, ...listingFolders()])];
50
+ }
51
+
52
+ /** Natural sort, Quartz Explorer-style (numeric, case-insensitive). */
53
+ const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
54
+
55
+ function findTreeNode(folder: string): TreeNode | undefined {
56
+ let node: TreeNode | undefined = getManifest().tree;
57
+ if (folder === '') return node;
58
+ for (const segment of folder.split('/')) {
59
+ node = node?.children.find((c) => c.name === segment);
60
+ if (!node) return undefined;
61
+ }
62
+ return node;
63
+ }
64
+
65
+ function toNavNode(node: TreeNode): NavNode {
66
+ const indexSlug = `${node.slug}/index`;
67
+ const folders = node.children.map(toNavNode).sort((a, b) => collator.compare(a.title, b.title));
68
+ const pages = node.pages
69
+ .filter((p) => p.slug !== indexSlug)
70
+ .map(
71
+ (p): NavNode => ({
72
+ locked: getManifest().pages[p.slug]?.locked,
73
+ title: p.title,
74
+ route: `/${displayRoute(p.slug)}`,
75
+ children: [],
76
+ segment: p.slug.split('/').pop() ?? p.slug
77
+ })
78
+ )
79
+ .sort((a, b) => collator.compare(a.title, b.title));
80
+ return {
81
+ locked: getManifest().pages[indexSlug]?.locked,
82
+ // Explorer shows folder names (matching the live Quartz explorer), not
83
+ // index-page titles — those can be long ("Midterm 1 - Practice Questions").
84
+ title: node.name,
85
+ route: `/${node.slug}`, // every folder has a route: index page or listing
86
+ children: [...folders, ...pages],
87
+ segment: node.name
88
+ };
89
+ }
90
+
91
+ let navCache: NavNode[] | undefined;
92
+
93
+ /** Sidebar Explorer tree: course folders first, then root pages (minus home). */
94
+ function navTree(): NavNode[] {
95
+ if (navCache) return navCache;
96
+ const root = getManifest().tree;
97
+ const folders = root.children.map(toNavNode).sort((a, b) => collator.compare(a.title, b.title));
98
+ const rootPages = root.pages
99
+ .filter((p) => p.slug !== 'index')
100
+ .map(
101
+ (p): NavNode => ({
102
+ title: p.title,
103
+ route: `/${displayRoute(p.slug)}`,
104
+ children: [],
105
+ segment: p.slug
106
+ })
107
+ )
108
+ .sort((a, b) => collator.compare(a.title, b.title));
109
+ navCache = [...folders, ...rootPages];
110
+ return navCache;
111
+ }
112
+
113
+ function countPages(node: TreeNode): number {
114
+ return node.pages.length + node.children.reduce((n, c) => n + countPages(c), 0);
115
+ }
116
+
117
+ /** Listing data for a folder without its own index page. */
118
+ function folderListing(folder: string): FolderListing | undefined {
119
+ const m = getManifest();
120
+ if (folder !== '' && !m.folders.includes(folder)) return undefined;
121
+ const node = findTreeNode(folder);
122
+ if (!node) return undefined;
123
+ const subfolders: FolderEntry[] = node.children
124
+ .map((c) => ({ title: c.name, route: `/${c.slug}`, isFolder: true }))
125
+ .sort((a, b) => collator.compare(a.title, b.title));
126
+ const pages: FolderEntry[] = node.pages
127
+ .filter((p) => p.slug !== `${folder}/index`)
128
+ .map((p) => ({
129
+ title: p.title,
130
+ route: `/${displayRoute(p.slug)}`,
131
+ isFolder: false,
132
+ modified: m.pages[p.slug]?.dates.modified
133
+ }))
134
+ // Quartz folder pages list by date (newest first), then alphabetical.
135
+ .sort(
136
+ (a, b) =>
137
+ Date.parse(b.modified ?? '0') - Date.parse(a.modified ?? '0') ||
138
+ collator.compare(a.title, b.title)
139
+ );
140
+ return {
141
+ folder,
142
+ name: folder === '' ? site.name : (folder.split('/').pop() ?? folder),
143
+ entries: [
144
+ ...subfolders,
145
+ ...pages,
146
+ ...(m.fileCourses?.includes(folder)
147
+ ? [{ title: 'Files', route: `/${folder}/files`, isFolder: true }]
148
+ : [])
149
+ ],
150
+ pageCount: countPages(node)
151
+ };
152
+ }
153
+
154
+ /** All tags (case-sensitive) with page counts, alphabetical. */
155
+ function tagIndex(): { tag: string; count: number }[] {
156
+ const { tags } = getManifest();
157
+ return Object.keys(tags)
158
+ .sort((a, b) => collator.compare(a, b))
159
+ .map((tag) => ({ tag, count: tags[tag].length }));
160
+ }
161
+
162
+ /** Pages for one tag (case-sensitive), newest first. */
163
+ function tagPages(tag: string): ManifestPage[] | undefined {
164
+ const m = getManifest();
165
+ const slugs = m.tags[tag];
166
+ if (!slugs) return undefined;
167
+ return slugs
168
+ .map((s) => m.pages[s])
169
+ .filter((p): p is ManifestPage => !!p)
170
+ .sort(
171
+ (a, b) =>
172
+ Date.parse(b.dates.modified) - Date.parse(a.dates.modified) ||
173
+ collator.compare(a.title, b.title)
174
+ );
175
+ }
176
+
177
+ function escapeXml(s: string): string {
178
+ return s
179
+ .replaceAll('&', '&')
180
+ .replaceAll('<', '&lt;')
181
+ .replaceAll('>', '&gt;')
182
+ .replaceAll('"', '&quot;');
183
+ }
184
+
185
+ function absoluteUrl(route: string): string {
186
+ return route === '' ? `${site.url}/` : `${site.url}/${route}`;
187
+ }
188
+
189
+ /** RSS feed matching the live `/index.xml` shape: latest 10 by modified date. */
190
+ function rssXml(): string {
191
+ const m = getManifest();
192
+ const items = Object.values(m.pages)
193
+ .filter((page) => !page.locked)
194
+ .sort((a, b) => Date.parse(b.dates.modified) - Date.parse(a.dates.modified))
195
+ .slice(0, 10)
196
+ .map((p) => {
197
+ const url = absoluteUrl(displayRoute(p.slug));
198
+ const description = p.description.replaceAll(']]>', ']]&gt;');
199
+ return `<item>
200
+ <title>${escapeXml(p.title)}</title>
201
+ <link>${url}</link>
202
+ <guid>${url}</guid>
203
+ <description><![CDATA[ ${description} ]]></description>
204
+ ${p.dates.published || p.dates.modified ? `<pubDate>${new Date(p.dates.published || p.dates.modified).toUTCString()}</pubDate>` : ''}
205
+ </item>`;
206
+ })
207
+ .join('');
208
+ return `<?xml version="1.0" encoding="UTF-8" ?>
209
+ <rss version="2.0">
210
+ <channel>
211
+ <title>${escapeXml(site.title)}</title>
212
+ <link>${site.url}</link>
213
+ <description>Last 10 notes on ${escapeXml(site.title)}</description>
214
+ <generator>${site.url}</generator>
215
+ ${items}
216
+ </channel>
217
+ </rss>`;
218
+ }
219
+
220
+ /** Sitemap matching the live `/sitemap.xml` shape: every page, ISO lastmod. */
221
+ function sitemapXml(): string {
222
+ const m = getManifest();
223
+ const urls = Object.values(m.pages)
224
+ .filter((page) => !page.locked)
225
+ .map(
226
+ (p) => `<url>
227
+ <loc>${escapeXml(absoluteUrl(displayRoute(p.slug)))}</loc>
228
+ ${p.dates.modified ? `<lastmod>${p.dates.modified}</lastmod>` : ''}
229
+ </url>`
230
+ )
231
+ .join('');
232
+ const folders = m.fileCourses
233
+ ? contentEntries()
234
+ .filter((route) => !pageSlugForRoute(route))
235
+ .map((route) => `<url><loc>${escapeXml(absoluteUrl(route))}</loc></url>`)
236
+ .join('')
237
+ : '';
238
+ return `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls}${folders}</urlset>`;
239
+ }
240
+
241
+ return {
242
+ displayRoute,
243
+ pageSlugForRoute,
244
+ listingFolders,
245
+ contentEntries,
246
+ navTree,
247
+ folderListing,
248
+ tagIndex,
249
+ tagPages,
250
+ rssXml,
251
+ sitemapXml
252
+ };
253
+ }