@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,634 @@
|
|
|
1
|
+
import { stripObsidianComments } from './comments';
|
|
2
|
+
/**
|
|
3
|
+
* Vendored from the wisconsin Quartz fork's `quartz/plugins/transformers/ofm.ts`,
|
|
4
|
+
* reshaped as plain unified plugins for the standalone prebuild.
|
|
5
|
+
*
|
|
6
|
+
* Grammar kept verbatim: comments `%%..%%`, wikilink-in-table escapes, external-URL
|
|
7
|
+
* wikilinks (text phase); wikilinks `[[x]]` / `[[x|alias]]` / `[[x#h]]`, embeds
|
|
8
|
+
* `![[img|WxH]]` / `![[doc.pdf]]` / `![[note]]` (note transclusion emits the fork's
|
|
9
|
+
* `<blockquote class="transclude">` placeholder, inlined in pass 2 of the prebuild),
|
|
10
|
+
* callouts incl. collapsed `[!type]-` with the fork's `callout-content` /
|
|
11
|
+
* `callout-content-inner` div structure, `==highlights==`, arrows, inline `#tags`
|
|
12
|
+
* with the fork's numeric-tag filtering, mermaid fence marking, block references,
|
|
13
|
+
* and the YouTube-image-to-iframe html-phase rewrite.
|
|
14
|
+
*
|
|
15
|
+
* Intentional divergences (noted inline too):
|
|
16
|
+
* - The Quartz plugin-wrapper machinery (QuartzTransformerPlugin, externalResources,
|
|
17
|
+
* inline browser scripts) is dropped; this module exports the raw plugin fns.
|
|
18
|
+
* - `enableInHtmlEmbed` was false in quartz.config.ts -> branch not ported.
|
|
19
|
+
* - Markdown checkboxes are enabled for interactive practice in the rendered notes.
|
|
20
|
+
* - `disableBrokenWikilinks` was false (default) -> branch not ported.
|
|
21
|
+
* - The mermaid html-phase expand-button/container DOM is NOT emitted; the new
|
|
22
|
+
* site's Mermaid island consumes `<code class="mermaid" data-clipboard>` directly.
|
|
23
|
+
* - quartz.config.ts ran ObsidianFlavoredMarkdown twice; the second application is
|
|
24
|
+
* a no-op for the mdast plugins (syntax already consumed) and only the
|
|
25
|
+
* `calloutLineRegex` text pre-transform observably re-fires, inserting an extra
|
|
26
|
+
* empty `> ` continuation line that renders identically. We run everything once.
|
|
27
|
+
*/
|
|
28
|
+
import type {
|
|
29
|
+
Root,
|
|
30
|
+
Html,
|
|
31
|
+
BlockContent,
|
|
32
|
+
PhrasingContent,
|
|
33
|
+
DefinitionContent,
|
|
34
|
+
Paragraph,
|
|
35
|
+
Code
|
|
36
|
+
} from 'mdast';
|
|
37
|
+
import type { Element, ElementContent, Literal, Root as HtmlRoot } from 'hast';
|
|
38
|
+
import {
|
|
39
|
+
type ReplaceFunction,
|
|
40
|
+
findAndReplace as mdastFindReplace
|
|
41
|
+
} from 'mdast-util-find-and-replace';
|
|
42
|
+
import { SKIP, visit } from 'unist-util-visit';
|
|
43
|
+
import path from 'node:path';
|
|
44
|
+
import { toHast } from 'mdast-util-to-hast';
|
|
45
|
+
import { toHtml } from 'hast-util-to-html';
|
|
46
|
+
import {
|
|
47
|
+
type FilePath,
|
|
48
|
+
type FullSlug,
|
|
49
|
+
pathToRoot,
|
|
50
|
+
slugTag,
|
|
51
|
+
slugifyFilePath,
|
|
52
|
+
splitAnchor
|
|
53
|
+
} from './slug';
|
|
54
|
+
|
|
55
|
+
const calloutMapping = {
|
|
56
|
+
note: 'note',
|
|
57
|
+
abstract: 'abstract',
|
|
58
|
+
summary: 'abstract',
|
|
59
|
+
tldr: 'abstract',
|
|
60
|
+
info: 'info',
|
|
61
|
+
todo: 'todo',
|
|
62
|
+
tip: 'tip',
|
|
63
|
+
hint: 'tip',
|
|
64
|
+
important: 'tip',
|
|
65
|
+
success: 'success',
|
|
66
|
+
check: 'success',
|
|
67
|
+
done: 'success',
|
|
68
|
+
question: 'question',
|
|
69
|
+
help: 'question',
|
|
70
|
+
faq: 'question',
|
|
71
|
+
warning: 'warning',
|
|
72
|
+
attention: 'warning',
|
|
73
|
+
caution: 'warning',
|
|
74
|
+
failure: 'failure',
|
|
75
|
+
missing: 'failure',
|
|
76
|
+
fail: 'failure',
|
|
77
|
+
danger: 'danger',
|
|
78
|
+
error: 'danger',
|
|
79
|
+
bug: 'bug',
|
|
80
|
+
example: 'example',
|
|
81
|
+
quote: 'quote',
|
|
82
|
+
cite: 'quote'
|
|
83
|
+
} as const;
|
|
84
|
+
|
|
85
|
+
const arrowMapping: Record<string, string> = {
|
|
86
|
+
'->': '→',
|
|
87
|
+
'-->': '⇒',
|
|
88
|
+
'=>': '⇒',
|
|
89
|
+
'==>': '⇒',
|
|
90
|
+
'<-': '←',
|
|
91
|
+
'<--': '⇐',
|
|
92
|
+
'<=': '⇐',
|
|
93
|
+
'<==': '⇐'
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// from quartz/util/lang.ts
|
|
97
|
+
function capitalize(s: string): string {
|
|
98
|
+
return s.substring(0, 1).toUpperCase() + s.substring(1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function canonicalizeCallout(calloutName: string): keyof typeof calloutMapping {
|
|
102
|
+
const normalizedCallout = calloutName.toLowerCase() as keyof typeof calloutMapping;
|
|
103
|
+
// if callout is not recognized, make it a custom one
|
|
104
|
+
return calloutMapping[normalizedCallout] ?? calloutName;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const externalLinkRegex = /^https?:\/\//i;
|
|
108
|
+
|
|
109
|
+
export const arrowRegex = new RegExp(/(-{1,2}>|={1,2}>|<-{1,2}|<={1,2})/g);
|
|
110
|
+
|
|
111
|
+
// !? -> optional embedding
|
|
112
|
+
// \[\[ -> open brace
|
|
113
|
+
// ([^\[\]\|\#]+) -> one or more non-special characters ([,],|, or #) (name)
|
|
114
|
+
// (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link)
|
|
115
|
+
// (\\?\|[^\[\]\#]+)? -> optional escape \ then | then zero or more non-special characters (alias)
|
|
116
|
+
export const wikilinkRegex = new RegExp(
|
|
117
|
+
/!?\[\[([^\[\]\|\#\\]+)?(#+[^\[\]\|\#\\]+)?(\\?\|[^\[\]\#]*)?\]\]/g
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// ^\|([^\n])+\|\n(\|) -> matches the header row
|
|
121
|
+
// ( ?:?-{3,}:? ?\|)+ -> matches the header row separator
|
|
122
|
+
// (\|([^\n])+\|\n)+ -> matches the body rows
|
|
123
|
+
export const tableRegex = new RegExp(/^\|([^\n])+\|\n(\|)( ?:?-{3,}:? ?\|)+\n(\|([^\n])+\|\n?)+/gm);
|
|
124
|
+
|
|
125
|
+
// matches any wikilink, only used for escaping wikilinks inside tables
|
|
126
|
+
export const tableWikilinkRegex = new RegExp(/(!?\[\[[^\]]*?\]\]|\[\^[^\]]*?\])/g);
|
|
127
|
+
|
|
128
|
+
const highlightRegex = new RegExp(/==([^=]+)==/g);
|
|
129
|
+
// from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts
|
|
130
|
+
const calloutRegex = new RegExp(/^\[\!([\w-]+)\|?(.+?)?\]([+-]?)/);
|
|
131
|
+
const calloutLineRegex = new RegExp(/^> *\[\!\w+\|?.*?\][+-]?.*$/gm);
|
|
132
|
+
// (?<=^| ) -> a lookbehind assertion, tag should start be separated by a space or be the start of the line
|
|
133
|
+
// #(...) -> capturing group, tag itself must start with #
|
|
134
|
+
// (?:[-_\p{L}\d\p{Z}])+ -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters and symbols, hyphens and/or underscores
|
|
135
|
+
// (?:\/[-_\p{L}\d\p{Z}]+)*) -> non-capturing group, matches an arbitrary number of tag strings separated by "/"
|
|
136
|
+
const tagRegex = new RegExp(
|
|
137
|
+
/(?<=^| )#((?:[-_\p{L}\p{Emoji}\p{M}\d])+(?:\/[-_\p{L}\p{Emoji}\p{M}\d]+)*)/gu
|
|
138
|
+
);
|
|
139
|
+
const blockReferenceRegex = new RegExp(/\^([-_A-Za-z0-9]+)$/g);
|
|
140
|
+
const ytLinkRegex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/;
|
|
141
|
+
const ytPlaylistLinkRegex = /[?&]list=([^#?&]*)/;
|
|
142
|
+
const videoExtensionRegex = new RegExp(/\.(mp4|webm|ogg|avi|mov|flv|wmv|mkv|mpg|mpeg|3gp|m4v)$/);
|
|
143
|
+
const wikilinkImageEmbedRegex = new RegExp(
|
|
144
|
+
/^(?<alt>(?!^\d*x?\d*$).*?)?(\|?\s*?(?<width>\d+)(x(?<height>\d+))?)?$/
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const mdastToHtml = (ast: PhrasingContent | Paragraph) => {
|
|
148
|
+
const hast = toHast(ast, { allowDangerousHtml: true })!;
|
|
149
|
+
return toHtml(hast, { allowDangerousHtml: true });
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/** Per-file context threaded through the mdast plugins (replaces vfile.data). */
|
|
153
|
+
export interface OfmFileData {
|
|
154
|
+
slug: FullSlug;
|
|
155
|
+
/** mutated in place: inline #tags get appended (slugged, deduped) */
|
|
156
|
+
tags: string[];
|
|
157
|
+
hasMermaidDiagram?: boolean;
|
|
158
|
+
/** block id -> hast element, collected by the block-reference html plugin */
|
|
159
|
+
blocks?: Record<string, Element>;
|
|
160
|
+
htmlAst?: HtmlRoot;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Let readers check Markdown tasks and answer choices in rendered notes. */
|
|
164
|
+
export function ofmCheckboxes() {
|
|
165
|
+
return (tree: HtmlRoot) => {
|
|
166
|
+
visit(tree, 'element', (node: Element) => {
|
|
167
|
+
if (node.tagName === 'input' && node.properties.type === 'checkbox') {
|
|
168
|
+
delete node.properties.disabled;
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// text phase (fork's textTransform, applied to raw markdown after frontmatter)
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
export function ofmTextTransform(src: string): string {
|
|
178
|
+
// do comments at text level
|
|
179
|
+
src = stripObsidianComments(src);
|
|
180
|
+
|
|
181
|
+
// pre-transform blockquotes
|
|
182
|
+
src = src.replace(calloutLineRegex, (value) => {
|
|
183
|
+
// force newline after title of callout
|
|
184
|
+
return value + '\n> ';
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// pre-transform wikilinks (fix anchors to things that may contain illegal syntax e.g. codeblocks, latex)
|
|
188
|
+
// replace all wikilinks inside a table first
|
|
189
|
+
src = src.replace(tableRegex, (value) => {
|
|
190
|
+
// escape all aliases and headers in wikilinks inside a table
|
|
191
|
+
return value.replace(tableWikilinkRegex, (_value, raw) => {
|
|
192
|
+
let escaped = raw ?? '';
|
|
193
|
+
escaped = escaped.replace('#', '\\#');
|
|
194
|
+
// escape pipe characters if they are not already escaped
|
|
195
|
+
escaped = escaped.replace(/((^|[^\\])(\\\\)*)\|/g, '$1\\|');
|
|
196
|
+
|
|
197
|
+
return escaped;
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// replace all other wikilinks
|
|
202
|
+
src = src.replace(wikilinkRegex, (value, ...capture) => {
|
|
203
|
+
const [rawFp, rawHeader, rawAlias]: (string | undefined)[] = capture;
|
|
204
|
+
|
|
205
|
+
const [fp, anchor] = splitAnchor(`${rawFp ?? ''}${rawHeader ?? ''}`);
|
|
206
|
+
const blockRef = Boolean(rawHeader?.startsWith('#^')) ? '^' : '';
|
|
207
|
+
const displayAnchor = anchor ? `#${blockRef}${anchor.trim().replace(/^#+/, '')}` : '';
|
|
208
|
+
const displayAlias = rawAlias ?? rawHeader?.replace('#', '|') ?? '';
|
|
209
|
+
const embedDisplay = value.startsWith('!') ? '!' : '';
|
|
210
|
+
|
|
211
|
+
if (rawFp?.match(externalLinkRegex)) {
|
|
212
|
+
return `${embedDisplay}[${displayAlias.replace(/^\|/, '')}](${rawFp})`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return `${embedDisplay}[[${fp}${displayAnchor}${displayAlias}]]`;
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
return src;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// mdast phase
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
/** wikilinks/embeds, ==highlights==, arrows, inline #tags (fork's regex-replacement plugin) */
|
|
226
|
+
export function ofmReplacements(data: OfmFileData) {
|
|
227
|
+
return (tree: Root) => {
|
|
228
|
+
const replacements: [RegExp, string | ReplaceFunction][] = [];
|
|
229
|
+
const base = pathToRoot(data.slug);
|
|
230
|
+
|
|
231
|
+
// wikilinks
|
|
232
|
+
replacements.push([
|
|
233
|
+
wikilinkRegex,
|
|
234
|
+
(value: string, ...capture: string[]) => {
|
|
235
|
+
let [rawFp, rawHeader, rawAlias] = capture;
|
|
236
|
+
const fp = rawFp?.trim() ?? '';
|
|
237
|
+
const anchor = rawHeader?.trim() ?? '';
|
|
238
|
+
const alias: string | undefined = rawAlias?.slice(1).trim();
|
|
239
|
+
|
|
240
|
+
// embed cases
|
|
241
|
+
if (value.startsWith('!')) {
|
|
242
|
+
const ext: string = path.extname(fp).toLowerCase();
|
|
243
|
+
const url = slugifyFilePath(fp as FilePath);
|
|
244
|
+
if (['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.svg', '.webp'].includes(ext)) {
|
|
245
|
+
const match = wikilinkImageEmbedRegex.exec(alias ?? '');
|
|
246
|
+
const alt = match?.groups?.alt ?? '';
|
|
247
|
+
const width = match?.groups?.width ?? 'auto';
|
|
248
|
+
const height = match?.groups?.height ?? 'auto';
|
|
249
|
+
return {
|
|
250
|
+
type: 'image',
|
|
251
|
+
url,
|
|
252
|
+
data: {
|
|
253
|
+
hProperties: {
|
|
254
|
+
width,
|
|
255
|
+
height,
|
|
256
|
+
alt
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
} else if (['.mp4', '.webm', '.ogv', '.mov', '.mkv'].includes(ext)) {
|
|
261
|
+
return {
|
|
262
|
+
type: 'html',
|
|
263
|
+
value: `<video src="${url}" controls></video>`
|
|
264
|
+
};
|
|
265
|
+
} else if (['.mp3', '.webm', '.wav', '.m4a', '.ogg', '.3gp', '.flac'].includes(ext)) {
|
|
266
|
+
return {
|
|
267
|
+
type: 'html',
|
|
268
|
+
value: `<audio src="${url}" controls></audio>`
|
|
269
|
+
};
|
|
270
|
+
} else if (['.pdf'].includes(ext)) {
|
|
271
|
+
return {
|
|
272
|
+
type: 'html',
|
|
273
|
+
value: `<iframe src="${url}" class="pdf"></iframe>`
|
|
274
|
+
};
|
|
275
|
+
} else {
|
|
276
|
+
// note transclusion: emit the fork's placeholder blockquote; the
|
|
277
|
+
// prebuild's pass 2 inlines it (replaces Quartz's renderPage hack)
|
|
278
|
+
const block = anchor;
|
|
279
|
+
return {
|
|
280
|
+
type: 'html',
|
|
281
|
+
data: { hProperties: { transclude: true } },
|
|
282
|
+
value: `<blockquote class="transclude" data-url="${url}" data-block="${block}" data-embed-alias="${alias}"><a href="${
|
|
283
|
+
url + anchor
|
|
284
|
+
}" class="transclude-inner">Transclude of ${url}${block}</a></blockquote>`
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// otherwise, fall through to regular link
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// internal link
|
|
292
|
+
const url = fp + anchor;
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
type: 'link',
|
|
296
|
+
url,
|
|
297
|
+
children: [
|
|
298
|
+
{
|
|
299
|
+
type: 'text',
|
|
300
|
+
value: alias ?? fp
|
|
301
|
+
}
|
|
302
|
+
]
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
]);
|
|
306
|
+
|
|
307
|
+
// ==highlights==
|
|
308
|
+
replacements.push([
|
|
309
|
+
highlightRegex,
|
|
310
|
+
(_value: string, ...capture: string[]) => {
|
|
311
|
+
const [inner] = capture;
|
|
312
|
+
return {
|
|
313
|
+
type: 'html',
|
|
314
|
+
value: `<span class="text-highlight">${inner}</span>`
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
]);
|
|
318
|
+
|
|
319
|
+
// arrows
|
|
320
|
+
replacements.push([
|
|
321
|
+
arrowRegex,
|
|
322
|
+
(value: string, ..._capture: string[]) => {
|
|
323
|
+
const maybeArrow = arrowMapping[value];
|
|
324
|
+
if (maybeArrow === undefined) return SKIP;
|
|
325
|
+
return {
|
|
326
|
+
type: 'html',
|
|
327
|
+
value: `<span>${maybeArrow}</span>`
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
]);
|
|
331
|
+
|
|
332
|
+
// inline #tags
|
|
333
|
+
replacements.push([
|
|
334
|
+
tagRegex,
|
|
335
|
+
(_value: string, tag: string) => {
|
|
336
|
+
// Check if the tag only includes numbers and slashes
|
|
337
|
+
if (/^[\/\d]+$/.test(tag)) {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
tag = slugTag(tag);
|
|
342
|
+
if (!data.tags.includes(tag)) {
|
|
343
|
+
data.tags.push(tag);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
type: 'link',
|
|
348
|
+
url: base + `/tags/${tag}`,
|
|
349
|
+
data: {
|
|
350
|
+
hProperties: {
|
|
351
|
+
className: ['tag-link']
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
children: [
|
|
355
|
+
{
|
|
356
|
+
type: 'text',
|
|
357
|
+
value: tag
|
|
358
|
+
}
|
|
359
|
+
]
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
]);
|
|
363
|
+
|
|
364
|
+
mdastFindReplace(tree, replacements);
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** ![[video.mp4]] markdown-image-with-video-extension -> <video> (enableVideoEmbed) */
|
|
369
|
+
export function ofmVideoEmbed() {
|
|
370
|
+
return (tree: Root) => {
|
|
371
|
+
visit(tree, 'image', (node, index, parent) => {
|
|
372
|
+
if (parent && index != undefined && videoExtensionRegex.test(node.url)) {
|
|
373
|
+
const newNode: Html = {
|
|
374
|
+
type: 'html',
|
|
375
|
+
value: `<video controls src="${node.url}"></video>`
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
parent.children.splice(index, 1, newNode);
|
|
379
|
+
return SKIP;
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** callouts incl. collapsed [!type]- with the fork's callout-content div structure */
|
|
386
|
+
export function ofmCallouts() {
|
|
387
|
+
return (tree: Root) => {
|
|
388
|
+
visit(tree, 'blockquote', (node) => {
|
|
389
|
+
if (node.children.length === 0) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// find first line and callout content
|
|
394
|
+
const [firstChild, ...calloutContent] = node.children;
|
|
395
|
+
if (firstChild.type !== 'paragraph' || firstChild.children[0]?.type !== 'text') {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const text = firstChild.children[0].value;
|
|
400
|
+
const restOfTitle = firstChild.children.slice(1);
|
|
401
|
+
const [firstLine, ...remainingLines] = text.split('\n');
|
|
402
|
+
const remainingText = remainingLines.join('\n');
|
|
403
|
+
|
|
404
|
+
const match = firstLine.match(calloutRegex);
|
|
405
|
+
if (match && match.input) {
|
|
406
|
+
const [calloutDirective, typeString, calloutMetaData, collapseChar] = match;
|
|
407
|
+
const calloutType = canonicalizeCallout(typeString.toLowerCase());
|
|
408
|
+
const collapse = collapseChar === '+' || collapseChar === '-';
|
|
409
|
+
const defaultState = collapseChar === '-' ? 'collapsed' : 'expanded';
|
|
410
|
+
const titleContent = match.input.slice(calloutDirective.length).trim();
|
|
411
|
+
const useDefaultTitle = titleContent === '' && restOfTitle.length === 0;
|
|
412
|
+
const titleNode: Paragraph = {
|
|
413
|
+
type: 'paragraph',
|
|
414
|
+
children: [
|
|
415
|
+
{
|
|
416
|
+
type: 'text',
|
|
417
|
+
value: useDefaultTitle
|
|
418
|
+
? capitalize(typeString).replace(/-/g, ' ')
|
|
419
|
+
: titleContent + ' '
|
|
420
|
+
},
|
|
421
|
+
...restOfTitle
|
|
422
|
+
]
|
|
423
|
+
};
|
|
424
|
+
const title = mdastToHtml(titleNode);
|
|
425
|
+
|
|
426
|
+
const toggleIcon = `<div class="fold-callout-icon"></div>`;
|
|
427
|
+
|
|
428
|
+
const titleHtml: Html = {
|
|
429
|
+
type: 'html',
|
|
430
|
+
value: `<div
|
|
431
|
+
class="callout-title"
|
|
432
|
+
>
|
|
433
|
+
<div class="callout-icon"></div>
|
|
434
|
+
<div class="callout-title-inner">${title}</div>
|
|
435
|
+
${collapse ? toggleIcon : ''}
|
|
436
|
+
</div>`
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const blockquoteContent: (BlockContent | DefinitionContent)[] = [titleHtml];
|
|
440
|
+
if (remainingText.length > 0) {
|
|
441
|
+
blockquoteContent.push({
|
|
442
|
+
type: 'paragraph',
|
|
443
|
+
children: [
|
|
444
|
+
{
|
|
445
|
+
type: 'text',
|
|
446
|
+
value: remainingText
|
|
447
|
+
}
|
|
448
|
+
]
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// For the rest of the MD callout elements other than the title, wrap them with
|
|
453
|
+
// two nested HTML <div>s (use some hacked mdhast component to achieve this) of
|
|
454
|
+
// class `callout-content` and `callout-content-inner` respectively for
|
|
455
|
+
// grid-based collapsible animation.
|
|
456
|
+
if (calloutContent.length > 0) {
|
|
457
|
+
node.children = [
|
|
458
|
+
node.children[0],
|
|
459
|
+
{
|
|
460
|
+
data: { hProperties: { className: ['callout-content'] }, hName: 'div' },
|
|
461
|
+
type: 'blockquote',
|
|
462
|
+
children: [
|
|
463
|
+
{
|
|
464
|
+
data: {
|
|
465
|
+
hProperties: { className: ['callout-content-inner'] },
|
|
466
|
+
hName: 'div'
|
|
467
|
+
},
|
|
468
|
+
type: 'blockquote',
|
|
469
|
+
children: [...calloutContent]
|
|
470
|
+
}
|
|
471
|
+
]
|
|
472
|
+
}
|
|
473
|
+
];
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// replace first line of blockquote with title and rest of the paragraph text
|
|
477
|
+
node.children.splice(0, 1, ...blockquoteContent);
|
|
478
|
+
|
|
479
|
+
const classNames = ['callout', calloutType];
|
|
480
|
+
if (collapse) {
|
|
481
|
+
classNames.push('is-collapsible');
|
|
482
|
+
}
|
|
483
|
+
if (defaultState === 'collapsed') {
|
|
484
|
+
classNames.push('is-collapsed');
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// add properties to base blockquote
|
|
488
|
+
node.data = {
|
|
489
|
+
hProperties: {
|
|
490
|
+
...(node.data?.hProperties ?? {}),
|
|
491
|
+
className: classNames.join(' '),
|
|
492
|
+
'data-callout': calloutType,
|
|
493
|
+
'data-callout-fold': collapse,
|
|
494
|
+
'data-callout-metadata': calloutMetaData
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** mark ```mermaid fences for the client island */
|
|
503
|
+
export function ofmMermaid(data: OfmFileData) {
|
|
504
|
+
return (tree: Root) => {
|
|
505
|
+
visit(tree, 'code', (node: Code) => {
|
|
506
|
+
if (node.lang === 'mermaid') {
|
|
507
|
+
data.hasMermaidDiagram = true;
|
|
508
|
+
node.data = {
|
|
509
|
+
hProperties: {
|
|
510
|
+
className: ['mermaid'],
|
|
511
|
+
'data-clipboard': JSON.stringify(node.value)
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ---------------------------------------------------------------------------
|
|
520
|
+
// hast phase
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
|
|
523
|
+
/** block references `^id` (parseBlockReferences) — collects data.blocks + htmlAst */
|
|
524
|
+
export function ofmBlockReferences(data: OfmFileData) {
|
|
525
|
+
const inlineTagTypes = new Set(['p', 'li']);
|
|
526
|
+
const blockTagTypes = new Set(['blockquote']);
|
|
527
|
+
return (tree: HtmlRoot) => {
|
|
528
|
+
data.blocks = {};
|
|
529
|
+
|
|
530
|
+
visit(tree, 'element', (node, index, parent) => {
|
|
531
|
+
if (blockTagTypes.has(node.tagName)) {
|
|
532
|
+
const nextChild = parent?.children.at(index! + 2) as Element;
|
|
533
|
+
if (nextChild && nextChild.tagName === 'p') {
|
|
534
|
+
const text = nextChild.children.at(0) as Literal;
|
|
535
|
+
if (text && text.value && text.type === 'text') {
|
|
536
|
+
const matches = text.value.match(blockReferenceRegex);
|
|
537
|
+
if (matches && matches.length >= 1) {
|
|
538
|
+
parent!.children.splice(index! + 2, 1);
|
|
539
|
+
const block = matches[0].slice(1);
|
|
540
|
+
|
|
541
|
+
if (!Object.keys(data.blocks!).includes(block)) {
|
|
542
|
+
node.properties = {
|
|
543
|
+
...node.properties,
|
|
544
|
+
id: block
|
|
545
|
+
};
|
|
546
|
+
data.blocks![block] = node;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
} else if (inlineTagTypes.has(node.tagName)) {
|
|
552
|
+
const last = node.children.at(-1) as Literal;
|
|
553
|
+
if (last && last.value && typeof last.value === 'string') {
|
|
554
|
+
const matches = last.value.match(blockReferenceRegex);
|
|
555
|
+
if (matches && matches.length >= 1) {
|
|
556
|
+
last.value = last.value.slice(0, -matches[0].length);
|
|
557
|
+
const block = matches[0].slice(1);
|
|
558
|
+
|
|
559
|
+
if (last.value === '') {
|
|
560
|
+
// this is an inline block ref but the actual block
|
|
561
|
+
// is the previous element above it
|
|
562
|
+
let idx = (index ?? 1) - 1;
|
|
563
|
+
while (idx >= 0) {
|
|
564
|
+
const element = parent?.children.at(idx);
|
|
565
|
+
if (!element) break;
|
|
566
|
+
if (element.type !== 'element') {
|
|
567
|
+
idx -= 1;
|
|
568
|
+
} else {
|
|
569
|
+
if (!Object.keys(data.blocks!).includes(block)) {
|
|
570
|
+
element.properties = {
|
|
571
|
+
...element.properties,
|
|
572
|
+
id: block
|
|
573
|
+
};
|
|
574
|
+
data.blocks![block] = element;
|
|
575
|
+
}
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
} else {
|
|
580
|
+
// normal paragraph transclude
|
|
581
|
+
if (!Object.keys(data.blocks!).includes(block)) {
|
|
582
|
+
node.properties = {
|
|
583
|
+
...node.properties,
|
|
584
|
+
id: block
|
|
585
|
+
};
|
|
586
|
+
data.blocks![block] = node;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
data.htmlAst = tree;
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**  image -> embedded iframe (enableYouTubeEmbed) */
|
|
599
|
+
export function ofmYouTubeEmbed() {
|
|
600
|
+
return (tree: HtmlRoot) => {
|
|
601
|
+
visit(tree, 'element', (node) => {
|
|
602
|
+
if (node.tagName === 'img' && typeof node.properties.src === 'string') {
|
|
603
|
+
const match = node.properties.src.match(ytLinkRegex);
|
|
604
|
+
const videoId = match && match[2].length == 11 ? match[2] : null;
|
|
605
|
+
const playlistId = node.properties.src.match(ytPlaylistLinkRegex)?.[1];
|
|
606
|
+
if (videoId) {
|
|
607
|
+
// YouTube video (with optional playlist)
|
|
608
|
+
node.tagName = 'iframe';
|
|
609
|
+
node.properties = {
|
|
610
|
+
class: 'external-embed youtube',
|
|
611
|
+
allow: 'fullscreen',
|
|
612
|
+
frameborder: 0,
|
|
613
|
+
width: '600px',
|
|
614
|
+
src: playlistId
|
|
615
|
+
? `https://www.youtube.com/embed/${videoId}?list=${playlistId}`
|
|
616
|
+
: `https://www.youtube.com/embed/${videoId}`
|
|
617
|
+
};
|
|
618
|
+
} else if (playlistId) {
|
|
619
|
+
// YouTube playlist only.
|
|
620
|
+
node.tagName = 'iframe';
|
|
621
|
+
node.properties = {
|
|
622
|
+
class: 'external-embed youtube',
|
|
623
|
+
allow: 'fullscreen',
|
|
624
|
+
frameborder: 0,
|
|
625
|
+
width: '600px',
|
|
626
|
+
src: `https://www.youtube.com/embed/videoseries?list=${playlistId}`
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
export type { Element, ElementContent, HtmlRoot };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
statSync,
|
|
10
|
+
writeFileSync
|
|
11
|
+
} from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
const changes = new AsyncLocalStorage<Set<string>>();
|
|
15
|
+
|
|
16
|
+
export async function trackOutputs(build: () => Promise<void>) {
|
|
17
|
+
const files = new Set<string>();
|
|
18
|
+
await changes.run(files, build);
|
|
19
|
+
return files;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Replace changed files atomically; preserve unchanged mtimes and hardlinked assets. */
|
|
23
|
+
export function writeChanged(file: string, data: string | Uint8Array) {
|
|
24
|
+
const bytes = Buffer.from(data);
|
|
25
|
+
if (existsSync(file) && statSync(file).size === bytes.length && readFileSync(file).equals(bytes))
|
|
26
|
+
return;
|
|
27
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
28
|
+
const temporary = `${file}.tmp-${process.pid}`;
|
|
29
|
+
try {
|
|
30
|
+
writeFileSync(temporary, bytes);
|
|
31
|
+
renameSync(temporary, file);
|
|
32
|
+
} finally {
|
|
33
|
+
rmSync(temporary, { force: true });
|
|
34
|
+
}
|
|
35
|
+
changes.getStore()?.add(path.resolve(file));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function copyChanged(source: string, destination: string) {
|
|
39
|
+
writeChanged(destination, readFileSync(source));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Paths are absolute. Each caller prunes only the directory it owns. */
|
|
43
|
+
export function pruneOutputs(
|
|
44
|
+
directory: string,
|
|
45
|
+
wanted: Set<string>,
|
|
46
|
+
keep?: (file: string) => boolean
|
|
47
|
+
) {
|
|
48
|
+
if (!existsSync(directory)) return;
|
|
49
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
50
|
+
const file = path.join(directory, entry.name);
|
|
51
|
+
if (keep?.(file)) continue;
|
|
52
|
+
if (entry.isDirectory()) {
|
|
53
|
+
pruneOutputs(file, wanted, keep);
|
|
54
|
+
if (!readdirSync(file).length) rmSync(file, { recursive: true });
|
|
55
|
+
} else if (!wanted.has(file)) {
|
|
56
|
+
rmSync(file);
|
|
57
|
+
changes.getStore()?.add(path.resolve(file));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|