@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,74 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { availableParallelism } from 'node:os';
3
+ import path from 'node:path';
4
+ import type { CourseFile } from '../../src/lib/files';
5
+
6
+ export type HistoryJob = {
7
+ repo: string;
8
+ output: string;
9
+ cache: string;
10
+ files: { file: CourseFile; source: string }[];
11
+ };
12
+
13
+ export async function buildHistories(
14
+ jobs: HistoryJob[],
15
+ retain: (job: HistoryJob, file: string) => void
16
+ ) {
17
+ if (!jobs.length) return;
18
+ const requested = Number(
19
+ process.env.WISCONSIN_HISTORY_WORKERS ?? Math.min(8, availableParallelism())
20
+ );
21
+ if (!Number.isInteger(requested) || requested < 1 || requested > 48)
22
+ throw new Error('WISCONSIN_HISTORY_WORKERS must be an integer from 1 to 48');
23
+ const started = performance.now();
24
+ let next = 0;
25
+ let failure: unknown;
26
+ const results = await Promise.allSettled(
27
+ Array.from({ length: Math.min(requested, jobs.length) }, async () => {
28
+ while (!failure && next < jobs.length) {
29
+ const job = jobs[next++];
30
+ try {
31
+ const result = await new Promise<{ histories: (string | null)[]; outputs: string[] }>(
32
+ (resolve, reject) => {
33
+ const child = spawn('bun', [path.join(import.meta.dirname, 'history-worker.ts')], {
34
+ stdio: ['pipe', 'pipe', 'pipe']
35
+ });
36
+ let stdout = '',
37
+ stderr = '';
38
+ child.stdout.setEncoding('utf8').on('data', (chunk) => {
39
+ stdout += chunk;
40
+ });
41
+ child.stderr.setEncoding('utf8').on('data', (chunk) => {
42
+ stderr += chunk;
43
+ });
44
+ child.on('error', reject);
45
+ child.stdin.on('error', reject);
46
+ child.on('close', (code) => {
47
+ if (code !== 0)
48
+ return reject(new Error(`History worker failed for ${job.repo}: ${stderr}`));
49
+ try {
50
+ resolve(JSON.parse(stdout));
51
+ } catch (error) {
52
+ reject(error);
53
+ }
54
+ });
55
+ child.stdin.end(JSON.stringify(job));
56
+ }
57
+ );
58
+ job.files.forEach(({ file }, i) => {
59
+ file.history = result.histories[i] ?? undefined;
60
+ });
61
+ for (const output of result.outputs) retain(job, output);
62
+ } catch (error) {
63
+ failure = error;
64
+ throw error;
65
+ }
66
+ }
67
+ })
68
+ );
69
+ const failed = results.find((result) => result.status === 'rejected');
70
+ if (failed?.status === 'rejected') throw failed.reason;
71
+ console.log(
72
+ `history: ${jobs.length} courses with ${Math.min(requested, jobs.length)} workers in ${((performance.now() - started) / 1000).toFixed(2)}s`
73
+ );
74
+ }
@@ -0,0 +1,22 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createFileHistoryBuilder } from './file-history';
3
+ import { browsablePath, fileHistoryPolicyKey } from './file-policy';
4
+ import type { CourseFile } from '../../src/lib/files';
5
+
6
+ const job: {
7
+ repo: string;
8
+ output: string;
9
+ cache: string;
10
+ files: { file: CourseFile; source: string }[];
11
+ } = JSON.parse(readFileSync(0, 'utf8'));
12
+ const outputs = new Set<string>();
13
+ const build = createFileHistoryBuilder(
14
+ job.repo,
15
+ job.output,
16
+ job.cache,
17
+ browsablePath,
18
+ fileHistoryPolicyKey(),
19
+ (file) => outputs.add(file)
20
+ );
21
+ const histories = job.files.map(({ file, source }) => build?.(file, readFileSync(source)) ?? null);
22
+ process.stdout.write(JSON.stringify({ histories, outputs: [...outputs] }));
@@ -0,0 +1,38 @@
1
+ args <- commandArgs(trailingOnly = TRUE)
2
+ input <- normalizePath(args[[1]])
3
+ output <- args[[2]]
4
+ figures <- args[[3]]
5
+
6
+ library(knitr)
7
+ render_markdown()
8
+ opts_knit$set(root.dir = dirname(input))
9
+ opts_chunk$set(dev = "png", fig.path = paste0(figures, "/"), error = FALSE, cache = FALSE)
10
+ # Keep plots as Markdown even when a worksheet sets HTML/PDF sizing options.
11
+ # The site renderer handles typography, math, and responsive image sizing.
12
+ knit_hooks$set(plot = function(x, options) {
13
+ caption <- if (is.null(options$fig.cap)) paste("Plot from", options$label) else options$fig.cap
14
+ caption <- gsub("[\r\n]", " ", paste(caption, collapse = " "))
15
+ paste0("\n\n![", caption, "](", x, ")\n\n")
16
+ })
17
+ set.seed(1)
18
+ # Worksheets often include setup instructions for their interactive session.
19
+ # Builds use packages provisioned beforehand, without updating shared libraries.
20
+ # This binding applies to unqualified worksheet calls, not utils::install.packages.
21
+ worksheet <- new.env(parent = globalenv())
22
+ worksheet$install.packages <- function(pkgs, lib = .libPaths(), ...) {
23
+ if (missing(pkgs) || !is.character(pkgs) || anyNA(pkgs) ||
24
+ any(!grepl("^[A-Za-z][A-Za-z0-9.]*$", pkgs))) {
25
+ stop("Worksheet package setup requires names of preinstalled R packages.", call. = FALSE)
26
+ }
27
+ available <- rownames(utils::installed.packages(lib.loc = lib))
28
+ missing_packages <- setdiff(pkgs, available)
29
+ if (length(missing_packages)) {
30
+ stop(paste0(
31
+ "Missing preinstalled R packages: ", paste(missing_packages, collapse = ", "),
32
+ ". Install worksheet dependencies in the build environment before rendering; ",
33
+ "worksheet package installation is disabled."
34
+ ), call. = FALSE)
35
+ }
36
+ invisible(NULL)
37
+ }
38
+ knit(input, output = output, envir = worksheet, quiet = TRUE)
@@ -0,0 +1,201 @@
1
+ import fs from 'node:fs';
2
+ import { stageFingerprint } from '../fingerprint';
3
+ import path from 'node:path';
4
+ import { execFileSync } from 'node:child_process';
5
+ import {
6
+ resolveProject,
7
+ declaredContentCourses,
8
+ contentRelative,
9
+ gitRepositoryRoot
10
+ } from './project-config';
11
+
12
+ const datesVersion = await stageFingerprint('gitdates');
13
+
14
+ export interface SubmoduleInfo {
15
+ path: string;
16
+ fullPath: string;
17
+ }
18
+
19
+ export function parseGitmodules(gitmodulesPath: string): SubmoduleInfo[] {
20
+ try {
21
+ const content = fs.readFileSync(gitmodulesPath, 'utf8');
22
+ const submodules: SubmoduleInfo[] = [];
23
+ const lines = content.split('\n');
24
+ let currentPath = '';
25
+
26
+ for (const line of lines) {
27
+ const trimmed = line.trim();
28
+ if (trimmed.startsWith('path = ')) {
29
+ currentPath = trimmed.substring(7).trim();
30
+ if (currentPath) {
31
+ submodules.push({
32
+ path: currentPath,
33
+ fullPath: path.resolve(path.dirname(gitmodulesPath), currentPath)
34
+ });
35
+ }
36
+ }
37
+ }
38
+ return submodules.sort((a, b) => b.fullPath.length - a.fullPath.length);
39
+ } catch {
40
+ return [];
41
+ }
42
+ }
43
+
44
+ function git(repo: string, args: string[], maxBuffer = 1024 * 1024 * 512): string {
45
+ return execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8', maxBuffer });
46
+ }
47
+
48
+ export function repositoryDates(repoDir: string): GitDateMap {
49
+ const out = git(repoDir, [
50
+ '-c',
51
+ 'core.quotepath=false',
52
+ 'log',
53
+ '--reverse',
54
+ '-m',
55
+ '--first-parent',
56
+ '--format=%H %ct',
57
+ '--name-status',
58
+ '-M'
59
+ ]);
60
+ const dates: GitDateMap = { created: {}, modified: {} };
61
+ let currentEpoch: number | null = null;
62
+ for (const line of out.split('\n')) {
63
+ if (line === '') continue;
64
+ const m = line.match(/^[0-9a-f]{40} (\d+)$/);
65
+ if (m) {
66
+ currentEpoch = Number(m[1]);
67
+ continue;
68
+ }
69
+ const [status, file, renamed] = line.split('\t');
70
+ if (currentEpoch === null || !file) continue;
71
+ if (status.startsWith('R') && renamed) {
72
+ dates.created[renamed] = dates.created[file] ?? currentEpoch;
73
+ dates.modified[renamed] = currentEpoch;
74
+ delete dates.created[file];
75
+ delete dates.modified[file];
76
+ } else if (status === 'D') {
77
+ delete dates.created[file];
78
+ delete dates.modified[file];
79
+ } else {
80
+ if (status === 'A' || dates.created[file] === undefined) dates.created[file] = currentEpoch;
81
+ dates.modified[file] = currentEpoch;
82
+ }
83
+ }
84
+ return dates;
85
+ }
86
+
87
+ function headSha(repoDir: string): string {
88
+ return git(repoDir, ['rev-parse', 'HEAD']).trim();
89
+ }
90
+
91
+ export interface GitDateMap {
92
+ created: Record<string, number>;
93
+ modified: Record<string, number>;
94
+ }
95
+
96
+ export function buildGitDateMap(
97
+ repoRoot: string,
98
+ cacheDir: string,
99
+ project = resolveProject(process.cwd(), repoRoot)
100
+ ): GitDateMap {
101
+ fs.mkdirSync(cacheDir, { recursive: true });
102
+ const submodules = declaredContentCourses(project);
103
+
104
+ const created: Record<string, number> = {};
105
+ const modified: Record<string, number> = {};
106
+
107
+ const loadRepo = (repoDir: string, cacheKey: string): GitDateMap => {
108
+ let head: string;
109
+ try {
110
+ head = headSha(repoDir);
111
+ } catch {
112
+ return { created: {}, modified: {} };
113
+ }
114
+ const cacheFile = path.join(cacheDir, `gitdates-v3-${cacheKey}-${datesVersion}-${head}.json`);
115
+ if (fs.existsSync(cacheFile)) {
116
+ try {
117
+ const saved = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
118
+ if (!saved.created || !saved.modified) throw new Error('Invalid Git date cache');
119
+ return saved;
120
+ } catch {
121
+ /* fall through to rebuild */
122
+ }
123
+ }
124
+ const dates = repositoryDates(repoDir);
125
+ for (const f of fs.readdirSync(cacheDir)) {
126
+ if (f.startsWith(`gitdates-v3-${cacheKey}-`)) fs.rmSync(path.join(cacheDir, f));
127
+ }
128
+ fs.writeFileSync(cacheFile, JSON.stringify(dates));
129
+ return dates;
130
+ };
131
+ for (const sub of submodules) {
132
+ const name = sub.name;
133
+ if (!fs.existsSync(path.join(sub.directory, '.git'))) continue;
134
+ const dates = loadRepo(sub.directory, name);
135
+ for (const [p, epoch] of Object.entries(dates.modified)) {
136
+ modified[`${name}/${p}`] = epoch;
137
+ created[`${name}/${p}`] = dates.created[p];
138
+ }
139
+ }
140
+ const dateRoot = gitRepositoryRoot(project.contentDir) ?? repoRoot;
141
+ const superDates =
142
+ project.discovery === 'git'
143
+ ? loadRepo(dateRoot, 'superproject')
144
+ : { created: {}, modified: {} };
145
+ for (const [p, epoch] of Object.entries(superDates.modified)) {
146
+ const rel = contentRelative(project, path.join(dateRoot, p));
147
+ if (rel) {
148
+ if (!submodules.some((s) => rel === s.name || rel.startsWith(s.name + '/'))) {
149
+ modified[rel] = epoch;
150
+ created[rel] = superDates.created[p];
151
+ }
152
+ }
153
+ }
154
+
155
+ return { created, modified };
156
+ }
157
+
158
+ export function coerceDate(
159
+ file: string,
160
+ value: unknown,
161
+ warn?: (message: string) => void
162
+ ): Date | undefined {
163
+ if (value === undefined || value === null || value === '') return;
164
+ const dateOnly = typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value);
165
+ const supported =
166
+ value instanceof Date ||
167
+ typeof value === 'number' ||
168
+ (typeof value === 'string' &&
169
+ (dateOnly || /^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(value)));
170
+ const date = supported ? new Date(value as string | number) : new Date(NaN);
171
+ if (
172
+ !Number.isFinite(date.getTime()) ||
173
+ date.getTime() > Date.now() ||
174
+ (dateOnly && date.toISOString().slice(0, 10) !== value)
175
+ ) {
176
+ warn?.(`${file}: ignored invalid or future date ${String(value)}`);
177
+ return;
178
+ }
179
+ return date;
180
+ }
181
+
182
+ export function resolveDates({
183
+ relativePath,
184
+ frontmatter,
185
+ gitDates,
186
+ warn
187
+ }: {
188
+ relativePath: string;
189
+ frontmatter: Record<string, unknown>;
190
+ gitDates: GitDateMap;
191
+ warn?: (message: string) => void;
192
+ }) {
193
+ const fromGit = (kind: 'created' | 'modified') => {
194
+ const epoch = gitDates[kind][relativePath];
195
+ return epoch === undefined ? undefined : coerceDate(relativePath, epoch * 1000, warn);
196
+ };
197
+ const created = coerceDate(relativePath, frontmatter.created, warn) ?? fromGit('created');
198
+ const modified = coerceDate(relativePath, frontmatter.modified, warn) ?? fromGit('modified');
199
+ const published = coerceDate(relativePath, frontmatter.published, warn);
200
+ return { created, modified, published };
201
+ }
@@ -0,0 +1,51 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { lstatSync, existsSync, readdirSync } from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ /** Include tracked and untracked, non-ignored Markdown, descending into submodules. */
6
+ export function markdownFiles(inputs: string[], extension = /\.md$/i): string[] {
7
+ const files = new Set<string>();
8
+ function collect(input: string) {
9
+ const absolute = path.resolve(input);
10
+ const stat = lstatSync(absolute);
11
+ if (stat.isSymbolicLink()) return;
12
+ if (stat.isFile()) {
13
+ if (!extension.test(absolute)) throw new Error(`Expected Markdown: ${input}`);
14
+ files.add(absolute);
15
+ return;
16
+ }
17
+ let names: string[];
18
+ try {
19
+ names = execFileSync(
20
+ 'git',
21
+ ['-C', absolute, 'ls-files', '--cached', '--others', '--exclude-standard', '-z'],
22
+ { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }
23
+ ).split('\0');
24
+ } catch (error) {
25
+ // A plain Obsidian folder is a supported source; permission failures remain errors.
26
+ const detail = String((error as { stderr?: unknown }).stderr ?? '');
27
+ if (!detail.includes('not a git repository')) throw error;
28
+ for (const name of readdirSync(absolute)) {
29
+ if (name.startsWith('.') || ['node_modules', 'build', 'dist'].includes(name)) continue;
30
+ const full = path.join(absolute, name),
31
+ entry = lstatSync(full);
32
+ if (entry.isDirectory()) collect(full);
33
+ else if (entry.isFile() && extension.test(name)) files.add(full);
34
+ }
35
+ return;
36
+ }
37
+ for (const name of new Set(names.filter(Boolean))) {
38
+ const full = path.join(absolute, name);
39
+ if (!existsSync(full)) continue; // tracked deletion in the working tree
40
+ const entry = lstatSync(full);
41
+ if (entry.isDirectory()) {
42
+ if (!existsSync(path.join(full, '.git'))) {
43
+ throw new Error(`Submodule is not initialized: ${full}`);
44
+ }
45
+ collect(full);
46
+ } else if (entry.isFile() && extension.test(name)) files.add(full);
47
+ }
48
+ }
49
+ inputs.forEach(collect);
50
+ return [...files].sort();
51
+ }