@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,288 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { browsablePath } from './file-policy';
5
+ import { writeChanged } from './output';
6
+
7
+ export interface SiteMetadata {
8
+ name: string;
9
+ title: string;
10
+ url: string;
11
+ description: string;
12
+ author: { name: string; url: string; github: string; email: string };
13
+ locale: string;
14
+ language: string;
15
+ }
16
+ export interface ProjectConfig {
17
+ version: 1;
18
+ contentDir?: string;
19
+ discovery?: 'filesystem' | 'git';
20
+ publication?: 'private' | 'public' | 'policies';
21
+ site?: Partial<Omit<SiteMetadata, 'author'>> & { author?: Partial<SiteMetadata['author']> };
22
+ cloudflare?: Record<string, unknown>;
23
+ analytics?: { siteId: string; scriptUrl?: string; endpoint?: string };
24
+ }
25
+ export interface ResolvedProject {
26
+ root: string;
27
+ contentDir: string;
28
+ configFile?: string;
29
+ discovery: 'filesystem' | 'git';
30
+ publication: 'private' | 'public' | 'policies';
31
+ site: SiteMetadata;
32
+ cloudflare?: Record<string, unknown>;
33
+ analytics?: ProjectConfig['analytics'];
34
+ }
35
+ const defaults: SiteMetadata = {
36
+ name: 'twiki',
37
+ title: 'twiki',
38
+ url: 'http://localhost:5173',
39
+ description: 'Notes, ideas, and reference material.',
40
+ author: { name: '', url: '', github: '', email: '' },
41
+ locale: 'en_US',
42
+ language: 'en'
43
+ };
44
+ const isObject = (value: unknown): value is Record<string, unknown> =>
45
+ !!value && typeof value === 'object' && !Array.isArray(value);
46
+ export function parseProjectConfig(value: unknown): ProjectConfig {
47
+ if (!isObject(value) || value.version !== 1)
48
+ throw new Error('Twiki config must be a JSON object with version: 1');
49
+ if (value.contentDir !== undefined && (typeof value.contentDir !== 'string' || !value.contentDir))
50
+ throw new Error('contentDir must be a nonempty path');
51
+ if (
52
+ value.discovery !== undefined &&
53
+ (typeof value.discovery !== 'string' || !['filesystem', 'git'].includes(value.discovery))
54
+ )
55
+ throw new Error('discovery must be filesystem or git');
56
+ if (
57
+ value.publication !== undefined &&
58
+ (typeof value.publication !== 'string' ||
59
+ !['private', 'public', 'policies'].includes(value.publication))
60
+ )
61
+ throw new Error('publication must be private, public or policies');
62
+ if (value.site !== undefined) {
63
+ if (!isObject(value.site)) throw new Error('site must be an object');
64
+ for (const [name, field] of Object.entries(value.site)) {
65
+ if (name === 'author') {
66
+ if (
67
+ !isObject(field) ||
68
+ Object.entries(field).some(
69
+ ([key, text]) =>
70
+ !['name', 'url', 'github', 'email'].includes(key) || typeof text !== 'string'
71
+ )
72
+ )
73
+ throw new Error('site.author must contain text fields');
74
+ } else if (
75
+ !['name', 'title', 'url', 'description', 'locale', 'language'].includes(name) ||
76
+ typeof field !== 'string'
77
+ )
78
+ throw new Error(`Unknown or invalid site field: ${name}`);
79
+ }
80
+ if (
81
+ value.site.url !== undefined &&
82
+ !['https:', 'http:'].includes(new URL(String(value.site.url)).protocol)
83
+ )
84
+ throw new Error('site.url must use HTTP or HTTPS');
85
+ }
86
+ if (value.cloudflare !== undefined && !isObject(value.cloudflare))
87
+ throw new Error('cloudflare must be an object');
88
+ if (
89
+ value.analytics !== undefined &&
90
+ (!isObject(value.analytics) ||
91
+ typeof value.analytics.siteId !== 'string' ||
92
+ [value.analytics.scriptUrl, value.analytics.endpoint].some(
93
+ (x) => x !== undefined && typeof x !== 'string'
94
+ ))
95
+ )
96
+ throw new Error('analytics must contain a siteId and optional scriptUrl/endpoint');
97
+ return value as unknown as ProjectConfig;
98
+ }
99
+ /** All writable engine state belongs to siteDir; project/content roots are read-only inputs. */
100
+ export function resolveProject(
101
+ siteDir = process.cwd(),
102
+ repoOverride?: string,
103
+ environment: NodeJS.ProcessEnv = process.env
104
+ ): ResolvedProject {
105
+ const explicit =
106
+ !!environment.TWIKI_PROJECT_ROOT ||
107
+ !!environment.TWIKI_CONFIG_FILE ||
108
+ !!environment.TWIKI_CONTENT_DIR;
109
+ const requestedRoot = path.resolve(
110
+ repoOverride ??
111
+ environment.TWIKI_PROJECT_ROOT ??
112
+ environment.WISCONSIN_CONTENT_REPO ??
113
+ path.dirname(siteDir)
114
+ );
115
+ const root = existsSync(requestedRoot) ? realpathSync(requestedRoot) : requestedRoot;
116
+ const requested = environment.TWIKI_CONFIG_FILE;
117
+ if (requested && !path.isAbsolute(requested))
118
+ throw new Error('TWIKI_CONFIG_FILE must be absolute');
119
+ const configFile =
120
+ requested ??
121
+ (existsSync(path.join(root, 'twiki.config.json'))
122
+ ? path.join(root, 'twiki.config.json')
123
+ : undefined);
124
+ const config = configFile
125
+ ? parseProjectConfig(JSON.parse(readFileSync(configFile, 'utf8')))
126
+ : { version: 1 as const };
127
+ const selectedContent = path.resolve(
128
+ root,
129
+ environment.TWIKI_CONTENT_DIR ?? config.contentDir ?? (explicit ? '.' : 'content')
130
+ );
131
+ const contentDir = existsSync(selectedContent) ? realpathSync(selectedContent) : selectedContent;
132
+ return {
133
+ root,
134
+ contentDir,
135
+ configFile,
136
+ discovery: config.discovery ?? (explicit ? 'filesystem' : 'git'),
137
+ publication: config.publication ?? (explicit ? 'private' : 'policies'),
138
+ site: { ...defaults, ...config.site, author: { ...defaults.author, ...config.site?.author } },
139
+ cloudflare: config.cloudflare,
140
+ analytics: config.analytics
141
+ };
142
+ }
143
+ export function writeProjectSiteConfig(siteDir: string, project = resolveProject(siteDir)) {
144
+ writeChanged(
145
+ path.join(siteDir, 'src/lib/site-config.json'),
146
+ JSON.stringify(project.site, null, 2) + '\n'
147
+ );
148
+ }
149
+ export function projectIdentity(project: ResolvedProject) {
150
+ return JSON.stringify([
151
+ project.root,
152
+ project.contentDir,
153
+ project.discovery,
154
+ project.publication,
155
+ project.site,
156
+ project.cloudflare,
157
+ project.analytics
158
+ ]);
159
+ }
160
+ export function gitRepositoryRoot(directory: string): string | undefined {
161
+ try {
162
+ return execFileSync('git', ['-C', directory, 'rev-parse', '--show-toplevel'], {
163
+ encoding: 'utf8',
164
+ stdio: 'pipe'
165
+ }).trim();
166
+ } catch {
167
+ return;
168
+ }
169
+ }
170
+ export function gitAvailable(directory: string) {
171
+ return gitRepositoryRoot(directory) !== undefined;
172
+ }
173
+ export function contentWithinProject(project: ResolvedProject) {
174
+ const relative = path.relative(project.root, project.contentDir);
175
+ return relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative);
176
+ }
177
+ export function contentRelative(project: ResolvedProject, file: string) {
178
+ const relative = path.relative(project.contentDir, file).split(path.sep).join('/');
179
+ return relative && relative !== '..' && !relative.startsWith('../') && !path.isAbsolute(relative)
180
+ ? relative
181
+ : undefined;
182
+ }
183
+ export interface ContentCourse {
184
+ name: string;
185
+ directory: string;
186
+ }
187
+ /** Only explicit submodules are course units; folders in a plain vault remain ordinary folders. */
188
+ export function declaredContentCourses(project: ResolvedProject): ContentCourse[] {
189
+ const modules = path.join(project.root, '.gitmodules');
190
+ if (project.discovery !== 'git' || !existsSync(modules)) return [];
191
+ let records: string;
192
+ try {
193
+ records = execFileSync(
194
+ 'git',
195
+ ['config', '-z', '--file', modules, '--get-regexp', '^submodule\\..*\\.path$'],
196
+ { encoding: 'utf8', stdio: 'pipe' }
197
+ );
198
+ } catch (error) {
199
+ if ((error as { status?: number }).status === 1) return [];
200
+ throw error;
201
+ }
202
+ const courses: ContentCourse[] = [];
203
+ for (const record of records.split('\0').filter(Boolean)) {
204
+ const directory = path.resolve(project.root, record.slice(record.indexOf('\n') + 1));
205
+ const name = contentRelative(project, directory);
206
+ if (name && !name.includes('/') && /^[\w-]+$/.test(name)) courses.push({ name, directory });
207
+ }
208
+ return courses.sort((a, b) => a.name.localeCompare(b.name));
209
+ }
210
+ const projectFiles = new Set([
211
+ 'twiki.config.json',
212
+ 'twiki.config.ts',
213
+ 'package.json',
214
+ 'bun.lock',
215
+ 'bun.lockb',
216
+ 'package-lock.json',
217
+ 'pnpm-lock.yaml',
218
+ 'yarn.lock'
219
+ ]);
220
+ function sourceVisible(relative: string) {
221
+ return (
222
+ browsablePath(relative) ||
223
+ (relative.endsWith('/publish.yaml') &&
224
+ browsablePath(relative.slice(0, -'publish.yaml'.length) + 'publication.yaml')) ||
225
+ relative === 'publish.yaml'
226
+ );
227
+ }
228
+ export function projectSourceVisible(project: ResolvedProject, relative: string) {
229
+ return (
230
+ sourceVisible(relative) &&
231
+ !(
232
+ path.resolve(project.contentDir) === path.resolve(project.root) && projectFiles.has(relative)
233
+ ) &&
234
+ !(
235
+ project.configFile &&
236
+ path.resolve(project.contentDir, relative) === path.resolve(project.configFile)
237
+ )
238
+ );
239
+ }
240
+ /** Regular, contained inputs only: never follow symlinks or include engine/editor hidden state. */
241
+ export function discoverSourceFiles(project: ResolvedProject): string[] {
242
+ if (!existsSync(project.contentDir)) throw new Error('Content directory does not exist');
243
+ const root = realpathSync(project.contentDir);
244
+ const paths = new Set<string>();
245
+ const add = (relative: string) => {
246
+ if (!projectSourceVisible(project, relative)) return;
247
+ const file = path.join(project.contentDir, relative);
248
+ try {
249
+ if (lstatSync(file).isFile() && realpathSync(file) === path.join(root, relative))
250
+ paths.add(relative);
251
+ } catch (error) {
252
+ if (!['ENOENT', 'ENOTDIR'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error;
253
+ }
254
+ };
255
+ if (project.discovery === 'git') {
256
+ if (!gitAvailable(project.contentDir))
257
+ throw new Error('Git discovery requires a Git repository containing the content directory');
258
+ const tracked = (directory: string) =>
259
+ execFileSync('git', ['-C', directory, 'ls-files', '-z', '--recurse-submodules'], {
260
+ maxBuffer: 1 << 28
261
+ })
262
+ .toString()
263
+ .split('\0')
264
+ .filter(Boolean);
265
+ for (const relative of tracked(project.contentDir)) add(relative);
266
+ for (const course of declaredContentCourses(project)) {
267
+ if (!existsSync(path.join(course.directory, '.git'))) continue;
268
+ for (const relative of tracked(course.directory)) add(`${course.name}/${relative}`);
269
+ }
270
+ } else {
271
+ const walk = (directory: string, prefix = '') => {
272
+ for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) =>
273
+ a.name.localeCompare(b.name)
274
+ )) {
275
+ const relative = prefix + entry.name;
276
+ if (!sourceVisible(relative)) continue;
277
+ if (entry.isDirectory()) walk(path.join(directory, entry.name), relative + '/');
278
+ else if (entry.isFile()) add(relative);
279
+ }
280
+ };
281
+ walk(project.contentDir);
282
+ }
283
+ return [...paths].sort();
284
+ }
285
+
286
+ export function projectConfig(repoRoot?: string, environment: NodeJS.ProcessEnv = process.env) {
287
+ return resolveProject(process.cwd(), repoRoot, environment);
288
+ }
@@ -0,0 +1,38 @@
1
+ import { readdirSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileRoute } from '../../src/lib/files';
4
+ import { publicFileBrowserShell } from '../../worker/publication';
5
+
6
+ export function publicAssetManifest(
7
+ directory: string,
8
+ pages: Record<string, string>,
9
+ fileEntries: { course: string; file: string }[] = []
10
+ ) {
11
+ const assets: Record<string, string> = {};
12
+ function walk(relative = '') {
13
+ for (const entry of readdirSync(path.join(directory, relative), { withFileTypes: true })) {
14
+ const file = relative ? `${relative}/${entry.name}` : entry.name;
15
+ if (entry.isDirectory()) walk(file);
16
+ else if (
17
+ !file.startsWith('.') &&
18
+ !['_headers', '_redirects', '404.html'].includes(file) &&
19
+ !file.endsWith('.html')
20
+ ) {
21
+ const url = '/' + file.split('/').map(encodeURIComponent).join('/');
22
+ assets[url] = `/_published${url}`;
23
+ }
24
+ }
25
+ }
26
+ walk();
27
+ for (const [route, file] of Object.entries(pages)) {
28
+ if (route === '/404') continue;
29
+ const target = file === 'index.html' ? '' : file.replace(/\.html$/, '');
30
+ const url = route.split('/').map(encodeURIComponent).join('/');
31
+ assets[url] = target
32
+ ? `/_published/${target.split('/').map(encodeURIComponent).join('/')}`
33
+ : '/_published';
34
+ }
35
+ for (const { course, file } of fileEntries)
36
+ assets[fileRoute(course, file)] = publicFileBrowserShell;
37
+ return assets;
38
+ }
@@ -0,0 +1,56 @@
1
+ import { site } from '../../src/lib/config';
2
+ import { visitElements } from './visit-elements';
3
+ import type { Root, Element } from 'hast';
4
+
5
+ export function protectPublicLinks(
6
+ tree: Root,
7
+ page: string,
8
+ allowed: Set<string>,
9
+ catalog = allowed,
10
+ origin = site.url
11
+ ) {
12
+ const permitted = (value: string, targets = allowed) => {
13
+ const url = new URL(value, new URL('/' + page, origin));
14
+ if (url.origin !== new URL(origin).origin) return true;
15
+ const target = decodeURIComponent(url.pathname).replace(/^\/|\/$/g, '');
16
+ return (
17
+ targets.has(target) ||
18
+ targets.has(`${target}/index`) ||
19
+ (target === '' && targets.has('index'))
20
+ );
21
+ };
22
+ visitElements(tree, (node) => {
23
+ const classes = node.properties.className;
24
+ if (node.tagName === 'blockquote' && Array.isArray(classes) && classes.includes('transclude')) {
25
+ const link = node.children[0] as Element | undefined;
26
+ if (typeof link?.properties?.href === 'string' && !permitted(link.properties.href))
27
+ throw new Error(
28
+ `${page}: transclusion requires explicit publication: ${link.properties.href}`
29
+ );
30
+ }
31
+ for (const attribute of ['src', 'poster', 'data', 'srcSet']) {
32
+ const value = node.properties[attribute];
33
+ if (typeof value !== 'string') continue;
34
+ const urls =
35
+ attribute === 'srcSet'
36
+ ? value.split(',').map((part) => part.trim().split(/\s+/)[0])
37
+ : [value];
38
+ for (const url of urls)
39
+ if (!permitted(url))
40
+ throw new Error(`${page}: embedded asset requires explicit publication: ${url}`);
41
+ }
42
+ if (
43
+ typeof node.properties.href === 'string' &&
44
+ !permitted(node.properties.href, node.tagName === 'a' ? catalog : allowed)
45
+ ) {
46
+ if (node.tagName !== 'a')
47
+ throw new Error(
48
+ `${page}: linked resource requires explicit publication: ${node.properties.href}`
49
+ );
50
+ delete node.properties.href;
51
+ delete node.properties['data-slug'];
52
+ delete node.properties.className;
53
+ node.tagName = 'span';
54
+ }
55
+ });
56
+ }
@@ -0,0 +1,156 @@
1
+ import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
3
+ import path from 'node:path';
4
+ import { parseDocument } from 'yaml';
5
+ import {
6
+ declaredContentCourses,
7
+ projectSourceVisible,
8
+ resolveProject,
9
+ type ResolvedProject
10
+ } from './project-config';
11
+ import type { ContentLicense } from '../../src/lib/content-license';
12
+
13
+ export interface PublishPolicy {
14
+ include: string[];
15
+ exclude: string[];
16
+ license?: ContentLicense;
17
+ }
18
+
19
+ function parseLicense(value: unknown): ContentLicense {
20
+ if (!value || typeof value !== 'object' || Array.isArray(value))
21
+ throw new Error('license must be a mapping');
22
+ const fields = value as Record<string, unknown>;
23
+ if (
24
+ Object.keys(fields).some(
25
+ (key) => !['name', 'url', 'attribution', 'source', 'changes'].includes(key)
26
+ )
27
+ )
28
+ throw new Error('Unknown license field');
29
+ for (const key of [
30
+ 'name',
31
+ 'url',
32
+ 'attribution',
33
+ 'source',
34
+ ...(Object.hasOwn(fields, 'changes') ? ['changes'] : [])
35
+ ]) {
36
+ if (typeof fields[key] !== 'string' || !(fields[key] as string).trim())
37
+ throw new Error(`license.${key} must be nonempty text`);
38
+ }
39
+ for (const key of ['url', 'source']) {
40
+ const url = new URL(fields[key] as string);
41
+ if (url.protocol !== 'https:' || url.username || url.password)
42
+ throw new Error(`license.${key} must be an HTTPS URL without credentials`);
43
+ }
44
+ return fields as unknown as ContentLicense;
45
+ }
46
+
47
+ export function parsePublishPolicy(source: string): PublishPolicy {
48
+ const document = parseDocument(source, { uniqueKeys: true, merge: false });
49
+ if (document.errors.length || document.warnings.length)
50
+ throw new Error(
51
+ [...document.errors, ...document.warnings].map((error) => error.message).join('\n')
52
+ );
53
+ const value = document.toJS({ maxAliasCount: 0 });
54
+ if (!value || typeof value !== 'object' || Array.isArray(value))
55
+ throw new Error('Expected a mapping with include and optional exclude lists');
56
+ if (Object.keys(value).some((key) => !['include', 'exclude', 'license'].includes(key)))
57
+ throw new Error('Only include, exclude, and license are supported');
58
+ for (const key of ['include', 'exclude']) {
59
+ const patterns = Object.hasOwn(value, key) ? value[key] : key === 'exclude' ? [] : undefined;
60
+ if (!Array.isArray(patterns)) throw new Error(`${key} must be a list of paths`);
61
+ for (const pattern of patterns) {
62
+ if (
63
+ typeof pattern !== 'string' ||
64
+ !pattern ||
65
+ /[\\\x00-\x1f\x7f{}\[\]()!:#]/.test(pattern) ||
66
+ pattern
67
+ .split('/')
68
+ .some(
69
+ (part: string) =>
70
+ !part || part.startsWith('.') || (part.includes('**') && part !== '**')
71
+ )
72
+ )
73
+ throw new Error(
74
+ `Invalid ${key} pattern: ${JSON.stringify(pattern)}; use relative paths with *, **, or ?`
75
+ );
76
+ }
77
+ }
78
+ return {
79
+ include: value.include,
80
+ exclude: value.exclude ?? [],
81
+ ...(Object.hasOwn(value, 'license') ? { license: parseLicense(value.license) } : {})
82
+ };
83
+ }
84
+
85
+ export function publicationDecision(file: string, policy?: PublishPolicy) {
86
+ if (
87
+ file
88
+ .split('/')
89
+ .some((segment) => !segment || segment.startsWith('.') || segment === 'publish.yaml') ||
90
+ file.includes('\\')
91
+ )
92
+ return { public: false, reason: 'Reserved path' };
93
+ if (!policy) return { public: false, reason: 'No publish.yaml' };
94
+ const excluded = policy.exclude.find((pattern) => path.posix.matchesGlob(file, pattern));
95
+ if (excluded) return { public: false, reason: `Excluded by ${excluded}` };
96
+ const included = policy.include.find((pattern) => path.posix.matchesGlob(file, pattern));
97
+ return included
98
+ ? { public: true, reason: `Included by ${included}` }
99
+ : { public: false, reason: 'Not included' };
100
+ }
101
+
102
+ export function coursePolicies(repo: string, project = resolveProject(process.cwd(), repo)) {
103
+ const policies = new Map<string, PublishPolicy>();
104
+ for (const course of declaredContentCourses(project)) {
105
+ const file = path.join(course.directory, 'publish.yaml');
106
+ if (!existsSync(file)) continue;
107
+ try {
108
+ if (!lstatSync(file).isFile() || realpathSync(file) !== file)
109
+ throw new Error('Policy must be a regular file');
110
+ execFileSync(
111
+ 'git',
112
+ ['-C', course.directory, 'ls-files', '--error-unmatch', '--', 'publish.yaml'],
113
+ { stdio: 'pipe' }
114
+ );
115
+ policies.set(course.name, parsePublishPolicy(readFileSync(file, 'utf8')));
116
+ } catch (error) {
117
+ throw new Error(`${file}: invalid or untracked publication policy`, { cause: error });
118
+ }
119
+ }
120
+ return policies;
121
+ }
122
+
123
+ export function publicationResolver(
124
+ repo: string,
125
+ project: ResolvedProject = resolveProject(process.cwd(), repo)
126
+ ) {
127
+ if (project.publication === 'public')
128
+ return (relative: string) => ({
129
+ public:
130
+ projectSourceVisible(project, relative) && !relative.split('/').includes('publish.yaml'),
131
+ reason: 'Project publication is public; reserved paths stay private.'
132
+ });
133
+ if (project.publication === 'private')
134
+ return (_relative: string) => ({ public: false, reason: 'Project publication is private.' });
135
+ const policies = coursePolicies(repo, project);
136
+ return (relative: string) => {
137
+ const [course, ...segments] = relative.split('/');
138
+ if (!segments.length)
139
+ return {
140
+ public: false,
141
+ reason: 'Root notes are private; publish.yaml applies to course content.'
142
+ };
143
+ const decision = publicationDecision(segments.join('/'), policies.get(course));
144
+ return { ...decision, reason: `${course}/publish.yaml: ${decision.reason}` };
145
+ };
146
+ }
147
+
148
+ export function publicationFilter(repo: string, project = resolveProject(process.cwd(), repo)) {
149
+ const resolve = publicationResolver(repo, project);
150
+ return (relative: string) => resolve(relative).public;
151
+ }
152
+
153
+ export function courseLicenseResolver(repo: string, project = resolveProject(process.cwd(), repo)) {
154
+ const policies = coursePolicies(repo, project);
155
+ return (relative: string) => policies.get(relative.split('/')[0])?.license;
156
+ }