@twango/twiki 0.0.0-stage → 0.1.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. package/.github/actions/product-cache/action.yml +32 -0
  2. package/.github/actions/product-cache/index.mjs +450 -0
  3. package/LICENSE +21 -0
  4. package/README.md +146 -2
  5. package/bun.lock +1701 -0
  6. package/cli/runtime.test.ts +420 -0
  7. package/cli/runtime.ts +385 -0
  8. package/cli/twiki.ts +305 -0
  9. package/package.json +138 -4
  10. package/site/_headers +7 -0
  11. package/site/assets/fonts/OFL.txt +93 -0
  12. package/site/assets/fonts/OverusedGrotesk-SemiBold.ttf +0 -0
  13. package/site/assets/social-background.svg +38 -0
  14. package/site/bun.lock +1701 -0
  15. package/site/database/index.ts +7 -0
  16. package/site/database/schema.ts +95 -0
  17. package/site/drizzle.config.ts +7 -0
  18. package/site/migrations/0001_auth.sql +14 -0
  19. package/site/migrations/0002_site_access.sql +5 -0
  20. package/site/migrations/0003_github_username.sql +1 -0
  21. package/site/migrations/meta/0002_snapshot.json +432 -0
  22. package/site/migrations/meta/0003_snapshot.json +439 -0
  23. package/site/migrations/meta/_journal.json +27 -0
  24. package/site/package.json +120 -0
  25. package/site/src/app.d.ts +14 -0
  26. package/site/src/app.html +11 -0
  27. package/site/src/hooks.server.ts +12 -0
  28. package/site/src/lib/analytics-config.json +1 -0
  29. package/site/src/lib/analytics.ts +40 -0
  30. package/site/src/lib/auth-config.json +3 -0
  31. package/site/src/lib/components/SEO.svelte +113 -0
  32. package/site/src/lib/components/doc/AccessLink.svelte +27 -0
  33. package/site/src/lib/components/doc/Backlinks.svelte +59 -0
  34. package/site/src/lib/components/doc/Breadcrumbs.svelte +29 -0
  35. package/site/src/lib/components/doc/ContentMeta.svelte +61 -0
  36. package/site/src/lib/components/doc/DocPager.svelte +91 -0
  37. package/site/src/lib/components/doc/DocShell.svelte +115 -0
  38. package/site/src/lib/components/doc/LicenseNotice.svelte +26 -0
  39. package/site/src/lib/components/doc/LockedContent.svelte +29 -0
  40. package/site/src/lib/components/doc/NavTree.svelte +158 -0
  41. package/site/src/lib/components/doc/NoteActions.svelte +139 -0
  42. package/site/src/lib/components/doc/PublicationBadge.svelte +49 -0
  43. package/site/src/lib/components/doc/ReaderModeToggle.svelte +19 -0
  44. package/site/src/lib/components/doc/Sidebar.svelte +351 -0
  45. package/site/src/lib/components/doc/Toc.svelte +339 -0
  46. package/site/src/lib/components/doc/enhancements.ts +146 -0
  47. package/site/src/lib/components/doc/heading-tracker.svelte.ts +39 -0
  48. package/site/src/lib/components/doc/image-preview.ts +114 -0
  49. package/site/src/lib/components/doc/reader-mode.svelte.ts +40 -0
  50. package/site/src/lib/components/embeds/mermaid.ts +353 -0
  51. package/site/src/lib/components/files/FileCode.svelte +81 -0
  52. package/site/src/lib/components/files/FileExplorer.svelte +74 -0
  53. package/site/src/lib/components/files/FileHistory.svelte +170 -0
  54. package/site/src/lib/components/files/FileIcon.svelte +28 -0
  55. package/site/src/lib/components/files/FileRmd.svelte +209 -0
  56. package/site/src/lib/components/files/FileTabs.svelte +180 -0
  57. package/site/src/lib/components/files/FileTree.svelte +67 -0
  58. package/site/src/lib/components/files/blame-gutter.ts +45 -0
  59. package/site/src/lib/components/files/code-view.ts +98 -0
  60. package/site/src/lib/components/files/file-workspace.svelte.ts +100 -0
  61. package/site/src/lib/components/graph/GraphPanel.svelte +275 -0
  62. package/site/src/lib/components/graph/GraphView.svelte +100 -0
  63. package/site/src/lib/components/graph/graph-data.ts +137 -0
  64. package/site/src/lib/components/graph/graph-model.ts +103 -0
  65. package/site/src/lib/components/graph/index.ts +3 -0
  66. package/site/src/lib/components/graph/render-graph.ts +626 -0
  67. package/site/src/lib/components/popover/index.ts +1 -0
  68. package/site/src/lib/components/popover/link-popovers.ts +233 -0
  69. package/site/src/lib/components/popover/popover.css +82 -0
  70. package/site/src/lib/components/search/SearchButton.svelte +57 -0
  71. package/site/src/lib/components/search/SearchPalette.svelte +189 -0
  72. package/site/src/lib/components/search/index.ts +3 -0
  73. package/site/src/lib/components/search/pagefind-client.svelte.ts +129 -0
  74. package/site/src/lib/components/search/pagefind-query.ts +22 -0
  75. package/site/src/lib/components/search/pagefind-url.ts +12 -0
  76. package/site/src/lib/components/search/search-state.svelte.ts +22 -0
  77. package/site/src/lib/components/ui/IconButton.svelte +30 -0
  78. package/site/src/lib/components/ui/ThemeToggle.svelte +22 -0
  79. package/site/src/lib/components/ui/index.ts +1 -0
  80. package/site/src/lib/config.ts +9 -0
  81. package/site/src/lib/content-license.ts +7 -0
  82. package/site/src/lib/file-history.ts +37 -0
  83. package/site/src/lib/file-icons.ts +27 -0
  84. package/site/src/lib/file-tabs.ts +72 -0
  85. package/site/src/lib/files.ts +73 -0
  86. package/site/src/lib/github-user.ts +70 -0
  87. package/site/src/lib/metadata.ts +166 -0
  88. package/site/src/lib/notebook-nav.ts +63 -0
  89. package/site/src/lib/publication.ts +1 -0
  90. package/site/src/lib/rmd.ts +156 -0
  91. package/site/src/lib/server/access-admin.ts +33 -0
  92. package/site/src/lib/server/content-model.ts +253 -0
  93. package/site/src/lib/server/content-provider.ts +266 -0
  94. package/site/src/lib/server/content.ts +277 -0
  95. package/site/src/lib/server/runtime-content.ts +34 -0
  96. package/site/src/lib/site-config.json +9 -0
  97. package/site/src/lib/social-image.ts +13 -0
  98. package/site/src/lib/styles/article.css +607 -0
  99. package/site/src/lib/styles/foundation.css +138 -0
  100. package/site/src/lib/styles/print.css +115 -0
  101. package/site/src/lib/styles/shell.css +182 -0
  102. package/site/src/lib/text-exports.ts +5 -0
  103. package/site/src/lib/types.ts +140 -0
  104. package/site/src/lib/utils.ts +6 -0
  105. package/site/src/routes/+error.svelte +16 -0
  106. package/site/src/routes/+layout.server.ts +10 -0
  107. package/site/src/routes/+layout.svelte +72 -0
  108. package/site/src/routes/+layout.ts +3 -0
  109. package/site/src/routes/404/+page.svelte +13 -0
  110. package/site/src/routes/[...slug]/+page.server.ts +24 -0
  111. package/site/src/routes/[...slug]/+page.svelte +180 -0
  112. package/site/src/routes/[course]/files/[...file]/+page.svelte +373 -0
  113. package/site/src/routes/[course]/files/[...file]/+page.ts +38 -0
  114. package/site/src/routes/admin/access/+page.server.ts +53 -0
  115. package/site/src/routes/admin/access/+page.svelte +118 -0
  116. package/site/src/routes/graph.json/+server.ts +7 -0
  117. package/site/src/routes/index.xml/+server.ts +7 -0
  118. package/site/src/routes/layout.css +10 -0
  119. package/site/src/routes/robots.txt/+server.ts +9 -0
  120. package/site/src/routes/sitemap.xml/+server.ts +7 -0
  121. package/site/src/routes/tags/+page.server.ts +5 -0
  122. package/site/src/routes/tags/+page.svelte +38 -0
  123. package/site/src/routes/tags/[...tag]/+page.server.ts +22 -0
  124. package/site/src/routes/tags/[...tag]/+page.svelte +52 -0
  125. package/site/static/favicon.png +0 -0
  126. package/site/static/fonts/JetBrainsMono-Italic-VF.woff2 +0 -0
  127. package/site/static/fonts/JetBrainsMono-VF.woff2 +0 -0
  128. package/site/static/fonts/OverusedGrotesk-VF.woff2 +0 -0
  129. package/site/static/fonts/OverusedGroteskItalic-VF.woff2 +0 -0
  130. package/site/static/fonts/Virgil.woff2 +0 -0
  131. package/site/svelte.config.js +71 -0
  132. package/site/tooling/LICENSE-quartz +25 -0
  133. package/site/tooling/adapter.js +55 -0
  134. package/site/tooling/assets.ts +323 -0
  135. package/site/tooling/benchmark.ts +191 -0
  136. package/site/tooling/build.ts +407 -0
  137. package/site/tooling/cache.ts +133 -0
  138. package/site/tooling/check-diagrams.ts +164 -0
  139. package/site/tooling/check-links.ts +86 -0
  140. package/site/tooling/check-targets.ts +139 -0
  141. package/site/tooling/compiler.ts +1386 -0
  142. package/site/tooling/deploy.ts +112 -0
  143. package/site/tooling/fingerprint.ts +81 -0
  144. package/site/tooling/lib/app-version.js +92 -0
  145. package/site/tooling/lib/application-asset-urls.ts +27 -0
  146. package/site/tooling/lib/application-cache.ts +269 -0
  147. package/site/tooling/lib/asset-hash-cache.ts +116 -0
  148. package/site/tooling/lib/autotag.ts +99 -0
  149. package/site/tooling/lib/build-assembly.ts +148 -0
  150. package/site/tooling/lib/build-workspace.ts +386 -0
  151. package/site/tooling/lib/cache-selection.ts +70 -0
  152. package/site/tooling/lib/cloudflare-prewarm.js +40 -0
  153. package/site/tooling/lib/comments.ts +32 -0
  154. package/site/tooling/lib/content-cache.ts +244 -0
  155. package/site/tooling/lib/content-urls.ts +50 -0
  156. package/site/tooling/lib/course-cache.ts +298 -0
  157. package/site/tooling/lib/course-files.ts +281 -0
  158. package/site/tooling/lib/currency.ts +33 -0
  159. package/site/tooling/lib/deployment-config.ts +222 -0
  160. package/site/tooling/lib/deployment-manifest.ts +274 -0
  161. package/site/tooling/lib/deployment-prepare.ts +325 -0
  162. package/site/tooling/lib/deployment-upload.ts +293 -0
  163. package/site/tooling/lib/dev-git-state.ts +130 -0
  164. package/site/tooling/lib/dev-history.ts +86 -0
  165. package/site/tooling/lib/dev-social-images.ts +38 -0
  166. package/site/tooling/lib/dev-state.ts +107 -0
  167. package/site/tooling/lib/edition-builds.ts +184 -0
  168. package/site/tooling/lib/edition-paths.js +29 -0
  169. package/site/tooling/lib/empty-tag-route.js +23 -0
  170. package/site/tooling/lib/file-history.ts +201 -0
  171. package/site/tooling/lib/file-icons.ts +93 -0
  172. package/site/tooling/lib/file-policy.ts +38 -0
  173. package/site/tooling/lib/file-search.js +13 -0
  174. package/site/tooling/lib/git-revisions.ts +41 -0
  175. package/site/tooling/lib/history-pool.ts +74 -0
  176. package/site/tooling/lib/history-worker.ts +22 -0
  177. package/site/tooling/lib/knit-rmd.R +38 -0
  178. package/site/tooling/lib/lastmod.ts +201 -0
  179. package/site/tooling/lib/markdown-files.ts +51 -0
  180. package/site/tooling/lib/ofm.ts +634 -0
  181. package/site/tooling/lib/output.ts +60 -0
  182. package/site/tooling/lib/product-cache.ts +1006 -0
  183. package/site/tooling/lib/project-config.ts +288 -0
  184. package/site/tooling/lib/public-assets.ts +38 -0
  185. package/site/tooling/lib/public-links.ts +56 -0
  186. package/site/tooling/lib/publishing.ts +156 -0
  187. package/site/tooling/lib/r-runtime.ts +257 -0
  188. package/site/tooling/lib/rebuild-queue.ts +25 -0
  189. package/site/tooling/lib/rmd-preflight.ts +166 -0
  190. package/site/tooling/lib/rmd-preview-cache.ts +93 -0
  191. package/site/tooling/lib/rmd-previews.ts +129 -0
  192. package/site/tooling/lib/runtime-data-modules.ts +30 -0
  193. package/site/tooling/lib/runtime-packaging.ts +321 -0
  194. package/site/tooling/lib/search-cache.js +165 -0
  195. package/site/tooling/lib/search-inputs.js +129 -0
  196. package/site/tooling/lib/slug.ts +377 -0
  197. package/site/tooling/lib/social-images.ts +210 -0
  198. package/site/tooling/lib/text-exports.ts +51 -0
  199. package/site/tooling/lib/visit-elements.ts +17 -0
  200. package/site/tooling/live-test.ts +138 -0
  201. package/site/tooling/pipeline.ts +103 -0
  202. package/site/tooling/prepare.ts +3 -0
  203. package/site/tooling/publish.ts +28 -0
  204. package/site/tooling/r-cache.ts +45 -0
  205. package/site/tooling/r-render-profile.json +7 -0
  206. package/site/tooling/search.js +94 -0
  207. package/site/tooling/verify-deployment.ts +109 -0
  208. package/site/tooling/vite-content.ts +240 -0
  209. package/site/tooling/wrangler-paths.js +25 -0
  210. package/site/tooling/wrangler.ts +19 -0
  211. package/site/tsconfig.json +16 -0
  212. package/site/vite.config.ts +40 -0
  213. package/site/worker/access.ts +57 -0
  214. package/site/worker/analytics.ts +100 -0
  215. package/site/worker/auth-routes.ts +78 -0
  216. package/site/worker/auth.ts +78 -0
  217. package/site/worker/content-runtime.ts +226 -0
  218. package/site/worker/framing.ts +4 -0
  219. package/site/worker/gate.ts +147 -0
  220. package/site/worker/index.ts +9 -0
  221. package/site/worker/login.ts +62 -0
  222. package/site/worker/publication.ts +44 -0
  223. package/site/worker/runtime.ts +104 -0
  224. package/site/worker/tsconfig.json +13 -0
  225. package/site/wrangler.jsonc +37 -0
@@ -0,0 +1,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
+ '->': '&rarr;',
87
+ '-->': '&rArr;',
88
+ '=>': '&rArr;',
89
+ '==>': '&rArr;',
90
+ '<-': '&larr;',
91
+ '<--': '&lArr;',
92
+ '<=': '&lArr;',
93
+ '<==': '&lArr;'
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
+ /** ![](youtube url) 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
+ }