@twango/twiki 0.0.0-stage → 0.1.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,377 @@
1
+ /**
2
+ * URL and wikilink utilities ported from the Wisconsin Quartz fork's path.ts.
3
+ * Ambiguous links prefer the longest shared directory prefix, then the
4
+ * shallowest path, then lexicographic order.
5
+ */
6
+ import { slug as slugAnchor } from 'github-slugger';
7
+ import type { Element as HastElement } from 'hast';
8
+
9
+ const clone = <T>(x: T): T => structuredClone(x);
10
+
11
+ /// Utility type to simulate nominal types in TypeScript
12
+ type SlugLike<T> = string & { __brand: T };
13
+
14
+ /** Cannot be relative and must have a file extension. */
15
+ export type FilePath = SlugLike<'filepath'>;
16
+ export function isFilePath(s: string): s is FilePath {
17
+ const validStart = !s.startsWith('.');
18
+ return validStart && _hasFileExtension(s);
19
+ }
20
+
21
+ /** Cannot be relative and may not have leading or trailing slashes. It can have `index` as it's last segment. Use this wherever possible is it's the most 'general' interpretation of a slug. */
22
+ export type FullSlug = SlugLike<'full'>;
23
+ export function isFullSlug(s: string): s is FullSlug {
24
+ const validStart = !(s.startsWith('.') || s.startsWith('/'));
25
+ const validEnding = !s.endsWith('/');
26
+ return validStart && validEnding && !containsForbiddenCharacters(s);
27
+ }
28
+
29
+ /** Shouldn't be a relative path and shouldn't have `/index` as an ending or a file extension. It _can_ however have a trailing slash to indicate a folder path. */
30
+ export type SimpleSlug = SlugLike<'simple'>;
31
+ export function isSimpleSlug(s: string): s is SimpleSlug {
32
+ const validStart = !(s.startsWith('.') || (s.length > 1 && s.startsWith('/')));
33
+ const validEnding = !endsWith(s, 'index');
34
+ return validStart && !containsForbiddenCharacters(s) && validEnding && !_hasFileExtension(s);
35
+ }
36
+
37
+ /** Can be found on `href`s but can also be constructed for client-side navigation (e.g. search and graph) */
38
+ export type RelativeURL = SlugLike<'relative'>;
39
+ export function isRelativeURL(s: string): s is RelativeURL {
40
+ const validStart = /^\.{1,2}/.test(s);
41
+ const validEnding = !endsWith(s, 'index');
42
+ return validStart && validEnding && !['.md', '.html'].includes(getFileExtension(s) ?? '');
43
+ }
44
+
45
+ export function isAbsoluteURL(s: string): boolean {
46
+ try {
47
+ new URL(s);
48
+ } catch {
49
+ return false;
50
+ }
51
+ return true;
52
+ }
53
+
54
+ export function sluggify(s: string): string {
55
+ return s
56
+ .split('/')
57
+ .map((segment) =>
58
+ segment
59
+ .replace(/\s/g, '-')
60
+ .replace(/&/g, '-and-')
61
+ .replace(/%/g, '-percent')
62
+ .replace(/\?/g, '')
63
+ .replace(/#/g, '')
64
+ )
65
+ .join('/') // always use / as sep
66
+ .replace(/\/$/, '');
67
+ }
68
+
69
+ export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
70
+ fp = stripSlashes(fp) as FilePath;
71
+ let ext = getFileExtension(fp);
72
+ const withoutFileExt = fp.replace(new RegExp(ext + '$'), '');
73
+ if (excludeExt || ['.md', '.html', undefined].includes(ext)) {
74
+ ext = '';
75
+ }
76
+
77
+ let slug = sluggify(withoutFileExt);
78
+
79
+ // treat _index as index
80
+ if (endsWith(slug, '_index')) {
81
+ slug = slug.replace(/_index$/, 'index');
82
+ }
83
+
84
+ return (slug + ext) as FullSlug;
85
+ }
86
+
87
+ export function simplifySlug(fp: FullSlug): SimpleSlug {
88
+ const res = stripSlashes(trimSuffix(fp, 'index'), true);
89
+ return (res.length === 0 ? '/' : res) as SimpleSlug;
90
+ }
91
+
92
+ export function transformInternalLink(link: string): RelativeURL {
93
+ let [fplike, anchor] = splitAnchor(decodeURI(link));
94
+
95
+ const folderPath = isFolderPath(fplike);
96
+ let segments = fplike.split('/').filter((x) => x.length > 0);
97
+ let prefix = segments.filter(isRelativeSegment).join('/');
98
+ let fp = segments.filter((seg) => !isRelativeSegment(seg) && seg !== '').join('/');
99
+
100
+ // manually add ext here as we want to not strip 'index' if it has an extension
101
+ const simpleSlug = simplifySlug(slugifyFilePath(fp as FilePath));
102
+ const joined = joinSegments(stripSlashes(prefix), stripSlashes(simpleSlug));
103
+ const trail = folderPath ? '/' : '';
104
+ const res = (_addRelativeToStart(joined) + trail + anchor) as RelativeURL;
105
+ return res;
106
+ }
107
+
108
+ const _rebaseHastElement = (
109
+ el: HastElement,
110
+ attr: string,
111
+ curBase: FullSlug,
112
+ newBase: FullSlug
113
+ ) => {
114
+ if (el.properties?.[attr]) {
115
+ if (!isRelativeURL(String(el.properties[attr]))) {
116
+ return;
117
+ }
118
+
119
+ const rel = joinSegments(
120
+ resolveRelative(curBase, newBase),
121
+ '..',
122
+ el.properties[attr] as string
123
+ );
124
+ el.properties[attr] = rel;
125
+ }
126
+ };
127
+
128
+ export function normalizeHastElement(rawEl: HastElement, curBase: FullSlug, newBase: FullSlug) {
129
+ const el = clone(rawEl); // clone so we dont modify the original page
130
+ _rebaseHastElement(el, 'src', curBase, newBase);
131
+ _rebaseHastElement(el, 'href', curBase, newBase);
132
+ if (el.children) {
133
+ el.children = el.children.map((child) =>
134
+ normalizeHastElement(child as HastElement, curBase, newBase)
135
+ );
136
+ }
137
+
138
+ return el;
139
+ }
140
+
141
+ // resolve /a/b/c to ../..
142
+ export function pathToRoot(slug: FullSlug): RelativeURL {
143
+ let rootPath = slug
144
+ .split('/')
145
+ .filter((x) => x !== '')
146
+ .slice(0, -1)
147
+ .map((_) => '..')
148
+ .join('/');
149
+
150
+ if (rootPath.length === 0) {
151
+ rootPath = '.';
152
+ }
153
+
154
+ return rootPath as RelativeURL;
155
+ }
156
+
157
+ export function resolveRelative(current: FullSlug, target: FullSlug | SimpleSlug): RelativeURL {
158
+ const res = joinSegments(pathToRoot(current), simplifySlug(target as FullSlug)) as RelativeURL;
159
+ return res;
160
+ }
161
+
162
+ export function splitAnchor(link: string): [string, string] {
163
+ let [fp, anchor] = link.split('#', 2);
164
+ if (fp.endsWith('.pdf')) {
165
+ return [fp, anchor === undefined ? '' : `#${anchor}`];
166
+ }
167
+ anchor = anchor === undefined ? '' : '#' + slugAnchor(anchor);
168
+ return [fp, anchor];
169
+ }
170
+
171
+ export function slugTag(tag: string) {
172
+ return tag
173
+ .split('/')
174
+ .map((tagSegment) => sluggify(tagSegment))
175
+ .join('/');
176
+ }
177
+
178
+ export function joinSegments(...args: string[]): string {
179
+ if (args.length === 0) {
180
+ return '';
181
+ }
182
+
183
+ let joined = args
184
+ .filter((segment) => segment !== '' && segment !== '/')
185
+ .map((segment) => stripSlashes(segment))
186
+ .join('/');
187
+
188
+ // if the first segment starts with a slash, add it back
189
+ if (args[0].startsWith('/')) {
190
+ joined = '/' + joined;
191
+ }
192
+
193
+ // if the last segment is a folder, add a trailing slash
194
+ if (args[args.length - 1].endsWith('/')) {
195
+ joined = joined + '/';
196
+ }
197
+
198
+ return joined;
199
+ }
200
+
201
+ export function getAllSegmentPrefixes(tags: string): string[] {
202
+ const segments = tags.split('/');
203
+ const results: string[] = [];
204
+ for (let i = 0; i < segments.length; i++) {
205
+ results.push(segments.slice(0, i + 1).join('/'));
206
+ }
207
+ return results;
208
+ }
209
+
210
+ export interface TransformOptions {
211
+ strategy: 'absolute' | 'relative' | 'shortest';
212
+ allSlugs: readonly FullSlug[];
213
+ slugIndex?: {
214
+ files: ReadonlyMap<string, readonly FullSlug[]>;
215
+ folders: ReadonlyMap<string, readonly FullSlug[]>;
216
+ };
217
+ }
218
+
219
+ /** Index each path suffix once for a corpus snapshot, preserving candidate order.
220
+ * Create new options when the corpus changes; no index survives between builds.
221
+ */
222
+ export function createTransformOptions(
223
+ allSlugs: readonly FullSlug[],
224
+ strategy: TransformOptions['strategy'] = 'shortest'
225
+ ): TransformOptions {
226
+ const options: TransformOptions = { strategy, allSlugs: Object.freeze([...allSlugs]) };
227
+ if (strategy !== 'shortest') return options;
228
+ const files = new Map<string, FullSlug[]>();
229
+ const folders = new Map<string, FullSlug[]>();
230
+ const add = (index: Map<string, FullSlug[]>, candidate: string, slug: FullSlug) => {
231
+ while (true) {
232
+ const matches = index.get(candidate);
233
+ if (matches) matches.push(slug);
234
+ else index.set(candidate, [slug]);
235
+ const slash = candidate.indexOf('/');
236
+ if (slash < 0) break;
237
+ candidate = candidate.slice(slash + 1);
238
+ }
239
+ };
240
+ for (const slug of options.allSlugs) {
241
+ add(files, slug, slug);
242
+ add(folders, stripSlashes(simplifySlug(slug)), slug);
243
+ }
244
+ options.slugIndex = { files, folders };
245
+ return options;
246
+ }
247
+
248
+ export function transformLink(src: FullSlug, target: string, opts: TransformOptions): RelativeURL {
249
+ // Explicit relative links are relative to the source note, not the vault root.
250
+ if (opts.strategy !== 'relative' && /^\.\.?\//.test(target)) {
251
+ const [relativePath, anchor] = splitAnchor(decodeURI(target));
252
+ const parts = src.split('/').slice(0, -1);
253
+ for (const part of relativePath.split('/')) {
254
+ if (part === '..') parts.pop();
255
+ else if (part && part !== '.') parts.push(part);
256
+ }
257
+ const resolved = slugifyFilePath(parts.join('/') as FilePath);
258
+ return (resolveRelative(src, resolved) + anchor) as RelativeURL;
259
+ }
260
+ let targetSlug = transformInternalLink(target);
261
+
262
+ if (opts.strategy === 'relative') {
263
+ return targetSlug as RelativeURL;
264
+ } else {
265
+ const folderTail = isFolderPath(splitAnchor(targetSlug)[0]) ? '/' : '';
266
+ const canonicalSlug = stripSlashes(targetSlug.slice('.'.length));
267
+ let [targetCanonical, targetAnchor] = splitAnchor(canonicalSlug);
268
+ targetCanonical = stripSlashes(targetCanonical);
269
+
270
+ if (opts.strategy === 'shortest') {
271
+ // match by full path suffix on a "/" boundary; for basename-only targets
272
+ // this is equivalent to matching the last path segment, and for
273
+ // path-prefix wikilinks like [[folder/file]] it finds slugs whose path
274
+ // ends in folder/file (mirrors Obsidian's shortest-path resolution).
275
+ const matchingFileNames = opts.slugIndex
276
+ ? ((folderTail ? opts.slugIndex.folders : opts.slugIndex.files).get(targetCanonical) ?? [])
277
+ : opts.allSlugs.filter((slug) => {
278
+ const candidate = folderTail ? stripSlashes(simplifySlug(slug)) : slug;
279
+ return candidate === targetCanonical || candidate.endsWith('/' + targetCanonical);
280
+ });
281
+
282
+ // only match, just use it
283
+ if (matchingFileNames.length === 1) {
284
+ const targetSlug = matchingFileNames[0];
285
+ return (resolveRelative(src, targetSlug) + targetAnchor) as RelativeURL;
286
+ }
287
+
288
+ // multiple matches: mirror Obsidian's "nearest" resolution.
289
+ // Prefer the candidate with the longest shared directory prefix to src;
290
+ // break ties by shallowest path, then lexicographically for stability.
291
+ if (matchingFileNames.length > 1) {
292
+ const srcDir = src.split('/').slice(0, -1);
293
+ const best = matchingFileNames
294
+ .map((slug) => {
295
+ const dir = slug.split('/').slice(0, -1);
296
+ let shared = 0;
297
+ while (
298
+ shared < srcDir.length &&
299
+ shared < dir.length &&
300
+ srcDir[shared] === dir[shared]
301
+ ) {
302
+ shared++;
303
+ }
304
+ return { slug, shared, depth: dir.length };
305
+ })
306
+ .sort(
307
+ (a, b) => b.shared - a.shared || a.depth - b.depth || a.slug.localeCompare(b.slug)
308
+ )[0];
309
+ return (resolveRelative(src, best.slug) + targetAnchor) as RelativeURL;
310
+ }
311
+ }
312
+
313
+ // if it's not unique, then it's the absolute path from the vault root
314
+ return (joinSegments(pathToRoot(src), canonicalSlug) + folderTail) as RelativeURL;
315
+ }
316
+ }
317
+
318
+ // path helpers
319
+ export function isFolderPath(fplike: string): boolean {
320
+ return (
321
+ fplike.endsWith('/') ||
322
+ endsWith(fplike, 'index') ||
323
+ endsWith(fplike, 'index.md') ||
324
+ endsWith(fplike, 'index.html')
325
+ );
326
+ }
327
+
328
+ export function endsWith(s: string, suffix: string): boolean {
329
+ return s === suffix || s.endsWith('/' + suffix);
330
+ }
331
+
332
+ export function trimSuffix(s: string, suffix: string): string {
333
+ if (endsWith(s, suffix)) {
334
+ s = s.slice(0, -suffix.length);
335
+ }
336
+ return s;
337
+ }
338
+
339
+ function containsForbiddenCharacters(s: string): boolean {
340
+ return s.includes(' ') || s.includes('#') || s.includes('?') || s.includes('&');
341
+ }
342
+
343
+ function _hasFileExtension(s: string): boolean {
344
+ return getFileExtension(s) !== undefined;
345
+ }
346
+
347
+ export function getFileExtension(s: string): string | undefined {
348
+ return s.match(/\.[A-Za-z0-9]+$/)?.[0];
349
+ }
350
+
351
+ function isRelativeSegment(s: string): boolean {
352
+ return /^\.{0,2}$/.test(s);
353
+ }
354
+
355
+ export function stripSlashes(s: string, onlyStripPrefix?: boolean): string {
356
+ if (s.startsWith('/')) {
357
+ s = s.substring(1);
358
+ }
359
+
360
+ if (!onlyStripPrefix && s.endsWith('/')) {
361
+ s = s.slice(0, -1);
362
+ }
363
+
364
+ return s;
365
+ }
366
+
367
+ function _addRelativeToStart(s: string): string {
368
+ if (s === '') {
369
+ s = '.';
370
+ }
371
+
372
+ if (!s.startsWith('.')) {
373
+ s = joinSegments('.', s);
374
+ }
375
+
376
+ return s;
377
+ }
@@ -0,0 +1,210 @@
1
+ import satori from 'satori';
2
+ import { stageFingerprint } from '../fingerprint';
3
+ import { Resvg } from '@resvg/resvg-js';
4
+ import { createHash } from 'node:crypto';
5
+ import {
6
+ copyFileSync,
7
+ existsSync,
8
+ mkdirSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ rmSync,
12
+ writeFileSync
13
+ } from 'node:fs';
14
+ import path from 'node:path';
15
+ import { staticDirectory } from './edition-paths.js';
16
+ import type { ContentManifest, ManifestPage } from '../../src/lib/types';
17
+ import { site } from '../../src/lib/config';
18
+ import { socialImagePath, socialImageSize } from '../../src/lib/social-image';
19
+ import { writeChanged } from './output';
20
+
21
+ const fontPath = path.resolve(
22
+ import.meta.dirname,
23
+ '../../assets/fonts/OverusedGrotesk-SemiBold.ttf'
24
+ );
25
+ const font = readFileSync(fontPath);
26
+ const background = readFileSync(
27
+ path.resolve(import.meta.dirname, '../../assets/social-background.svg')
28
+ );
29
+ const backgroundImage = `data:image/svg+xml;base64,${background.toString('base64')}`;
30
+ const rendererKey = await stageFingerprint('social');
31
+
32
+ interface SocialCard {
33
+ title: string;
34
+ subtitle?: string;
35
+ label: string;
36
+ }
37
+
38
+ function shorten(text: string, length: number) {
39
+ const normalized = text.replace(/\s+/g, ' ').trim();
40
+ return normalized.length <= length ? normalized : normalized.slice(0, length - 1).trimEnd() + '…';
41
+ }
42
+
43
+ export async function renderSocialImage(card: SocialCard) {
44
+ const title = shorten(card.title, 200);
45
+ const svg = await satori(
46
+ {
47
+ type: 'div',
48
+ props: {
49
+ style: {
50
+ position: 'relative',
51
+ overflow: 'hidden',
52
+ display: 'flex',
53
+ flexDirection: 'column',
54
+ width: '100%',
55
+ height: '100%',
56
+ background: '#1a1916',
57
+ color: '#e8e5df',
58
+ padding: '64px 72px',
59
+ fontFamily: 'Overused Grotesk',
60
+ fontWeight: 600
61
+ },
62
+ children: [
63
+ {
64
+ type: 'img',
65
+ props: {
66
+ src: backgroundImage,
67
+ width: socialImageSize.width,
68
+ height: socialImageSize.height,
69
+ style: { position: 'absolute', top: 0, left: 0 }
70
+ }
71
+ },
72
+ {
73
+ type: 'div',
74
+ props: {
75
+ style: { color: '#e68578', fontSize: 24 },
76
+ children: shorten(card.label, 70)
77
+ }
78
+ },
79
+ {
80
+ type: 'div',
81
+ props: {
82
+ style: {
83
+ display: 'flex',
84
+ flex: 1,
85
+ flexDirection: 'column',
86
+ justifyContent: 'center'
87
+ },
88
+ children: [
89
+ {
90
+ type: 'div',
91
+ props: {
92
+ style: {
93
+ fontSize: title.length > 130 ? 48 : title.length > 80 ? 60 : 80,
94
+ lineHeight: 1.08,
95
+ letterSpacing: '-1.5px',
96
+ overflowWrap: 'anywhere'
97
+ },
98
+ children: title
99
+ }
100
+ },
101
+ {
102
+ type: 'div',
103
+ props: {
104
+ style: { marginTop: 24, fontSize: 26, color: '#7a7568' },
105
+ children: shorten(card.subtitle ?? '', 90)
106
+ }
107
+ }
108
+ ]
109
+ }
110
+ },
111
+ {
112
+ type: 'div',
113
+ props: {
114
+ style: {
115
+ display: 'flex',
116
+ justifyContent: 'space-between',
117
+ fontSize: 20,
118
+ color: '#7a7568'
119
+ },
120
+ children: [
121
+ { type: 'span', props: { children: site.name } },
122
+ { type: 'span', props: { children: new URL(site.url).host } }
123
+ ]
124
+ }
125
+ }
126
+ ]
127
+ }
128
+ },
129
+ {
130
+ ...socialImageSize,
131
+ fonts: [{ name: 'Overused Grotesk', data: font, weight: 600, style: 'normal' }]
132
+ }
133
+ );
134
+ return new Resvg(svg, { font: { loadSystemFonts: false } }).render().asPng();
135
+ }
136
+
137
+ function courseLabel(slug: string) {
138
+ const course = slug.split('/')[0];
139
+ const match = /^(sp|su|fa|wi)(\d{2})-(.+)$/.exec(course);
140
+ if (!match) return course === 'des-inv' ? 'Design Innovation Lab' : 'Course notes';
141
+ const term = { sp: 'Spring', su: 'Summer', fa: 'Fall', wi: 'Winter' }[match[1]];
142
+ const code = match[3].replace(/([a-z])(\d)/, '$1 $2').toUpperCase();
143
+ return `${code} · ${term} 20${match[2]}`;
144
+ }
145
+
146
+ export function socialCard(
147
+ page: Pick<ManifestPage, 'slug' | 'title' | 'heading' | 'relativePath'>
148
+ ) {
149
+ return {
150
+ slug: page.slug,
151
+ title: page.heading?.trim() || page.title,
152
+ subtitle: path.posix.basename(page.relativePath),
153
+ label: courseLabel(page.slug)
154
+ };
155
+ }
156
+
157
+ export const defaultSocialCard = {
158
+ slug: undefined,
159
+ title: site.title,
160
+ label: 'Notes'
161
+ };
162
+
163
+ export async function cachedSocialImage(siteDir: string, card: SocialCard & { slug?: string }) {
164
+ const cache = path.join(siteDir, 'build/generated/cache/social-titles');
165
+ mkdirSync(cache, { recursive: true });
166
+ const key = createHash('sha256').update(rendererKey).update(JSON.stringify(card)).digest('hex');
167
+ const cached = path.join(cache, key + '.png');
168
+ const rendered = !existsSync(cached);
169
+ if (rendered) writeFileSync(cached, await renderSocialImage(card));
170
+ return { cached, rendered };
171
+ }
172
+
173
+ export async function buildSocialImages(siteDir: string, manifest: Pick<ContentManifest, 'pages'>) {
174
+ const output = path.join(staticDirectory(siteDir), '_og');
175
+ mkdirSync(output, { recursive: true });
176
+ const wanted = new Set<string>();
177
+ const current = new Set<string>();
178
+ let rendered = 0;
179
+ const cards = [defaultSocialCard, ...Object.values(manifest.pages).map(socialCard)];
180
+ for (const card of cards) {
181
+ const result = await cachedSocialImage(siteDir, card);
182
+ const cached = result.cached;
183
+ current.add(
184
+ path.relative(path.join(siteDir, 'build/generated/cache'), cached).split(path.sep).join('/')
185
+ );
186
+ if (result.rendered) rendered++;
187
+ const destination = path.join(
188
+ staticDirectory(siteDir),
189
+ decodeURIComponent(socialImagePath(card.slug))
190
+ );
191
+ mkdirSync(path.dirname(destination), { recursive: true });
192
+ wanted.add(destination);
193
+ if (!existsSync(destination) || !readFileSync(destination).equals(readFileSync(cached)))
194
+ copyFileSync(cached, destination);
195
+ }
196
+ for (const file of readdirSync(output, { recursive: true, withFileTypes: true })) {
197
+ const filename = path.join(file.parentPath, file.name);
198
+ if (file.isFile() && !wanted.has(filename)) rmSync(filename);
199
+ }
200
+ writeChanged(
201
+ path.join(
202
+ siteDir,
203
+ `build/generated/cache/social-titles-current-${process.env.VITE_PUBLIC_EDITION === 'true' ? 'public' : 'full'}.json`
204
+ ),
205
+ JSON.stringify([...current].sort())
206
+ );
207
+ console.log(
208
+ `social images: ${cards.length} title-only cards (${rendered} rendered, ${cards.length - rendered} cached)`
209
+ );
210
+ }
@@ -0,0 +1,51 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { writeChanged } from './output';
3
+ import path from 'node:path';
4
+ import type { PageDoc } from '../../src/lib/types';
5
+ import { site } from '../../src/lib/config';
6
+ import { textExportUrl } from '../../src/lib/text-exports';
7
+
8
+ /** Called separately for each edition; locked outlines never become text exports. */
9
+ export function writeTextExports(directory: string, pages: PageDoc[], assets = new Set<string>()) {
10
+ const written = new Set<string>();
11
+ const index = [
12
+ `# ${site.title}`,
13
+ '',
14
+ `> ${site.description}`,
15
+ '',
16
+ 'Notes are available as UTF-8 Markdown (.md) and plain-text Markdown (.txt).',
17
+ 'Obsidian wikilinks and LaTeX math are preserved. Restricted notes require site access.',
18
+ '',
19
+ '## Notes',
20
+ ''
21
+ ];
22
+ for (const page of [...pages].sort((a, b) => a.slug.localeCompare(b.slug))) {
23
+ if (page.locked || page.markdown === undefined) continue;
24
+ const route = page.slug === 'index' ? '' : page.slug.replace(/\/index$/, '');
25
+ const source = `${site.url}/${route.split('/').map(encodeURIComponent).join('/')}`;
26
+ const text = `# ${page.title}\n\nSource: ${source}\n\n${page.markdown.trim()}\n`;
27
+ for (const format of ['md', 'txt'] as const) {
28
+ const file = path.join(
29
+ directory,
30
+ decodeURIComponent(textExportUrl(page.slug, format)).slice(1)
31
+ );
32
+ if (
33
+ assets.has(path.relative(directory, file)) ||
34
+ written.has(path.relative(directory, file)) ||
35
+ path.relative(directory, file) === 'llms.txt'
36
+ )
37
+ throw new Error(`Text export conflicts with an existing asset: ${page.slug}.${format}`);
38
+ if (Buffer.byteLength(text) > 25 * 1024 * 1024)
39
+ throw new Error(`Text export exceeds deployment size limit: ${page.slug}`);
40
+ mkdirSync(path.dirname(file), { recursive: true });
41
+ writeChanged(file, text);
42
+ written.add(path.relative(directory, file));
43
+ }
44
+ const title = page.title.replace(/[\r\n]/g, ' ').replace(/[\\[\]]/g, '\\$&');
45
+ index.push(`- [${title}](${site.url}${textExportUrl(page.slug)})`);
46
+ }
47
+ if (assets.has('llms.txt')) throw new Error('Text export conflicts with llms.txt');
48
+ writeChanged(path.join(directory, 'llms.txt'), index.join('\n') + '\n');
49
+ written.add('llms.txt');
50
+ return written;
51
+ }
@@ -0,0 +1,17 @@
1
+ import type { Element, Root } from 'hast';
2
+ import { SKIP } from 'unist-util-visit';
3
+
4
+ /** Preorder HAST traversal for visitors that only mutate the current element.
5
+ * Child mutations are visited immediately; SKIP prunes the current subtree.
6
+ * Sibling replacement and index/ancestor callbacks still need unist-util-visit.
7
+ */
8
+ export function visitElements(
9
+ tree: Root | Element,
10
+ visitor: (node: Element) => void | typeof SKIP
11
+ ) {
12
+ if (tree.type === 'element' && visitor(tree) === SKIP) return;
13
+ for (let index = 0; index < tree.children.length; index++) {
14
+ const child = tree.children[index];
15
+ if (child.type === 'element') visitElements(child, visitor);
16
+ }
17
+ }