@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,1386 @@
1
+ import { htmlAssetPaths, rewriteContentUrls } from './lib/content-urls';
2
+ import { fileRoute } from '../src/lib/files';
3
+ import { browsablePath } from './lib/file-policy';
4
+ import { preserveCurrency } from './lib/currency';
5
+ /** Render tracked notes, resolve links and transclusions, and emit pages, assets, and graph data. */
6
+ import fs from 'node:fs';
7
+ import { writeChanged, copyChanged, pruneOutputs } from './lib/output';
8
+ import path from 'node:path';
9
+ import crypto from 'node:crypto';
10
+
11
+ import matter from 'gray-matter';
12
+ import yaml from 'js-yaml';
13
+ import { unified, type Plugin } from 'unified';
14
+ import remarkParse from 'remark-parse';
15
+ import remarkGfm from 'remark-gfm';
16
+ import remarkSmartypants from 'remark-smartypants';
17
+ import remarkMath from 'remark-math';
18
+ import remarkRehype from 'remark-rehype';
19
+ import rehypeRaw from 'rehype-raw';
20
+ import rehypeSlug from 'rehype-slug';
21
+ import rehypeKatex from 'rehype-katex';
22
+ import { SKIP as SKIP_VISIT, visit } from 'unist-util-visit';
23
+ import { visitElements } from './lib/visit-elements';
24
+ import { toString as mdastToString } from 'mdast-util-to-string';
25
+ import { toString as hastToString } from 'hast-util-to-string';
26
+ import { toHtml } from 'hast-util-to-html';
27
+ import Slugger from 'github-slugger';
28
+ import { createHighlighter, type Highlighter } from 'shiki';
29
+ import type { Root as MdRoot } from 'mdast';
30
+ import type { Element, ElementContent, Root as HtmlRoot } from 'hast';
31
+
32
+ import {
33
+ type FilePath,
34
+ type FullSlug,
35
+ type SimpleSlug,
36
+ type RelativeURL,
37
+ type TransformOptions,
38
+ getFileExtension,
39
+ joinSegments,
40
+ getAllSegmentPrefixes,
41
+ normalizeHastElement,
42
+ simplifySlug,
43
+ slugifyFilePath,
44
+ slugTag,
45
+ splitAnchor,
46
+ stripSlashes,
47
+ transformLink,
48
+ createTransformOptions
49
+ } from './lib/slug';
50
+ import {
51
+ type OfmFileData,
52
+ ofmBlockReferences,
53
+ ofmCallouts,
54
+ ofmCheckboxes,
55
+ ofmMermaid,
56
+ ofmReplacements,
57
+ ofmTextTransform,
58
+ ofmVideoEmbed,
59
+ ofmYouTubeEmbed
60
+ } from './lib/ofm';
61
+ import { applyAutoTags } from './lib/autotag';
62
+ import { buildGitDateMap, resolveDates } from './lib/lastmod';
63
+ import { contentAuthor } from '../src/lib/metadata';
64
+ import { publicationResolver, courseLicenseResolver } from './lib/publishing';
65
+ import { protectPublicLinks } from './lib/public-links';
66
+ import { stageFingerprint } from './fingerprint';
67
+ import { compiledAssetsDirectory } from './lib/edition-paths.js';
68
+ import { resolveProject, discoverSourceFiles, declaredContentCourses } from './lib/project-config';
69
+ import {
70
+ contentHash,
71
+ readContentBody,
72
+ readContentIndex,
73
+ readContentObject,
74
+ transclusionClosure,
75
+ writeContentIndex,
76
+ writeContentObject,
77
+ type ContentBody,
78
+ type ContentCacheIndex,
79
+ type ContentCachePage,
80
+ type ContentSummary
81
+ } from './lib/content-cache';
82
+
83
+ export async function compileContent(
84
+ options: {
85
+ siteDir?: string;
86
+ repoRoot?: string;
87
+ publicEdition?: boolean;
88
+ incremental?: boolean;
89
+ } = {}
90
+ ) {
91
+ const SITE_DIR = options.siteDir ?? path.resolve(import.meta.dirname, '..');
92
+ const project = resolveProject(SITE_DIR, options.repoRoot);
93
+ const REPO_ROOT = project.root;
94
+ const PIPELINE_VERSION = crypto
95
+ .createHash('sha256')
96
+ .update(await stageFingerprint('parser'))
97
+ .update(project.discovery)
98
+ .digest('hex');
99
+ const publicEdition = options.publicEdition ?? process.env.VITE_PUBLIC_EDITION === 'true';
100
+ const edition = publicEdition ? 'public' : 'full';
101
+ const CONTENT_DIR = project.contentDir;
102
+ const OUT_DIR = path.join(SITE_DIR, 'build/generated');
103
+ const CACHE_DIR = path.join(OUT_DIR, 'cache');
104
+ const ASSETS_DIR =
105
+ options.publicEdition === undefined
106
+ ? compiledAssetsDirectory(SITE_DIR)
107
+ : path.join(OUT_DIR, publicEdition ? 'public-assets' : 'assets');
108
+
109
+ const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp']);
110
+ const ASSET_EXTS = new Set([...IMAGE_EXTS, '.pdf', '.html']);
111
+
112
+ const SIZE_WARN = 20 * 1024 * 1024;
113
+ const SIZE_FAIL = 25 * 1024 * 1024;
114
+
115
+ const warnings: string[] = [];
116
+ const warn = (msg: string) => {
117
+ warnings.push(msg);
118
+ };
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // stage 0 — discover
122
+ // ---------------------------------------------------------------------------
123
+ interface SourceFile {
124
+ rel: string; // path relative to content/
125
+ abs: string;
126
+ slug: FullSlug;
127
+ kind: 'page' | 'asset' | 'other';
128
+ /** in Quartz's RESOLUTION slug set (build.ts globs "**\/*.*": basename must
129
+ * contain a dot). The Assets emitter globs "**" (everything non-md), so
130
+ * extensionless files (Makefile, LICENSE, gradlew…) are SERVED by Quartz but
131
+ * never participate in link resolution. We mirror both sets exactly. */
132
+ inResolution: boolean;
133
+ }
134
+
135
+ /**
136
+ * Quartz's resolution discovery was globby("**\/*.*", { gitignore: true }) —
137
+ * i.e. visible files whose basename contains a dot and with no dot-leading
138
+ * path segment. We use git ls-files (authoritative, kills untracked junk) and
139
+ * apply the same visibility rule so the resolution slug set matches Quartz's
140
+ * allSlugs. The served set is wider: Quartz's Assets emitter globs "**", which
141
+ * additionally picks up extensionless basenames (see SourceFile.inResolution).
142
+ */
143
+ function dotLed(rel: string): boolean {
144
+ return rel.split('/').some((s) => s.startsWith('.'));
145
+ }
146
+ function globVisible(rel: string): boolean {
147
+ if (dotLed(rel)) return false;
148
+ const base = rel.split('/').pop()!;
149
+ return base.slice(1).includes('.');
150
+ }
151
+
152
+ function discover(): { files: SourceFile[]; submoduleNames: string[] } {
153
+ const courses = declaredContentCourses(project);
154
+ const rels = discoverSourceFiles(project);
155
+
156
+ const files: SourceFile[] = [];
157
+ for (const rel of rels) {
158
+ if (rel.split('/').includes('publish.yaml')) continue;
159
+ const source = path.join(CONTENT_DIR, rel);
160
+ if (
161
+ !fs.existsSync(source) ||
162
+ !fs.lstatSync(source).isFile() ||
163
+ fs.realpathSync(source) !== source
164
+ )
165
+ continue;
166
+ // dot-led paths are invisible to every Quartz glob (globby dot:false)
167
+ if (dotLed(rel)) continue;
168
+ const ext = (getFileExtension(rel) ?? '').toLowerCase();
169
+ const kind: SourceFile['kind'] =
170
+ ext === '.md' ? 'page' : ASSET_EXTS.has(ext) ? 'asset' : 'other';
171
+ files.push({
172
+ rel,
173
+ abs: path.join(CONTENT_DIR, rel),
174
+ slug: slugifyFilePath(rel as FilePath),
175
+ kind,
176
+ inResolution: globVisible(rel)
177
+ });
178
+ }
179
+ files.sort((a, b) => a.rel.localeCompare(b.rel));
180
+ return { files, submoduleNames: courses.map((course) => course.name) };
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // stage 1 — parse (per file, cacheable)
185
+ // ---------------------------------------------------------------------------
186
+ interface TocEntry {
187
+ depth: number;
188
+ text: string;
189
+ slug: string;
190
+ }
191
+
192
+ interface PageParse {
193
+ markdown: string;
194
+ slug: FullSlug;
195
+ rel: string;
196
+ frontmatter: Record<string, unknown> & { title: string };
197
+ heading?: string;
198
+ title: string;
199
+ description: string;
200
+ tags: string[];
201
+ toc: TocEntry[];
202
+ draft: boolean;
203
+ hasMermaid: boolean;
204
+ wordCount: number;
205
+ blocks: Record<string, Element>;
206
+ tree: HtmlRoot;
207
+ }
208
+
209
+ let highlighter: Highlighter | undefined;
210
+ const SHIKI_LANGS = [
211
+ 'asm',
212
+ 'bash',
213
+ 'c',
214
+ 'console',
215
+ 'cpp',
216
+ 'csharp',
217
+ 'css',
218
+ 'csv',
219
+ 'diff',
220
+ 'dockerfile',
221
+ 'go',
222
+ 'html',
223
+ 'ini',
224
+ 'java',
225
+ 'javascript',
226
+ 'json',
227
+ 'jsx',
228
+ 'kotlin',
229
+ 'latex',
230
+ 'lua',
231
+ 'makefile',
232
+ 'markdown',
233
+ 'nginx',
234
+ 'perl',
235
+ 'php',
236
+ 'python',
237
+ 'r',
238
+ 'regex',
239
+ 'ruby',
240
+ 'rust',
241
+ 'scss',
242
+ 'shell',
243
+ 'sql',
244
+ 'text',
245
+ 'toml',
246
+ 'tsx',
247
+ 'typescript',
248
+ 'verilog',
249
+ 'xml',
250
+ 'yaml'
251
+ ];
252
+ const shikiLangAliases: Record<string, string> = {
253
+ // languages seen in the corpus that shiki has no grammar for / different name
254
+ 'c++': 'cpp',
255
+ sh: 'bash',
256
+ zsh: 'bash',
257
+ shellsession: 'console',
258
+ plaintext: 'text',
259
+ txt: 'text',
260
+ gitignore: 'ini',
261
+ mysql: 'sql'
262
+ };
263
+ const unknownLangs = new Map<string, number>();
264
+
265
+ async function getHighlighter(): Promise<Highlighter> {
266
+ highlighter ??= await createHighlighter({
267
+ themes: ['github-light', 'github-dark'],
268
+ langs: SHIKI_LANGS
269
+ });
270
+ return highlighter;
271
+ }
272
+
273
+ /** Shiki dual-theme highlighting (replaces rehype-pretty-code; cca pattern). */
274
+ function rehypeShiki(hl: Highlighter) {
275
+ return (tree: HtmlRoot) => {
276
+ visit(tree, 'element', (node, index, parent) => {
277
+ if (
278
+ node.tagName !== 'pre' ||
279
+ !parent ||
280
+ index === undefined ||
281
+ node.children.length !== 1 ||
282
+ (node.children[0] as Element).tagName !== 'code'
283
+ ) {
284
+ return;
285
+ }
286
+ const code = node.children[0] as Element;
287
+ const classNames = (code.properties?.className ?? []) as string[];
288
+ if (classNames.includes('mermaid')) return; // client-side island
289
+ // $$ math blocks: remark-math emits <pre><code class="language-math math-display">,
290
+ // consumed by rehype-katex downstream — leave untouched
291
+ if (classNames.includes('math-display') || classNames.includes('math-inline')) return;
292
+ let lang =
293
+ classNames
294
+ .find((c) => c.startsWith('language-'))
295
+ ?.slice('language-'.length)
296
+ .toLowerCase() ?? 'text';
297
+ lang = shikiLangAliases[lang] ?? lang;
298
+ if (!['text'].includes(lang) && !hl.getLoadedLanguages().includes(lang)) {
299
+ unknownLangs.set(lang, (unknownLangs.get(lang) ?? 0) + 1);
300
+ lang = 'text';
301
+ }
302
+ const source = hastToString(code).replace(/\n$/, '');
303
+ const highlighted = hl.codeToHast(source, {
304
+ lang,
305
+ themes: { light: 'github-light', dark: 'github-dark' },
306
+ defaultColor: false
307
+ });
308
+ const pre = highlighted.children[0] as Element;
309
+ // keep the original language- class on the inner <code> for styling/copy
310
+ const newCode = pre.children[0] as Element;
311
+ newCode.properties = { ...newCode.properties, className: classNames };
312
+ parent.children[index] = pre;
313
+ });
314
+ };
315
+ }
316
+
317
+ // vendored from quartz/util/escape.ts
318
+ const escapeHTML = (unsafe: string) =>
319
+ unsafe
320
+ .replaceAll('&', '&amp;')
321
+ .replaceAll('<', '&lt;')
322
+ .replaceAll('>', '&gt;')
323
+ .replaceAll('"', '&quot;')
324
+ .replaceAll("'", '&#039;');
325
+
326
+ // vendored from quartz/plugins/transformers/description.ts (defaults)
327
+ const urlRegex = new RegExp(
328
+ /(https?:\/\/)?(?<domain>([\da-z\.-]+)\.([a-z\.]{2,6})(:\d+)?)(?<path>[\/\w\.-]*)(\?[\/\w\.=&;-]*)?/,
329
+ 'g'
330
+ );
331
+ function computeDescription(tree: HtmlRoot, frontmatterDescription?: string): string {
332
+ let fmDesc = frontmatterDescription;
333
+ let text = escapeHTML(hastToString(tree));
334
+ fmDesc = fmDesc?.replace(urlRegex, '$<domain>$<path>');
335
+ text = text.replace(urlRegex, '$<domain>$<path>');
336
+ if (fmDesc) return fmDesc;
337
+
338
+ const desc = text;
339
+ const sentences = desc.replace(/\s+/g, ' ').split(/\.\s/);
340
+ let finalDesc = '';
341
+ let sentenceIdx = 0;
342
+ while (sentenceIdx < sentences.length) {
343
+ const sentence = sentences[sentenceIdx];
344
+ if (!sentence) break;
345
+ const currentSentence = sentence.endsWith('.') ? sentence : sentence + '.';
346
+ const nextLength = finalDesc.length + currentSentence.length + (finalDesc ? 1 : 0);
347
+ if (nextLength <= 150 || sentenceIdx === 0) {
348
+ finalDesc += (finalDesc ? ' ' : '') + currentSentence;
349
+ sentenceIdx++;
350
+ } else {
351
+ break;
352
+ }
353
+ }
354
+ return finalDesc.length > 300 ? finalDesc.slice(0, 300) + '...' : finalDesc;
355
+ }
356
+
357
+ // frontmatter coalescing, vendored from quartz/plugins/transformers/frontmatter.ts
358
+ function coalesceAliases(data: Record<string, any>, aliases: string[]) {
359
+ for (const alias of aliases) {
360
+ if (data[alias] !== undefined && data[alias] !== null) return data[alias];
361
+ }
362
+ }
363
+ function coerceToArray(input: unknown): string[] | undefined {
364
+ if (input === undefined || input === null) return undefined;
365
+ let arr: unknown[] = Array.isArray(input)
366
+ ? input
367
+ : String(input)
368
+ .split(',')
369
+ .map((tag: string) => tag.trim());
370
+ return arr
371
+ .filter((tag: unknown) => typeof tag === 'string' || typeof tag === 'number')
372
+ .map((tag) => String(tag));
373
+ }
374
+
375
+ function sha256(s: string): string {
376
+ return crypto.createHash('sha256').update(s).digest('hex');
377
+ }
378
+
379
+ let parseHits = 0;
380
+ const parseCacheFiles = new Set<string>();
381
+ async function parsePage(
382
+ file: SourceFile,
383
+ raw = fs.readFileSync(file.abs, 'utf8')
384
+ ): Promise<PageParse> {
385
+ const cacheKey = sha256(`${PIPELINE_VERSION}|${file.rel}|${raw}`);
386
+ const cacheFile = path.join(CACHE_DIR, 'stage1', cacheKey.slice(0, 2), cacheKey + '.json');
387
+ parseCacheFiles.add(path.relative(CACHE_DIR, cacheFile).split(path.sep).join('/'));
388
+ if (fs.existsSync(cacheFile)) {
389
+ try {
390
+ const saved = JSON.parse(fs.readFileSync(cacheFile, 'utf8')) as PageParse;
391
+ if (
392
+ !saved ||
393
+ saved.slug !== file.slug ||
394
+ saved.tree?.type !== 'root' ||
395
+ !Array.isArray(saved.tags)
396
+ )
397
+ throw new Error('Invalid parse cache');
398
+ parseHits++;
399
+ return saved;
400
+ } catch {
401
+ /* re-parse */
402
+ }
403
+ }
404
+
405
+ const { data: fmRaw, content } = matter(raw, {
406
+ engines: {
407
+ yaml: (s) => yaml.load(s, { schema: yaml.JSON_SCHEMA }) as object
408
+ }
409
+ });
410
+ const data: Record<string, any> = { ...fmRaw };
411
+
412
+ // OFM text phase
413
+ const text = ofmTextTransform(content);
414
+
415
+ // tags first so the inline-#tag plugin can append (fork order: FrontMatter -> AutoTag -> ... -> OFM)
416
+ const fmTags = coerceToArray(coalesceAliases(data, ['tags', 'tag']));
417
+ let tags: string[] = fmTags ? [...new Set(fmTags.map((tag) => slugTag(tag)))] : [];
418
+ // AutoTag (fork ran it right after FrontMatter, before OFM appends inline tags)
419
+ if (project.discovery === 'git') tags = applyAutoTags(tags, file.rel);
420
+
421
+ const aliases = coerceToArray(coalesceAliases(data, ['aliases', 'alias']));
422
+ if (aliases && aliases.length > 0) {
423
+ warn(
424
+ `${file.rel}: frontmatter aliases present but alias redirects are not emitted: ${aliases.join(', ')}`
425
+ );
426
+ }
427
+
428
+ const modified = coalesceAliases(data, ['modified', 'lastmod', 'updated', 'last-modified']);
429
+ if (modified) data.modified = modified;
430
+ const published = coalesceAliases(data, ['published', 'publishDate']);
431
+ if (published) data.published = published;
432
+
433
+ const ofmData: OfmFileData = { slug: file.slug, tags };
434
+
435
+ // markdown phase — plugin order mirrors quartz.config.ts transformer order
436
+ // TOC is collected at the hast stage AFTER rehype-raw + rehype-slug so that
437
+ // entry slugs always equal the final rendered heading ids. (Quartz slugged
438
+ // the mdast text instead, which diverges when a heading contains raw-HTML-
439
+ // looking tokens — e.g. `ArrayList<Partition>` in fa24-cs300/p06 — producing
440
+ // a TOC anchor broken on the live site. Deliberate fix-not-replicate;
441
+ // handleMissingId:'fail' enforces it corpus-wide.)
442
+ const toc: TocEntry[] = [];
443
+ const collectToc = () => (tree: HtmlRoot) => {
444
+ let highestDepth = 6;
445
+ const entries: TocEntry[] = [];
446
+ visit(tree, 'element', (node: Element) => {
447
+ const m = /^h([1-6])$/.exec(node.tagName);
448
+ if (m && typeof node.properties?.id === 'string') {
449
+ const depth = Number(m[1]);
450
+ highestDepth = Math.min(highestDepth, depth);
451
+ entries.push({ depth, text: hastToString(node), slug: node.properties.id });
452
+ }
453
+ });
454
+ if (entries.length > 1) {
455
+ toc.push(...entries.map((e) => ({ ...e, depth: e.depth - highestDepth })));
456
+ }
457
+ };
458
+
459
+ let description = '';
460
+ const captureDescription = () => (tree: HtmlRoot) => {
461
+ description = computeDescription(tree, data.description as string | undefined);
462
+ };
463
+
464
+ const hl = await getHighlighter();
465
+ const processor = unified()
466
+ .use(remarkParse)
467
+ // syntax-extension-only plugins (position in chain irrelevant, parse-time)
468
+ .use(remarkGfm)
469
+ .use(remarkMath)
470
+ .use(() => preserveCurrency(text))
471
+ // OFM mdast phase (fork order)
472
+ .use(() => ofmReplacements(ofmData))
473
+ .use(() => ofmVideoEmbed())
474
+ .use(() => ofmCallouts())
475
+ .use(() => ofmMermaid(ofmData))
476
+ .use(remarkSmartypants)
477
+ .use(remarkRehype, { allowDangerousHtml: true })
478
+ .use(rehypeRaw)
479
+ .use(() => ofmBlockReferences(ofmData))
480
+ .use(() => ofmYouTubeEmbed())
481
+ .use(rehypeSlug)
482
+ .use(collectToc)
483
+ .use(() => rehypeShiki(hl))
484
+ .use(captureDescription)
485
+ .use(rehypeKatex, { output: 'html', macros: {} });
486
+
487
+ const mdast = processor.parse(text) as MdRoot;
488
+
489
+ let heading: string | undefined;
490
+ visit(mdast, 'heading', (node) => {
491
+ if (node.depth === 1 && !heading) heading = mdastToString(node).trim() || undefined;
492
+ });
493
+ let title: string;
494
+ if (data.title != null && String(data.title) !== '') {
495
+ title = String(data.title);
496
+ } else {
497
+ const stem = path.basename(file.rel, '.md');
498
+ const keepFilename = stem.toLowerCase().includes('readme');
499
+ title = (keepFilename ? stem : heading) ?? stem ?? 'Untitled';
500
+ }
501
+ data.title = title;
502
+
503
+ const tree = (await processor.run(mdast)) as HtmlRoot;
504
+
505
+ const fullText = hastToString(tree);
506
+ const wordCount = fullText.split(/\s+/).filter((w) => w.length > 0).length;
507
+
508
+ const page: PageParse = {
509
+ markdown: content,
510
+ slug: file.slug,
511
+ rel: file.rel,
512
+ frontmatter: data as PageParse['frontmatter'],
513
+ heading,
514
+ title,
515
+ description,
516
+ tags: ofmData.tags,
517
+ toc,
518
+ draft: data.draft === true || data.draft === 'true',
519
+ hasMermaid: ofmData.hasMermaidDiagram ?? false,
520
+ wordCount,
521
+ blocks: ofmData.blocks ?? {},
522
+ tree
523
+ };
524
+
525
+ fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
526
+ writeChanged(cacheFile, JSON.stringify(page));
527
+ return page;
528
+ }
529
+
530
+ function summary(page: PageParse): ContentSummary {
531
+ const { tree: _tree, blocks: _blocks, markdown: _markdown, ...metadata } = page;
532
+ return metadata;
533
+ }
534
+
535
+ function directTranscludes(tree: HtmlRoot) {
536
+ const references: ContentCachePage['transcludes'] = [];
537
+ visitElements(tree, (node) => {
538
+ if (node.tagName !== 'blockquote') return;
539
+ const classes = node.properties.className;
540
+ if (!Array.isArray(classes) || !classes.includes('transclude')) return;
541
+ const anchor = node.children[0] as Element | undefined;
542
+ const target = anchor?.properties?.['data-slug'];
543
+ if (typeof target === 'string')
544
+ references.push({
545
+ target,
546
+ ...(typeof node.properties.dataBlock === 'string'
547
+ ? { selector: node.properties.dataBlock }
548
+ : {})
549
+ });
550
+ });
551
+ return references;
552
+ }
553
+
554
+ function hasTranscludes(tree: HtmlRoot) {
555
+ let found = false;
556
+ visitElements(tree, (node) => {
557
+ const classes = node.properties.className;
558
+ if (node.tagName === 'blockquote' && Array.isArray(classes) && classes.includes('transclude'))
559
+ found = true;
560
+ });
561
+ return found;
562
+ }
563
+
564
+ let completedIndex: ContentCacheIndex | undefined;
565
+
566
+ // ---------------------------------------------------------------------------
567
+ // stage 2 — resolve (global)
568
+ // ---------------------------------------------------------------------------
569
+
570
+ // vendored from quartz CrawlLinks' use of is-absolute-url
571
+ function isAbsoluteUrl(url: string): boolean {
572
+ if (/^[a-zA-Z][a-zA-Z\d+\-.]*?:/.test(url)) {
573
+ const windowsPath = /^[a-zA-Z]:\\/;
574
+ if (windowsPath.test(url)) return false;
575
+ return true;
576
+ }
577
+ return false;
578
+ }
579
+
580
+ const EXTERNAL_ICON: ElementContent = {
581
+ type: 'element',
582
+ tagName: 'svg',
583
+ properties: {
584
+ 'aria-hidden': 'true',
585
+ class: 'external-icon',
586
+ style: 'max-width:0.8em;max-height:0.8em',
587
+ viewBox: '0 0 512 512'
588
+ },
589
+ children: [
590
+ {
591
+ type: 'element',
592
+ tagName: 'path',
593
+ properties: {
594
+ d: 'M320 0H288V64h32 82.7L201.4 265.4 178.7 288 224 333.3l22.6-22.6L448 109.3V192v32h64V192 32 0H480 320zM32 32H0V64 480v32H32 456h32V480 352 320H424v32 96H64V96h96 32V32H160 32z'
595
+ },
596
+ children: []
597
+ }
598
+ ]
599
+ };
600
+
601
+ /** Vendored CrawlLinks visitor (quartz/plugins/transformers/links.ts) with the
602
+ * config used in quartz.config.ts: markdownLinkResolution "shortest",
603
+ * prettyLinks true, openLinksInNewTab false, lazyLoad false, externalLinkIcon true. */
604
+ function crawlLinks(
605
+ page: PageParse,
606
+ transformOptions: TransformOptions,
607
+ emittedSlugs: Set<string>,
608
+ droppedSlugs: Set<string>,
609
+ fileRoutes: Map<string, string>
610
+ ): SimpleSlug[] {
611
+ const curSlug = simplifySlug(page.slug);
612
+ const outgoing: Set<SimpleSlug> = new Set();
613
+
614
+ visitElements(page.tree, (node) => {
615
+ if (node.tagName === 'a' && node.properties && typeof node.properties.href === 'string') {
616
+ let dest = node.properties.href as RelativeURL;
617
+ const classes = (node.properties.className ?? []) as string[];
618
+ const isExternal = isAbsoluteUrl(dest);
619
+ classes.push(isExternal ? 'external' : 'internal');
620
+
621
+ if (isExternal) {
622
+ node.children.push(structuredClone(EXTERNAL_ICON));
623
+ }
624
+
625
+ // Check if the link has alias text
626
+ if (
627
+ node.children.length === 1 &&
628
+ node.children[0].type === 'text' &&
629
+ node.children[0].value !== dest
630
+ ) {
631
+ classes.push('alias');
632
+ }
633
+ node.properties.className = classes;
634
+
635
+ // don't process external links or intra-document anchors
636
+ const isInternal = !(isAbsoluteUrl(dest) || dest.startsWith('#'));
637
+ if (isInternal) {
638
+ dest = node.properties.href = transformLink(page.slug, dest, transformOptions);
639
+
640
+ const url = new URL(dest, 'https://base.com/' + stripSlashes(curSlug, true));
641
+ const canonicalDest = url.pathname;
642
+ let [destCanonical, _destAnchor] = splitAnchor(canonicalDest);
643
+ if (destCanonical.endsWith('/')) {
644
+ destCanonical += 'index';
645
+ }
646
+ const full = decodeURIComponent(stripSlashes(destCanonical, true)) as FullSlug;
647
+ const simple = simplifySlug(full);
648
+ outgoing.add(simple);
649
+ node.properties['data-slug'] = full;
650
+ const fileDestination = fileRoutes.get(full);
651
+ if (fileDestination) {
652
+ node.properties.href = fileDestination + url.hash;
653
+ delete node.properties['data-slug'];
654
+ }
655
+
656
+ // broken-link audit (build artifact only; quartz had no equivalent)
657
+ if (
658
+ !fileDestination &&
659
+ !emittedSlugs.has(full) &&
660
+ !emittedSlugs.has(joinSegments(full, 'index'))
661
+ ) {
662
+ if (droppedSlugs.has(full)) {
663
+ warn(
664
+ `link-to-dropped-file: ${page.rel} -> "${dest}" (target "${full}" excluded by whitelist — review!)`
665
+ );
666
+ } else {
667
+ warn(
668
+ `broken link (also broken on live site): ${page.rel} -> "${dest}" (resolved to "${full}")`
669
+ );
670
+ }
671
+ }
672
+ }
673
+
674
+ // rewrite link internals if prettylinks is on
675
+ if (
676
+ isInternal &&
677
+ node.children.length === 1 &&
678
+ node.children[0].type === 'text' &&
679
+ !node.children[0].value.startsWith('#')
680
+ ) {
681
+ node.children[0].value = path.basename(node.children[0].value);
682
+ }
683
+ }
684
+
685
+ // transform all other resources that may use links
686
+ if (
687
+ ['img', 'video', 'audio', 'iframe'].includes(node.tagName) &&
688
+ node.properties &&
689
+ typeof node.properties.src === 'string'
690
+ ) {
691
+ if (!isAbsoluteUrl(node.properties.src)) {
692
+ let dest = node.properties.src as RelativeURL;
693
+ dest = node.properties.src = transformLink(page.slug, dest, transformOptions);
694
+ node.properties.src = dest;
695
+
696
+ const url = new URL(dest, 'https://base.com/' + stripSlashes(curSlug, true));
697
+ const full = decodeURIComponent(stripSlashes(url.pathname, true));
698
+ if (!emittedSlugs.has(full)) {
699
+ if (droppedSlugs.has(full)) {
700
+ warn(
701
+ `asset-ref-to-dropped-file: ${page.rel} -> "${dest}" (target "${full}" excluded by whitelist — review!)`
702
+ );
703
+ } else {
704
+ warn(`broken asset ref: ${page.rel} -> "${dest}" (resolved to "${full}")`);
705
+ }
706
+ }
707
+ }
708
+ }
709
+ });
710
+
711
+ return [...outgoing];
712
+ }
713
+
714
+ const headerRegex = new RegExp(/h[1-6]/);
715
+
716
+ /** Vendored transclusion inliner (quartz/components/renderPage.tsx
717
+ * renderTranscludes), run at build time, depth-limited (<=3, quartz recursed
718
+ * unboundedly via unist-util-visit's natural re-traversal). */
719
+ function inlineTranscludes(
720
+ root: HtmlRoot,
721
+ slug: FullSlug,
722
+ pagesBySlug: Map<string, PageParse>,
723
+ depth: number,
724
+ rel: string
725
+ ) {
726
+ if (depth > 3) return;
727
+ visitElements(root, (node): typeof SKIP_VISIT | undefined => {
728
+ if (node.tagName !== 'blockquote') return;
729
+ const classNames = (node.properties?.className ?? []) as string[];
730
+ if (!classNames.includes('transclude')) return;
731
+
732
+ const inner = node.children[0] as Element;
733
+ // already-expanded blockquotes no longer have the inner transclude anchor
734
+ if (!inner || inner.type !== 'element' || !inner.properties?.['data-slug']) return SKIP_VISIT;
735
+ const transcludeTarget = inner.properties['data-slug'] as FullSlug;
736
+ const page = pagesBySlug.get(transcludeTarget);
737
+ if (!page) {
738
+ warn(`transclude target not found: ${rel} -> ${transcludeTarget}`);
739
+ return SKIP_VISIT;
740
+ }
741
+ if (transcludeTarget === slug) {
742
+ warn(`self-transclusion skipped: ${rel}`);
743
+ return SKIP_VISIT;
744
+ }
745
+
746
+ let blockRef = node.properties?.dataBlock as string | undefined;
747
+ if (blockRef?.startsWith('#^')) {
748
+ // block transclude
749
+ blockRef = blockRef.slice('#^'.length);
750
+ let blockNode = page.blocks?.[blockRef];
751
+ if (blockNode) {
752
+ if (blockNode.tagName === 'li') {
753
+ blockNode = { type: 'element', tagName: 'ul', properties: {}, children: [blockNode] };
754
+ }
755
+ node.children = [
756
+ normalizeHastElement(blockNode, slug, transcludeTarget),
757
+ linkToOriginal(inner)
758
+ ];
759
+ }
760
+ } else if (blockRef?.startsWith('#') && page.tree) {
761
+ // header transclude
762
+ blockRef = blockRef.slice(1);
763
+ let startIdx: number | undefined = undefined;
764
+ let startDepth: number | undefined = undefined;
765
+ let endIdx: number | undefined = undefined;
766
+ for (const [i, el] of page.tree.children.entries()) {
767
+ if (!(el.type === 'element' && el.tagName.match(headerRegex))) continue;
768
+ const depth_ = Number(el.tagName.substring(1));
769
+ if (startIdx === undefined || startDepth === undefined) {
770
+ if (el.properties?.id === blockRef) {
771
+ startIdx = i;
772
+ startDepth = depth_;
773
+ }
774
+ } else if (depth_ <= startDepth) {
775
+ endIdx = i;
776
+ break;
777
+ }
778
+ }
779
+ if (startIdx === undefined) {
780
+ warn(`transclude header not found: ${rel} -> ${transcludeTarget}#${blockRef}`);
781
+ return;
782
+ }
783
+ node.children = [
784
+ ...(page.tree.children.slice(startIdx, endIdx) as ElementContent[]).map((child) =>
785
+ normalizeHastElement(child as Element, slug, transcludeTarget)
786
+ ),
787
+ linkToOriginal(inner)
788
+ ];
789
+ } else if (page.tree) {
790
+ // page transclude
791
+ node.children = [
792
+ {
793
+ type: 'element',
794
+ tagName: 'h1',
795
+ properties: {},
796
+ children: [
797
+ {
798
+ type: 'text',
799
+ value: page.frontmatter?.title ?? `Transclude of ${page.slug}`
800
+ }
801
+ ]
802
+ },
803
+ ...(page.tree.children as ElementContent[]).map((child) =>
804
+ normalizeHastElement(child as Element, slug, transcludeTarget)
805
+ ),
806
+ linkToOriginal(inner)
807
+ ];
808
+ }
809
+
810
+ // nested transcludes inside the inlined content (explicit recursion with
811
+ // depth accounting; SKIP stops the outer traversal from re-descending)
812
+ inlineTranscludes(
813
+ { type: 'root', children: node.children } as HtmlRoot,
814
+ slug,
815
+ pagesBySlug,
816
+ depth + 1,
817
+ rel
818
+ );
819
+ return SKIP_VISIT;
820
+ });
821
+ }
822
+
823
+ function linkToOriginal(inner: Element): ElementContent {
824
+ return {
825
+ type: 'element',
826
+ tagName: 'a',
827
+ properties: { href: inner.properties?.href, class: ['internal', 'transclude-src'] },
828
+ children: [{ type: 'text', value: 'Link to original' }]
829
+ };
830
+ }
831
+
832
+ // ---------------------------------------------------------------------------
833
+ // stage 3 — emit
834
+ // ---------------------------------------------------------------------------
835
+ interface NavNode {
836
+ name: string;
837
+ slug: string; // folder simple slug, e.g. "sp26-cs537/p1"
838
+ title: string;
839
+ children: NavNode[];
840
+ pages: { slug: string; title: string }[];
841
+ }
842
+
843
+ function buildTree(pages: PageParse[]): NavNode {
844
+ const root: NavNode = { name: '', slug: '', title: 'Notes', children: [], pages: [] };
845
+ const dirOf = (slug: string) => slug.split('/').slice(0, -1).join('/');
846
+ const folderNode = (dir: string): NavNode => {
847
+ if (dir === '') return root;
848
+ const parts = dir.split('/');
849
+ let cur = root;
850
+ let acc: string[] = [];
851
+ for (const part of parts) {
852
+ acc.push(part);
853
+ let next = cur.children.find((c) => c.name === part);
854
+ if (!next) {
855
+ next = { name: part, slug: acc.join('/'), title: part, children: [], pages: [] };
856
+ cur.children.push(next);
857
+ }
858
+ cur = next;
859
+ }
860
+ return cur;
861
+ };
862
+ for (const page of pages) {
863
+ const dir = dirOf(page.slug);
864
+ const node = folderNode(dir);
865
+ if (page.slug.split('/').pop() === 'index') {
866
+ node.title = page.title; // folder with its own index page
867
+ }
868
+ node.pages.push({ slug: page.slug, title: page.title });
869
+ }
870
+ const sortNode = (n: NavNode) => {
871
+ n.children.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
872
+ n.pages.sort((a, b) => a.slug.localeCompare(b.slug, undefined, { numeric: true }));
873
+ n.children.forEach(sortNode);
874
+ };
875
+ sortNode(root);
876
+ return root;
877
+ }
878
+
879
+ async function main() {
880
+ const t0 = performance.now();
881
+ fs.mkdirSync(OUT_DIR, { recursive: true });
882
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
883
+ const wantedPages = new Set<string>();
884
+ const wantedAssets = new Set<string>();
885
+ fs.mkdirSync(ASSETS_DIR, { recursive: true });
886
+
887
+ // stage 0
888
+ const { files: discovered, submoduleNames } = discover();
889
+ const publicationFor = publicationResolver(REPO_ROOT, project);
890
+ const licenseFor = courseLicenseResolver(REPO_ROOT, project);
891
+ const files = publicEdition
892
+ ? discovered.filter((file) => publicationFor(file.rel).public)
893
+ : discovered;
894
+ const pagesSrc = discovered.filter((f) => f.kind === 'page');
895
+ const assetsSrc = files.filter((f) => f.kind === 'asset');
896
+ const otherSrc = files.filter((f) => f.kind === 'other');
897
+ console.log(
898
+ `discover: ${files.length} tracked+visible files — ${pagesSrc.length} md, ${assetsSrc.length} whitelisted assets, ${otherSrc.length} dropped`
899
+ );
900
+
901
+ // resolution slug set = "**/*.*"-visible files only (matches quartz ctx.allSlugs;
902
+ // extensionless served files are deliberately NOT in it, same as quartz)
903
+ const allSlugs = discovered.filter((f) => f.inResolution).map((f) => f.slug);
904
+
905
+ // git dates (one batched walk per submodule, HEAD-keyed cache)
906
+ const tDates = performance.now();
907
+ const gitDates = buildGitDateMap(REPO_ROOT, CACHE_DIR, project);
908
+ console.log(
909
+ `git dates: ${Object.keys(gitDates.modified).length} paths in ${Math.round(performance.now() - tDates)}ms`
910
+ );
911
+
912
+ // stage 1
913
+ const t1 = performance.now();
914
+ const previousIndex =
915
+ options.incremental === false
916
+ ? undefined
917
+ : readContentIndex(CACHE_DIR, edition, PIPELINE_VERSION);
918
+ const currentRecords: Record<string, ContentCachePage> = {};
919
+ const loaded = new Set<string>();
920
+ const changedSlugs = new Set<string>();
921
+ const sourceByRel = new Map(pagesSrc.map((file) => [file.rel, file]));
922
+ let compactHits = 0;
923
+ const parses: PageParse[] = [];
924
+ for (const f of pagesSrc) {
925
+ try {
926
+ const raw = fs.readFileSync(f.abs, 'utf8');
927
+ const parseKey = sha256(`${PIPELINE_VERSION}|${f.rel}|${raw}`);
928
+ parseCacheFiles.add(`stage1/${parseKey.slice(0, 2)}/${parseKey}.json`);
929
+ const saved = previousIndex?.pages[f.rel];
930
+ if (saved?.parseKey === parseKey && saved.summary.slug === f.slug) {
931
+ parses.push({
932
+ ...saved.summary,
933
+ markdown: '',
934
+ blocks: {},
935
+ tree: { type: 'root', children: [] }
936
+ });
937
+ currentRecords[f.rel] = { ...saved };
938
+ warnings.push(...saved.parseWarnings);
939
+ for (const [language, count] of Object.entries(saved.unknownLanguages ?? {}))
940
+ unknownLangs.set(language, (unknownLangs.get(language) ?? 0) + count);
941
+ parseHits++;
942
+ compactHits++;
943
+ } else {
944
+ const warningStart = warnings.length;
945
+ const previousLanguages = new Map(unknownLangs);
946
+ const page = await parsePage(f, raw);
947
+ parses.push(page);
948
+ loaded.add(f.rel);
949
+ changedSlugs.add(f.slug);
950
+ currentRecords[f.rel] = {
951
+ parseKey,
952
+ summary: summary(page),
953
+ outgoing: [],
954
+ transcludes: [],
955
+ parseWarnings: warnings.slice(warningStart),
956
+ unknownLanguages: Object.fromEntries(
957
+ [...unknownLangs].flatMap(([language, count]) =>
958
+ count > (previousLanguages.get(language) ?? 0)
959
+ ? [[language, count - (previousLanguages.get(language) ?? 0)]]
960
+ : []
961
+ )
962
+ ),
963
+ linkWarnings: [],
964
+ transclusionWarnings: []
965
+ };
966
+ }
967
+ } catch (e) {
968
+ console.error(`PARSE FAILED: ${f.rel}`);
969
+ throw e;
970
+ }
971
+ }
972
+ console.log(
973
+ `parse: ${parses.length} pages in ${Math.round(performance.now() - t1)}ms (${parseHits} cached, ${parses.length - parseHits} parsed)`
974
+ );
975
+ console.log(`content cache: ${compactHits} compact metadata hits, ${loaded.size} ASTs loaded`);
976
+ for (const [relative, saved] of Object.entries(previousIndex?.pages ?? {}))
977
+ if (!sourceByRel.has(relative)) changedSlugs.add(saved.summary.slug);
978
+ const hydrate = async (page: PageParse) => {
979
+ if (loaded.has(page.rel)) return;
980
+ // Existing diagnostics are replayed from the compact record, not duplicated during hydration.
981
+ const start = warnings.length;
982
+ const previousLanguages = new Map(unknownLangs);
983
+ Object.assign(page, await parsePage(sourceByRel.get(page.rel)!));
984
+ warnings.length = start;
985
+ unknownLangs.clear();
986
+ for (const [language, count] of previousLanguages) unknownLangs.set(language, count);
987
+ loaded.add(page.rel);
988
+ };
989
+
990
+ const drafts = parses.filter((p) => p.draft);
991
+ const catalog = parses.filter((p) => !p.draft);
992
+ const lockedPages = publicEdition
993
+ ? catalog.filter((page) => !publicationFor(page.rel).public)
994
+ : [];
995
+ const pages = publicEdition
996
+ ? catalog.filter((page) => publicationFor(page.rel).public)
997
+ : catalog;
998
+ if (drafts.length > 0) {
999
+ console.log(`drafts skipped: ${drafts.length} (${drafts.map((d) => d.rel).join(', ')})`);
1000
+ }
1001
+
1002
+ // stage 2
1003
+ const t2 = performance.now();
1004
+ const transformOptions: TransformOptions = createTransformOptions(allSlugs);
1005
+ // emitted output set for broken-link audit: pages + assets + folder pages + tag pages
1006
+ const pageSlugSet = new Set<string>(pages.map((p) => p.slug as string));
1007
+ const folderSet = new Set<string>();
1008
+ for (const p of pages) {
1009
+ const parts = (p.slug as string).split('/');
1010
+ for (let i = 1; i < parts.length; i++) {
1011
+ folderSet.add(parts.slice(0, i).join('/'));
1012
+ }
1013
+ }
1014
+ const allTags = new Set<string>(catalog.flatMap((p) => p.tags.flatMap(getAllSegmentPrefixes)));
1015
+ const emittedSlugs = new Set<string>([
1016
+ ...pageSlugSet,
1017
+ ...assetsSrc.map((a) => a.slug as string),
1018
+ ...folderSet, // folder pages (route serves /<dir>)
1019
+ ...[...folderSet].map((f) => `${f}/index`),
1020
+ ...[...allTags].map((t) => `tags/${t}`),
1021
+ 'index',
1022
+ 'tags',
1023
+ 'tags/index'
1024
+ ]);
1025
+
1026
+ // Non-note files have catalog routes even when their contents are private.
1027
+ const fileRoutes = new Map<string, string>();
1028
+ for (const file of discovered) {
1029
+ const [course, ...parts] = file.rel.split('/');
1030
+ const relative = parts.join('/');
1031
+ if (file.kind === 'other' && submoduleNames.includes(course) && browsablePath(relative))
1032
+ fileRoutes.set(file.slug, fileRoute(course, relative));
1033
+ }
1034
+ const droppedSlugs = new Set<string>(otherSrc.map((f) => f.slug as string));
1035
+ const catalogSlugs = new Set([
1036
+ ...emittedSlugs,
1037
+ ...catalog.map((page) => page.slug as string),
1038
+ ...[...fileRoutes.values()].map((route) => decodeURIComponent(route.slice(1)))
1039
+ ]);
1040
+ const assetPaths = htmlAssetPaths(assetsSrc);
1041
+ // Ordering of shortest-link candidates is semantic; do not reduce allSlugs to a set.
1042
+ const context = contentHash(
1043
+ JSON.stringify([
1044
+ 'content-resolution-v1',
1045
+ [project.discovery, project.publication],
1046
+ edition,
1047
+ allSlugs,
1048
+ [...emittedSlugs],
1049
+ [...catalogSlugs],
1050
+ [...droppedSlugs],
1051
+ [...fileRoutes],
1052
+ [...assetPaths],
1053
+ catalog.map((page) => [page.rel, publicationFor(page.rel)]),
1054
+ Intl.Collator().resolvedOptions().locale
1055
+ ])
1056
+ );
1057
+ const sameContext = previousIndex?.context === context;
1058
+ const bodies = new Map<string, ContentBody>();
1059
+ const renderDirty = new Set(changedSlugs);
1060
+ for (const page of pages) {
1061
+ const saved = currentRecords[page.rel];
1062
+ const body =
1063
+ sameContext && !changedSlugs.has(page.slug) && saved.body
1064
+ ? readContentBody(CACHE_DIR, saved.body, edition)
1065
+ : undefined;
1066
+ if (body) bodies.set(page.rel, body);
1067
+ else renderDirty.add(page.slug);
1068
+ }
1069
+ const affected = transclusionClosure(Object.values(previousIndex?.pages ?? {}), renderDirty);
1070
+ for (const slug of affected) renderDirty.add(slug);
1071
+ const changedTransclusion = catalog.some(
1072
+ (page) =>
1073
+ changedSlugs.has(page.slug) &&
1074
+ (currentRecords[page.rel].transcludes.length > 0 ||
1075
+ (loaded.has(page.rel) && hasTranscludes(page.tree)))
1076
+ );
1077
+ const fullResolve =
1078
+ !sameContext ||
1079
+ changedTransclusion ||
1080
+ (affected.size > changedSlugs.size &&
1081
+ Object.values(previousIndex?.pages ?? {}).some(
1082
+ (page) => page.transcludes.length > 0 && renderDirty.has(page.summary.slug)
1083
+ ));
1084
+ if (!sameContext) for (const page of pages) renderDirty.add(page.slug);
1085
+ if (fullResolve) for (const page of catalog) await hydrate(page);
1086
+ else
1087
+ for (const page of catalog)
1088
+ if (changedSlugs.has(page.slug) || renderDirty.has(page.slug)) await hydrate(page);
1089
+ const outgoingBySlug = new Map<string, SimpleSlug[]>();
1090
+ for (const page of catalog) {
1091
+ const record = currentRecords[page.rel];
1092
+ if (fullResolve || loaded.has(page.rel)) {
1093
+ const start = warnings.length;
1094
+ const outgoing = crawlLinks(page, transformOptions, catalogSlugs, droppedSlugs, fileRoutes);
1095
+ if (publicEdition && publicationFor(page.rel).public)
1096
+ protectPublicLinks(page.tree, page.slug, emittedSlugs, catalogSlugs);
1097
+ record.outgoing = outgoing;
1098
+ record.transcludes = directTranscludes(page.tree);
1099
+ record.linkWarnings = warnings.slice(start);
1100
+ } else warnings.push(...record.linkWarnings);
1101
+ outgoingBySlug.set(page.slug, record.outgoing as SimpleSlug[]);
1102
+ }
1103
+
1104
+ // tag pages exist for every tag (incl. hierarchical prefixes) — add them to
1105
+ // the emitted set retroactively for the link audit? (tag links are generated
1106
+ // by the pipeline itself and always valid, so nothing to do)
1107
+
1108
+ // transclusions (after link resolution, mirrors quartz render order)
1109
+ const pagesBySlug = new Map<string, PageParse>(pages.map((p) => [p.slug as string, p]));
1110
+ for (const page of pages) {
1111
+ const record = currentRecords[page.rel];
1112
+ if (fullResolve || loaded.has(page.rel)) {
1113
+ const start = warnings.length;
1114
+ inlineTranscludes(page.tree, page.slug, pagesBySlug, 1, page.rel);
1115
+ record.transclusionWarnings = warnings.slice(start);
1116
+ } else warnings.push(...record.transclusionWarnings);
1117
+ }
1118
+
1119
+ // backlinks (simple-slug space, like quartz)
1120
+ const backlinks = new Map<string, Set<string>>();
1121
+ for (const page of pages) {
1122
+ const fromSimple = simplifySlug(page.slug);
1123
+ for (const out of outgoingBySlug.get(page.slug) ?? []) {
1124
+ if (!backlinks.has(out)) backlinks.set(out, new Set());
1125
+ backlinks.get(out)!.add(fromSimple);
1126
+ }
1127
+ }
1128
+
1129
+ for (const page of pages) {
1130
+ if (fullResolve || loaded.has(page.rel)) rewriteContentUrls(page.tree, page.slug, assetPaths);
1131
+ }
1132
+ console.log(
1133
+ `content cache: ${fullResolve ? 'full resolution fallback' : 'incremental resolution'}, ${renderDirty.size} dirty bodies, ${loaded.size} ASTs loaded`
1134
+ );
1135
+ console.log(`resolve: links+transcludes+backlinks in ${Math.round(performance.now() - t2)}ms`);
1136
+
1137
+ // stage 3
1138
+ const t3 = performance.now();
1139
+ const sizeViolations: string[] = [];
1140
+ const checkSize = (outPath: string) => {
1141
+ const { size } = fs.statSync(outPath);
1142
+ if (size > SIZE_FAIL) {
1143
+ sizeViolations.push(
1144
+ `${outPath} is ${(size / 1024 / 1024).toFixed(1)}MiB (> 25MiB Workers cap)`
1145
+ );
1146
+ } else if (size > SIZE_WARN) {
1147
+ warn(`size: ${outPath} is ${(size / 1024 / 1024).toFixed(1)}MiB (> 20MiB warn threshold)`);
1148
+ }
1149
+ };
1150
+
1151
+ // assets
1152
+ let assetBytes = 0;
1153
+ for (const asset of assetsSrc) {
1154
+ // html assets: extensionless at slug, exactly like quartz's Assets emitter
1155
+ const dest = path.join(ASSETS_DIR, asset.slug);
1156
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
1157
+ copyChanged(asset.abs, dest);
1158
+ wantedAssets.add(dest);
1159
+ assetBytes += fs.statSync(dest).size;
1160
+ checkSize(dest);
1161
+ }
1162
+
1163
+ // per-page JSON
1164
+ const simpleToFull = new Map<string, string>();
1165
+ for (const p of pages) simpleToFull.set(simplifySlug(p.slug), p.slug);
1166
+ const pageMeta: Record<string, unknown> = {};
1167
+ const tagIndex = new Map<string, string[]>();
1168
+ for (const page of catalog) {
1169
+ for (const tag of page.tags.flatMap(getAllSegmentPrefixes)) {
1170
+ if (!tagIndex.has(tag)) tagIndex.set(tag, []);
1171
+ if (!tagIndex.get(tag)!.includes(page.slug)) tagIndex.get(tag)!.push(page.slug);
1172
+ }
1173
+ }
1174
+
1175
+ for (const page of pages) {
1176
+ const dates = resolveDates({
1177
+ relativePath: page.rel,
1178
+ frontmatter: page.frontmatter,
1179
+ gitDates,
1180
+ warn
1181
+ });
1182
+ const simple = simplifySlug(page.slug);
1183
+ const pageBacklinks = [...(backlinks.get(simple) ?? [])]
1184
+ .filter((s) => s !== simple)
1185
+ .sort()
1186
+ .map((s) => {
1187
+ const full = simpleToFull.get(s);
1188
+ const p = full ? pagesBySlug.get(full) : undefined;
1189
+ return { slug: s, title: p?.title ?? s };
1190
+ });
1191
+
1192
+ const record = currentRecords[page.rel];
1193
+ let body = bodies.get(page.rel);
1194
+ if (renderDirty.has(page.slug)) {
1195
+ // Apply after cached parses and transclusions have been resolved.
1196
+ ofmCheckboxes()(page.tree);
1197
+ body = { html: toHtml(page.tree, { allowDangerousHtml: true }), markdown: page.markdown };
1198
+ record.body = writeContentObject(CACHE_DIR, edition, 'bodies', JSON.stringify(body));
1199
+ }
1200
+ if (!body || !record.body) throw new Error(`Missing resolved content body: ${page.rel}`);
1201
+ const meta = {
1202
+ author: contentAuthor(page.frontmatter.author),
1203
+ license: licenseFor(page.rel),
1204
+ publication: publicEdition ? { public: true } : publicationFor(page.rel),
1205
+ slug: page.slug,
1206
+ title: page.title,
1207
+ description: page.description,
1208
+ heading: page.heading,
1209
+ tags: page.tags,
1210
+ dates: {
1211
+ created: dates.created?.toISOString() ?? '',
1212
+ modified: dates.modified?.toISOString() ?? '',
1213
+ published: dates.published?.toISOString() ?? ''
1214
+ },
1215
+ links: outgoingBySlug.get(page.slug) ?? [],
1216
+ backlinks: pageBacklinks.map((b) => b.slug),
1217
+ wordCount: page.wordCount,
1218
+ readingTime: Math.max(1, Math.ceil(page.wordCount / 200)),
1219
+ hasMermaid: page.hasMermaid,
1220
+ relativePath: page.rel
1221
+ };
1222
+ pageMeta[page.slug] = meta;
1223
+
1224
+ const outPath = path.join(OUT_DIR, 'pages', page.slug + '.json');
1225
+ wantedPages.add(outPath);
1226
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
1227
+ const documentKey = contentHash(JSON.stringify([meta, page.toc, pageBacklinks, record.body]));
1228
+ let document =
1229
+ record.documentKey === documentKey && record.document
1230
+ ? readContentObject(CACHE_DIR, record.document, edition)
1231
+ : undefined;
1232
+ if (!document) {
1233
+ document = Buffer.from(
1234
+ JSON.stringify({
1235
+ ...meta,
1236
+ toc: page.toc,
1237
+ backlinks: pageBacklinks,
1238
+ html: body.html,
1239
+ markdown: body.markdown
1240
+ })
1241
+ );
1242
+ record.document = writeContentObject(CACHE_DIR, edition, 'docs', document);
1243
+ record.documentKey = documentKey;
1244
+ }
1245
+ writeChanged(outPath, document);
1246
+ checkSize(outPath);
1247
+ }
1248
+
1249
+ for (const page of lockedPages) {
1250
+ const meta = {
1251
+ locked: true,
1252
+ publication: { public: false },
1253
+ slug: page.slug,
1254
+ title: page.title,
1255
+ description: '',
1256
+ heading: page.heading,
1257
+ tags: page.tags,
1258
+ dates: { created: '', modified: '', published: '' },
1259
+ links: [],
1260
+ backlinks: [],
1261
+ wordCount: 0,
1262
+ readingTime: 0,
1263
+ hasMermaid: false,
1264
+ relativePath: page.rel
1265
+ };
1266
+ pageMeta[page.slug] = meta;
1267
+ const outPath = path.join(OUT_DIR, 'pages', page.slug + '.json');
1268
+ wantedPages.add(outPath);
1269
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
1270
+ const record = currentRecords[page.rel];
1271
+ const documentKey = contentHash(JSON.stringify([meta, page.toc, 'locked']));
1272
+ let document =
1273
+ record.documentKey === documentKey && record.document
1274
+ ? readContentObject(CACHE_DIR, record.document, edition)
1275
+ : undefined;
1276
+ if (!document) {
1277
+ document = Buffer.from(JSON.stringify({ ...meta, toc: page.toc, html: '' }));
1278
+ record.document = writeContentObject(CACHE_DIR, edition, 'docs', document);
1279
+ record.documentKey = documentKey;
1280
+ }
1281
+ writeChanged(outPath, document);
1282
+ const parts = page.slug.split('/');
1283
+ for (let i = 1; i < parts.length; i++) folderSet.add(parts.slice(0, i).join('/'));
1284
+ }
1285
+
1286
+ // manifest
1287
+ const tree = buildTree(catalog);
1288
+ const catalogNodes = new Set(catalog.map((page) => simplifySlug(page.slug)));
1289
+ const graph = {
1290
+ nodes: catalog.map((p) => ({
1291
+ id: simplifySlug(p.slug),
1292
+ title: p.title,
1293
+ tags: p.tags
1294
+ })),
1295
+ links: catalog.flatMap((p) =>
1296
+ (outgoingBySlug.get(p.slug) ?? [])
1297
+ .filter((out) => catalogNodes.has(out))
1298
+ .map((out) => ({ source: simplifySlug(p.slug), target: out }))
1299
+ )
1300
+ };
1301
+ const manifest = {
1302
+ generatedAt: new Date().toISOString(),
1303
+ counts: {
1304
+ pages: pages.length,
1305
+ drafts: drafts.length,
1306
+ assets: assetsSrc.length,
1307
+ images: assetsSrc.filter((a) =>
1308
+ IMAGE_EXTS.has((getFileExtension(a.rel) ?? '').toLowerCase())
1309
+ ).length,
1310
+ pdfs: assetsSrc.filter((a) => a.rel.toLowerCase().endsWith('.pdf')).length,
1311
+ htmlAssets: assetsSrc.filter((a) => a.rel.toLowerCase().endsWith('.html')).length,
1312
+ dropped: otherSrc.length
1313
+ },
1314
+ pages: pageMeta,
1315
+ folders: [...folderSet].sort(),
1316
+ tags: Object.fromEntries(
1317
+ [...tagIndex.entries()]
1318
+ .sort(([a], [b]) => a.localeCompare(b))
1319
+ .map(([t, slugs]) => [t, slugs.sort()])
1320
+ ),
1321
+ tree,
1322
+ assets: assetsSrc.map((a) => a.slug),
1323
+ htmlAssets: assetsSrc.filter((a) => a.rel.toLowerCase().endsWith('.html')).map((a) => a.slug),
1324
+ graph
1325
+ };
1326
+ const manifestFile = path.join(OUT_DIR, 'content-manifest.json');
1327
+ if (fs.existsSync(manifestFile)) {
1328
+ const previous = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
1329
+ if (
1330
+ JSON.stringify({ ...manifest, generatedAt: previous.generatedAt }) ===
1331
+ JSON.stringify(previous)
1332
+ )
1333
+ manifest.generatedAt = previous.generatedAt;
1334
+ }
1335
+ writeChanged(manifestFile, JSON.stringify(manifest, null, 1));
1336
+
1337
+ // dropped-urls.txt — tracked files the old Quartz site served but the
1338
+ // whitelist excludes.
1339
+ writeChanged(
1340
+ path.join(OUT_DIR, 'dropped-urls.txt'),
1341
+ otherSrc.map((f) => '/' + f.slug).join('\n') + '\n'
1342
+ );
1343
+
1344
+ // warnings
1345
+ if (unknownLangs.size > 0) {
1346
+ warn(
1347
+ `shiki: unknown languages rendered as plaintext: ${[...unknownLangs.entries()]
1348
+ .map(([l, n]) => `${l} (${n})`)
1349
+ .join(', ')}`
1350
+ );
1351
+ }
1352
+ writeChanged(path.join(OUT_DIR, 'warnings.txt'), warnings.join('\n') + '\n');
1353
+
1354
+ console.log(
1355
+ `emit: ${pages.length} page JSONs, ${assetsSrc.length} assets (${(assetBytes / 1024 / 1024).toFixed(1)}MiB) in ${Math.round(performance.now() - t3)}ms`
1356
+ );
1357
+ console.log(
1358
+ `counts: pages=${pages.length} drafts=${drafts.length} assets=${assetsSrc.length} (images=${manifest.counts.images} pdfs=${manifest.counts.pdfs} html=${manifest.counts.htmlAssets}) dropped=${otherSrc.length} tags=${tagIndex.size} folders=${folderSet.size}`
1359
+ );
1360
+ console.log(`warnings: ${warnings.length} (site/build/generated/warnings.txt)`);
1361
+
1362
+ if (sizeViolations.length > 0) {
1363
+ console.error('SIZE FAILURES:\n' + sizeViolations.join('\n'));
1364
+ throw new Error('Content exceeds the deployment asset limit');
1365
+ }
1366
+ pruneOutputs(path.join(OUT_DIR, 'pages'), wantedPages);
1367
+ pruneOutputs(
1368
+ ASSETS_DIR,
1369
+ wantedAssets,
1370
+ (file) => !publicEdition && file === path.join(ASSETS_DIR, '_files')
1371
+ );
1372
+ completedIndex = { schema: 1, renderer: PIPELINE_VERSION, context, pages: currentRecords };
1373
+ console.log(`done in ${((performance.now() - t0) / 1000).toFixed(1)}s`);
1374
+ }
1375
+
1376
+ try {
1377
+ await main();
1378
+ writeChanged(
1379
+ path.join(CACHE_DIR, 'stage1-current.json'),
1380
+ JSON.stringify([...parseCacheFiles].sort())
1381
+ );
1382
+ if (completedIndex) writeContentIndex(CACHE_DIR, edition, completedIndex);
1383
+ } finally {
1384
+ highlighter?.dispose();
1385
+ }
1386
+ }