@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.
- package/.github/actions/product-cache/action.yml +32 -0
- package/.github/actions/product-cache/index.mjs +450 -0
- package/LICENSE +21 -0
- package/README.md +146 -2
- package/bun.lock +1701 -0
- package/cli/runtime.test.ts +420 -0
- package/cli/runtime.ts +385 -0
- package/cli/twiki.ts +305 -0
- package/package.json +138 -4
- package/site/_headers +7 -0
- package/site/assets/fonts/OFL.txt +93 -0
- package/site/assets/fonts/OverusedGrotesk-SemiBold.ttf +0 -0
- package/site/assets/social-background.svg +38 -0
- package/site/bun.lock +1701 -0
- package/site/database/index.ts +7 -0
- package/site/database/schema.ts +95 -0
- package/site/drizzle.config.ts +7 -0
- package/site/migrations/0001_auth.sql +14 -0
- package/site/migrations/0002_site_access.sql +5 -0
- package/site/migrations/0003_github_username.sql +1 -0
- package/site/migrations/meta/0002_snapshot.json +432 -0
- package/site/migrations/meta/0003_snapshot.json +439 -0
- package/site/migrations/meta/_journal.json +27 -0
- package/site/package.json +120 -0
- package/site/src/app.d.ts +14 -0
- package/site/src/app.html +11 -0
- package/site/src/hooks.server.ts +12 -0
- package/site/src/lib/analytics-config.json +1 -0
- package/site/src/lib/analytics.ts +40 -0
- package/site/src/lib/auth-config.json +3 -0
- package/site/src/lib/components/SEO.svelte +113 -0
- package/site/src/lib/components/doc/AccessLink.svelte +27 -0
- package/site/src/lib/components/doc/Backlinks.svelte +59 -0
- package/site/src/lib/components/doc/Breadcrumbs.svelte +29 -0
- package/site/src/lib/components/doc/ContentMeta.svelte +61 -0
- package/site/src/lib/components/doc/DocPager.svelte +91 -0
- package/site/src/lib/components/doc/DocShell.svelte +115 -0
- package/site/src/lib/components/doc/LicenseNotice.svelte +26 -0
- package/site/src/lib/components/doc/LockedContent.svelte +29 -0
- package/site/src/lib/components/doc/NavTree.svelte +158 -0
- package/site/src/lib/components/doc/NoteActions.svelte +139 -0
- package/site/src/lib/components/doc/PublicationBadge.svelte +49 -0
- package/site/src/lib/components/doc/ReaderModeToggle.svelte +19 -0
- package/site/src/lib/components/doc/Sidebar.svelte +351 -0
- package/site/src/lib/components/doc/Toc.svelte +339 -0
- package/site/src/lib/components/doc/enhancements.ts +146 -0
- package/site/src/lib/components/doc/heading-tracker.svelte.ts +39 -0
- package/site/src/lib/components/doc/image-preview.ts +114 -0
- package/site/src/lib/components/doc/reader-mode.svelte.ts +40 -0
- package/site/src/lib/components/embeds/mermaid.ts +353 -0
- package/site/src/lib/components/files/FileCode.svelte +81 -0
- package/site/src/lib/components/files/FileExplorer.svelte +74 -0
- package/site/src/lib/components/files/FileHistory.svelte +170 -0
- package/site/src/lib/components/files/FileIcon.svelte +28 -0
- package/site/src/lib/components/files/FileRmd.svelte +209 -0
- package/site/src/lib/components/files/FileTabs.svelte +180 -0
- package/site/src/lib/components/files/FileTree.svelte +67 -0
- package/site/src/lib/components/files/blame-gutter.ts +45 -0
- package/site/src/lib/components/files/code-view.ts +98 -0
- package/site/src/lib/components/files/file-workspace.svelte.ts +100 -0
- package/site/src/lib/components/graph/GraphPanel.svelte +275 -0
- package/site/src/lib/components/graph/GraphView.svelte +100 -0
- package/site/src/lib/components/graph/graph-data.ts +137 -0
- package/site/src/lib/components/graph/graph-model.ts +103 -0
- package/site/src/lib/components/graph/index.ts +3 -0
- package/site/src/lib/components/graph/render-graph.ts +626 -0
- package/site/src/lib/components/popover/index.ts +1 -0
- package/site/src/lib/components/popover/link-popovers.ts +233 -0
- package/site/src/lib/components/popover/popover.css +82 -0
- package/site/src/lib/components/search/SearchButton.svelte +57 -0
- package/site/src/lib/components/search/SearchPalette.svelte +189 -0
- package/site/src/lib/components/search/index.ts +3 -0
- package/site/src/lib/components/search/pagefind-client.svelte.ts +129 -0
- package/site/src/lib/components/search/pagefind-query.ts +22 -0
- package/site/src/lib/components/search/pagefind-url.ts +12 -0
- package/site/src/lib/components/search/search-state.svelte.ts +22 -0
- package/site/src/lib/components/ui/IconButton.svelte +30 -0
- package/site/src/lib/components/ui/ThemeToggle.svelte +22 -0
- package/site/src/lib/components/ui/index.ts +1 -0
- package/site/src/lib/config.ts +9 -0
- package/site/src/lib/content-license.ts +7 -0
- package/site/src/lib/file-history.ts +37 -0
- package/site/src/lib/file-icons.ts +27 -0
- package/site/src/lib/file-tabs.ts +72 -0
- package/site/src/lib/files.ts +73 -0
- package/site/src/lib/github-user.ts +70 -0
- package/site/src/lib/metadata.ts +166 -0
- package/site/src/lib/notebook-nav.ts +63 -0
- package/site/src/lib/publication.ts +1 -0
- package/site/src/lib/rmd.ts +156 -0
- package/site/src/lib/server/access-admin.ts +33 -0
- package/site/src/lib/server/content-model.ts +253 -0
- package/site/src/lib/server/content-provider.ts +266 -0
- package/site/src/lib/server/content.ts +277 -0
- package/site/src/lib/server/runtime-content.ts +34 -0
- package/site/src/lib/site-config.json +9 -0
- package/site/src/lib/social-image.ts +13 -0
- package/site/src/lib/styles/article.css +607 -0
- package/site/src/lib/styles/foundation.css +138 -0
- package/site/src/lib/styles/print.css +115 -0
- package/site/src/lib/styles/shell.css +182 -0
- package/site/src/lib/text-exports.ts +5 -0
- package/site/src/lib/types.ts +140 -0
- package/site/src/lib/utils.ts +6 -0
- package/site/src/routes/+error.svelte +16 -0
- package/site/src/routes/+layout.server.ts +10 -0
- package/site/src/routes/+layout.svelte +72 -0
- package/site/src/routes/+layout.ts +3 -0
- package/site/src/routes/404/+page.svelte +13 -0
- package/site/src/routes/[...slug]/+page.server.ts +24 -0
- package/site/src/routes/[...slug]/+page.svelte +180 -0
- package/site/src/routes/[course]/files/[...file]/+page.svelte +373 -0
- package/site/src/routes/[course]/files/[...file]/+page.ts +38 -0
- package/site/src/routes/admin/access/+page.server.ts +53 -0
- package/site/src/routes/admin/access/+page.svelte +118 -0
- package/site/src/routes/graph.json/+server.ts +7 -0
- package/site/src/routes/index.xml/+server.ts +7 -0
- package/site/src/routes/layout.css +10 -0
- package/site/src/routes/robots.txt/+server.ts +9 -0
- package/site/src/routes/sitemap.xml/+server.ts +7 -0
- package/site/src/routes/tags/+page.server.ts +5 -0
- package/site/src/routes/tags/+page.svelte +38 -0
- package/site/src/routes/tags/[...tag]/+page.server.ts +22 -0
- package/site/src/routes/tags/[...tag]/+page.svelte +52 -0
- package/site/static/favicon.png +0 -0
- package/site/static/fonts/JetBrainsMono-Italic-VF.woff2 +0 -0
- package/site/static/fonts/JetBrainsMono-VF.woff2 +0 -0
- package/site/static/fonts/OverusedGrotesk-VF.woff2 +0 -0
- package/site/static/fonts/OverusedGroteskItalic-VF.woff2 +0 -0
- package/site/static/fonts/Virgil.woff2 +0 -0
- package/site/svelte.config.js +71 -0
- package/site/tooling/LICENSE-quartz +25 -0
- package/site/tooling/adapter.js +55 -0
- package/site/tooling/assets.ts +323 -0
- package/site/tooling/benchmark.ts +191 -0
- package/site/tooling/build.ts +407 -0
- package/site/tooling/cache.ts +133 -0
- package/site/tooling/check-diagrams.ts +164 -0
- package/site/tooling/check-links.ts +86 -0
- package/site/tooling/check-targets.ts +139 -0
- package/site/tooling/compiler.ts +1386 -0
- package/site/tooling/deploy.ts +112 -0
- package/site/tooling/fingerprint.ts +81 -0
- package/site/tooling/lib/app-version.js +92 -0
- package/site/tooling/lib/application-asset-urls.ts +27 -0
- package/site/tooling/lib/application-cache.ts +269 -0
- package/site/tooling/lib/asset-hash-cache.ts +116 -0
- package/site/tooling/lib/autotag.ts +99 -0
- package/site/tooling/lib/build-assembly.ts +148 -0
- package/site/tooling/lib/build-workspace.ts +386 -0
- package/site/tooling/lib/cache-selection.ts +70 -0
- package/site/tooling/lib/cloudflare-prewarm.js +40 -0
- package/site/tooling/lib/comments.ts +32 -0
- package/site/tooling/lib/content-cache.ts +244 -0
- package/site/tooling/lib/content-urls.ts +50 -0
- package/site/tooling/lib/course-cache.ts +298 -0
- package/site/tooling/lib/course-files.ts +281 -0
- package/site/tooling/lib/currency.ts +33 -0
- package/site/tooling/lib/deployment-config.ts +222 -0
- package/site/tooling/lib/deployment-manifest.ts +274 -0
- package/site/tooling/lib/deployment-prepare.ts +325 -0
- package/site/tooling/lib/deployment-upload.ts +293 -0
- package/site/tooling/lib/dev-git-state.ts +130 -0
- package/site/tooling/lib/dev-history.ts +86 -0
- package/site/tooling/lib/dev-social-images.ts +38 -0
- package/site/tooling/lib/dev-state.ts +107 -0
- package/site/tooling/lib/edition-builds.ts +184 -0
- package/site/tooling/lib/edition-paths.js +29 -0
- package/site/tooling/lib/empty-tag-route.js +23 -0
- package/site/tooling/lib/file-history.ts +201 -0
- package/site/tooling/lib/file-icons.ts +93 -0
- package/site/tooling/lib/file-policy.ts +38 -0
- package/site/tooling/lib/file-search.js +13 -0
- package/site/tooling/lib/git-revisions.ts +41 -0
- package/site/tooling/lib/history-pool.ts +74 -0
- package/site/tooling/lib/history-worker.ts +22 -0
- package/site/tooling/lib/knit-rmd.R +38 -0
- package/site/tooling/lib/lastmod.ts +201 -0
- package/site/tooling/lib/markdown-files.ts +51 -0
- package/site/tooling/lib/ofm.ts +634 -0
- package/site/tooling/lib/output.ts +60 -0
- package/site/tooling/lib/product-cache.ts +1006 -0
- package/site/tooling/lib/project-config.ts +288 -0
- package/site/tooling/lib/public-assets.ts +38 -0
- package/site/tooling/lib/public-links.ts +56 -0
- package/site/tooling/lib/publishing.ts +156 -0
- package/site/tooling/lib/r-runtime.ts +257 -0
- package/site/tooling/lib/rebuild-queue.ts +25 -0
- package/site/tooling/lib/rmd-preflight.ts +166 -0
- package/site/tooling/lib/rmd-preview-cache.ts +93 -0
- package/site/tooling/lib/rmd-previews.ts +129 -0
- package/site/tooling/lib/runtime-data-modules.ts +30 -0
- package/site/tooling/lib/runtime-packaging.ts +321 -0
- package/site/tooling/lib/search-cache.js +165 -0
- package/site/tooling/lib/search-inputs.js +129 -0
- package/site/tooling/lib/slug.ts +377 -0
- package/site/tooling/lib/social-images.ts +210 -0
- package/site/tooling/lib/text-exports.ts +51 -0
- package/site/tooling/lib/visit-elements.ts +17 -0
- package/site/tooling/live-test.ts +138 -0
- package/site/tooling/pipeline.ts +103 -0
- package/site/tooling/prepare.ts +3 -0
- package/site/tooling/publish.ts +28 -0
- package/site/tooling/r-cache.ts +45 -0
- package/site/tooling/r-render-profile.json +7 -0
- package/site/tooling/search.js +94 -0
- package/site/tooling/verify-deployment.ts +109 -0
- package/site/tooling/vite-content.ts +240 -0
- package/site/tooling/wrangler-paths.js +25 -0
- package/site/tooling/wrangler.ts +19 -0
- package/site/tsconfig.json +16 -0
- package/site/vite.config.ts +40 -0
- package/site/worker/access.ts +57 -0
- package/site/worker/analytics.ts +100 -0
- package/site/worker/auth-routes.ts +78 -0
- package/site/worker/auth.ts +78 -0
- package/site/worker/content-runtime.ts +226 -0
- package/site/worker/framing.ts +4 -0
- package/site/worker/gate.ts +147 -0
- package/site/worker/index.ts +9 -0
- package/site/worker/login.ts +62 -0
- package/site/worker/publication.ts +44 -0
- package/site/worker/runtime.ts +104 -0
- package/site/worker/tsconfig.json +13 -0
- 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('&', '&')
|
|
321
|
+
.replaceAll('<', '<')
|
|
322
|
+
.replaceAll('>', '>')
|
|
323
|
+
.replaceAll('"', '"')
|
|
324
|
+
.replaceAll("'", ''');
|
|
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
|
+
}
|