@pathmx/core 0.5.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 (195) hide show
  1. package/LICENSE.md +172 -0
  2. package/README.md +111 -0
  3. package/actions/actions.ts +307 -0
  4. package/actions/draft.ts +105 -0
  5. package/actions/transaction.ts +164 -0
  6. package/assets.ts +149 -0
  7. package/authority/access.ts +820 -0
  8. package/authority/actors.ts +271 -0
  9. package/authority/agents.ts +95 -0
  10. package/authority/credentials.ts +144 -0
  11. package/authority/model.ts +71 -0
  12. package/authority/sharing.ts +89 -0
  13. package/browser-bundle.ts +61 -0
  14. package/canonical.ts +41 -0
  15. package/cli/dev.ts +134 -0
  16. package/cli/export.ts +74 -0
  17. package/cli/index.ts +52 -0
  18. package/cli/lint.ts +164 -0
  19. package/cli/mv.ts +30 -0
  20. package/cli/perms.ts +182 -0
  21. package/cli/plugin-packages.ts +159 -0
  22. package/cli/plugins.ts +95 -0
  23. package/cli/query.ts +225 -0
  24. package/cli/readme.md +95 -0
  25. package/cli/release.ts +75 -0
  26. package/cli/rm.ts +22 -0
  27. package/cli/serve.ts +100 -0
  28. package/cli/tree.ts +131 -0
  29. package/cli/update.ts +78 -0
  30. package/cli/utils.ts +53 -0
  31. package/core.ts +96 -0
  32. package/database.ts +189 -0
  33. package/dependencies.ts +60 -0
  34. package/dist/pathmx.js +1224 -0
  35. package/document.ts +194 -0
  36. package/environment.ts +110 -0
  37. package/environments/bun-plugins.ts +68 -0
  38. package/environments/bun-publish.ts +194 -0
  39. package/environments/bun.ts +223 -0
  40. package/globals.d.ts +14 -0
  41. package/graph.ts +153 -0
  42. package/host/compiler.ts +314 -0
  43. package/host/create.ts +66 -0
  44. package/host/engine.ts +630 -0
  45. package/host/plugin-host.ts +487 -0
  46. package/host/render.ts +102 -0
  47. package/host/response.ts +20 -0
  48. package/host/shell.html +15 -0
  49. package/host/source-sync.ts +254 -0
  50. package/html-attributes.ts +15 -0
  51. package/html.ts +48 -0
  52. package/icons/LICENSE +43 -0
  53. package/icons/lucide.ts +54 -0
  54. package/index.ts +75 -0
  55. package/invalidation.ts +85 -0
  56. package/log.ts +84 -0
  57. package/ops/href.ts +87 -0
  58. package/ops/index.ts +9 -0
  59. package/ops/move-source.ts +114 -0
  60. package/ops/plan.ts +47 -0
  61. package/ops/remove-source.ts +70 -0
  62. package/package.json +46 -0
  63. package/path.ts +75 -0
  64. package/pattern.ts +314 -0
  65. package/perf.ts +88 -0
  66. package/plugins/actor-path.ts +43 -0
  67. package/plugins/actor-profile.ts +55 -0
  68. package/plugins/agent-browsing.ts +183 -0
  69. package/plugins/app-component.ts +23 -0
  70. package/plugins/block-layout/index.ts +78 -0
  71. package/plugins/block-layout/theme.css +90 -0
  72. package/plugins/callout/index.ts +236 -0
  73. package/plugins/callout/theme.css +138 -0
  74. package/plugins/canonical-links.ts +144 -0
  75. package/plugins/code-highlight/client.ts +89 -0
  76. package/plugins/code-highlight/index.ts +147 -0
  77. package/plugins/code-highlight/theme.css +89 -0
  78. package/plugins/context.ts +131 -0
  79. package/plugins/datatype/Datatype[wdth,wght].woff2 +0 -0
  80. package/plugins/datatype/OFL.txt +96 -0
  81. package/plugins/datatype/index.ts +287 -0
  82. package/plugins/definition-list.ts +236 -0
  83. package/plugins/directive-definitions.ts +13 -0
  84. package/plugins/embedding/client.ts +88 -0
  85. package/plugins/embedding/index.ts +43 -0
  86. package/plugins/image/index.ts +68 -0
  87. package/plugins/image/theme.css +43 -0
  88. package/plugins/include.ts +119 -0
  89. package/plugins/index.ts +91 -0
  90. package/plugins/input/authoring.ts +236 -0
  91. package/plugins/input/config.ts +85 -0
  92. package/plugins/input/index.ts +359 -0
  93. package/plugins/input/model.ts +173 -0
  94. package/plugins/input/state.ts +272 -0
  95. package/plugins/layout.ts +313 -0
  96. package/plugins/literate/client.ts +208 -0
  97. package/plugins/literate/index.ts +742 -0
  98. package/plugins/math/index.ts +140 -0
  99. package/plugins/meta.ts +42 -0
  100. package/plugins/navigation/index.ts +158 -0
  101. package/plugins/navigation/model.ts +364 -0
  102. package/plugins/preset.ts +65 -0
  103. package/plugins/query/index.ts +322 -0
  104. package/plugins/rss/index.ts +46 -0
  105. package/plugins/runtime/actions.ts +62 -0
  106. package/plugins/runtime/client.ts +8 -0
  107. package/plugins/runtime/document.ts +506 -0
  108. package/plugins/runtime/error.ts +154 -0
  109. package/plugins/runtime/heading-links.ts +31 -0
  110. package/plugins/runtime/index.ts +104 -0
  111. package/plugins/runtime/live.ts +51 -0
  112. package/plugins/runtime/navigation.ts +113 -0
  113. package/plugins/runtime/pending.ts +53 -0
  114. package/plugins/runtime/publication.ts +19 -0
  115. package/plugins/runtime/start.ts +177 -0
  116. package/plugins/runtime/static-client.ts +6 -0
  117. package/plugins/runtime/static-live.ts +188 -0
  118. package/plugins/runtime/view-transition.ts +72 -0
  119. package/plugins/runtime/viewport.ts +110 -0
  120. package/plugins/schema.ts +24 -0
  121. package/plugins/scripts.ts +140 -0
  122. package/plugins/source-preview/client.ts +256 -0
  123. package/plugins/source-preview/index.ts +132 -0
  124. package/plugins/source-preview/model.ts +11 -0
  125. package/plugins/source-preview/preview.css +107 -0
  126. package/plugins/styles.ts +145 -0
  127. package/plugins/theme/components.ts +10 -0
  128. package/plugins/theme/foundation.css +358 -0
  129. package/plugins/theme/index.ts +333 -0
  130. package/plugins/theme/prose.css +284 -0
  131. package/plugins/theme/scheme.js +63 -0
  132. package/plugins/theme/toggle.css +44 -0
  133. package/plugins/theme/view-transition.css +68 -0
  134. package/plugins/toc/client.ts +146 -0
  135. package/plugins/toc/index.ts +232 -0
  136. package/plugins/toc/theme.css +172 -0
  137. package/plugins/types.ts +538 -0
  138. package/problem.ts +41 -0
  139. package/project.ts +6 -0
  140. package/publish/build.ts +402 -0
  141. package/publish/concurrent.ts +32 -0
  142. package/publish/identity.ts +63 -0
  143. package/publish/index.ts +11 -0
  144. package/publish/location.ts +167 -0
  145. package/publish/model.ts +58 -0
  146. package/publish/plan.ts +300 -0
  147. package/publish/validate.ts +150 -0
  148. package/query/candidate-index.ts +101 -0
  149. package/query/dependency-index.ts +99 -0
  150. package/query/fields.ts +216 -0
  151. package/query/invalidation.ts +48 -0
  152. package/query/kernel.ts +776 -0
  153. package/query/model.ts +79 -0
  154. package/repository.ts +374 -0
  155. package/runtime-profile.ts +34 -0
  156. package/schema/model.ts +92 -0
  157. package/schema/registry.ts +642 -0
  158. package/schema/standard.ts +226 -0
  159. package/server/context.ts +94 -0
  160. package/server/css-imports.ts +66 -0
  161. package/server/http.ts +333 -0
  162. package/server/page.ts +21 -0
  163. package/server/protocols.ts +12 -0
  164. package/server/representations.ts +66 -0
  165. package/server/router.ts +131 -0
  166. package/server/routes.ts +202 -0
  167. package/server/serve.ts +5 -0
  168. package/server/watch.ts +120 -0
  169. package/source/blocks.ts +71 -0
  170. package/source/format.ts +164 -0
  171. package/source/index.ts +67 -0
  172. package/source/interpolate.ts +63 -0
  173. package/source/markdown/blocks.ts +46 -0
  174. package/source/markdown/comments.ts +32 -0
  175. package/source/markdown/fences.ts +67 -0
  176. package/source/markdown/headings.ts +88 -0
  177. package/source/markdown/index.ts +34 -0
  178. package/source/markdown/inline-code.ts +6 -0
  179. package/source/markdown/links.ts +326 -0
  180. package/source/markdown/lists.ts +223 -0
  181. package/source/markdown/prose.ts +74 -0
  182. package/source/markdown/scan.ts +79 -0
  183. package/source/markdown/text.ts +82 -0
  184. package/source/meta.ts +87 -0
  185. package/source/source.ts +121 -0
  186. package/source/topmatter.ts +57 -0
  187. package/source/utils.ts +21 -0
  188. package/source-diagnostic.ts +63 -0
  189. package/text-file.ts +26 -0
  190. package/util.ts +35 -0
  191. package/view/components.ts +499 -0
  192. package/view/model.ts +91 -0
  193. package/view/projection.ts +162 -0
  194. package/view/session.ts +146 -0
  195. package/view/syntax.ts +372 -0
@@ -0,0 +1,223 @@
1
+ import { mkdir, unlink } from "node:fs/promises"
2
+ import { dirname, join, posix } from "node:path"
3
+ import { fileURLToPath } from "node:url"
4
+ import { Database } from "bun:sqlite"
5
+ import { YAML } from "bun"
6
+ import type {
7
+ SqlConnection,
8
+ SqlDatabase,
9
+ SqlParams,
10
+ SqlResult,
11
+ SqlValue,
12
+ } from "../database.ts"
13
+ import type {
14
+ BundledAsset,
15
+ HtmlRewrite,
16
+ PathMXEnvironment,
17
+ } from "../environment.ts"
18
+
19
+ export { publishToDirectory, writePublication } from "./bun-publish.ts"
20
+
21
+ function positional(params: SqlParams): params is readonly SqlValue[] {
22
+ return Array.isArray(params)
23
+ }
24
+
25
+ function isBusy(error: unknown): error is { code: "SQLITE_BUSY" } {
26
+ return (
27
+ typeof error === "object" &&
28
+ error !== null &&
29
+ "code" in error &&
30
+ error.code === "SQLITE_BUSY"
31
+ )
32
+ }
33
+
34
+ async function retryBusy<T>(run: () => T, timeoutMs = 5_000): Promise<T> {
35
+ const deadline = Date.now() + timeoutMs
36
+ while (true) {
37
+ try {
38
+ return run()
39
+ } catch (error) {
40
+ if (!isBusy(error) || Date.now() >= deadline) throw error
41
+ await new Promise((resolve) => setTimeout(resolve, 10))
42
+ }
43
+ }
44
+ }
45
+
46
+ function bunDatabase(path: string): SqlConnection {
47
+ const database = new Database(path, { create: true, strict: true })
48
+ database.exec("PRAGMA foreign_keys = ON")
49
+ database.exec("PRAGMA journal_mode = WAL")
50
+ // A synchronous busy wait blocks the JS thread and can deadlock the async
51
+ // transaction holding the competing connection. Retry by yielding instead.
52
+ database.exec("PRAGMA busy_timeout = 0")
53
+
54
+ let tail = Promise.resolve()
55
+ const enqueue = <T>(run: () => T | Promise<T>) => {
56
+ const result = tail.then(run, run)
57
+ tail = result.then(
58
+ () => undefined,
59
+ () => undefined,
60
+ )
61
+ return result
62
+ }
63
+ const direct = (allowTransaction: boolean): SqlDatabase =>
64
+ Object.freeze({
65
+ all: async <Row>(sql: string, params?: SqlParams) => {
66
+ return retryBusy(() => {
67
+ if (!params) return database.query<Row, []>(sql).all()
68
+ return positional(params)
69
+ ? database.query<Row, SqlValue[]>(sql).all(...params)
70
+ : database
71
+ .query<Row, Record<string, SqlValue>>(sql)
72
+ .all({ ...params })
73
+ })
74
+ },
75
+ get: async <Row>(sql: string, params?: SqlParams) => {
76
+ const row = await retryBusy(() =>
77
+ !params
78
+ ? database.query<Row, []>(sql).get()
79
+ : positional(params)
80
+ ? database.query<Row, SqlValue[]>(sql).get(...params)
81
+ : database
82
+ .query<Row, Record<string, SqlValue>>(sql)
83
+ .get({ ...params }),
84
+ )
85
+ return row ?? undefined
86
+ },
87
+ run: async (sql: string, params?: SqlParams): Promise<SqlResult> => {
88
+ const result = await retryBusy(() =>
89
+ !params
90
+ ? database.query<never, []>(sql).run()
91
+ : positional(params)
92
+ ? database.query<never, SqlValue[]>(sql).run(...params)
93
+ : database
94
+ .query<never, Record<string, SqlValue>>(sql)
95
+ .run({ ...params }),
96
+ )
97
+ return {
98
+ changes: result.changes,
99
+ ...(result.lastInsertRowid != null
100
+ ? { lastInsertRowId: result.lastInsertRowid }
101
+ : {}),
102
+ }
103
+ },
104
+ exec: async (sql: string) => {
105
+ database.exec(sql)
106
+ },
107
+ transaction: async <T>(run: (database: SqlDatabase) => Promise<T>) => {
108
+ if (!allowTransaction) {
109
+ throw new Error("Nested database transactions are not supported.")
110
+ }
111
+ await retryBusy(() => database.exec("BEGIN IMMEDIATE"))
112
+ try {
113
+ const result = await run(direct(false))
114
+ await retryBusy(() => database.exec("COMMIT"))
115
+ return result
116
+ } catch (error) {
117
+ database.exec("ROLLBACK")
118
+ throw error
119
+ }
120
+ },
121
+ })
122
+ const raw = direct(true)
123
+ return Object.freeze({
124
+ all: <Row>(sql: string, params?: SqlParams) =>
125
+ enqueue(() => raw.all<Row>(sql, params)),
126
+ get: <Row>(sql: string, params?: SqlParams) =>
127
+ enqueue(() => raw.get<Row>(sql, params)),
128
+ run: (sql: string, params?: SqlParams) =>
129
+ enqueue(() => raw.run(sql, params)),
130
+ exec: (sql: string) => enqueue(() => raw.exec(sql)),
131
+ transaction: <T>(run: (database: SqlDatabase) => Promise<T>) =>
132
+ enqueue(() => raw.transaction(run)),
133
+ close: () =>
134
+ enqueue(() => {
135
+ database.close(false)
136
+ }),
137
+ })
138
+ }
139
+
140
+ function bundledAssetType(contentType: string) {
141
+ if (contentType.includes("javascript")) return "script" as const
142
+ if (contentType.includes("css")) return "style" as const
143
+ return "file" as const
144
+ }
145
+
146
+ function rewriteHtml(input: string, rules: Parameters<HtmlRewrite>[1]): string
147
+ function rewriteHtml(
148
+ input: Response,
149
+ rules: Parameters<HtmlRewrite>[1],
150
+ ): Response
151
+ function rewriteHtml(
152
+ input: string | Response,
153
+ rules: Parameters<HtmlRewrite>[1],
154
+ ) {
155
+ const rewriter = new HTMLRewriter()
156
+ for (const [selector, handler] of rules) {
157
+ rewriter.on(
158
+ selector,
159
+ handler as HTMLRewriterTypes.HTMLRewriterElementContentHandlers,
160
+ )
161
+ }
162
+ if (typeof input === "string") return rewriter.transform(input)
163
+ return rewriter.transform(input)
164
+ }
165
+
166
+ export const bunEnvironment: PathMXEnvironment = {
167
+ renderMarkdown: (markdown) =>
168
+ Bun.markdown.html(markdown, {
169
+ headings: { ids: true },
170
+ noIndentedCodeBlocks: true,
171
+ }),
172
+ rewriteHtml,
173
+ loadAsset: (url) => Bun.file(url),
174
+ bundle: async (entrypoint) => {
175
+ const result = await Bun.build({
176
+ entrypoints: [
177
+ entrypoint instanceof URL ? fileURLToPath(entrypoint) : entrypoint,
178
+ ],
179
+ target: "browser",
180
+ minify: true,
181
+ define: { "process.env.NODE_ENV": JSON.stringify("production") },
182
+ throw: true,
183
+ })
184
+ return Promise.all(
185
+ result.outputs.map(async (artifact): Promise<BundledAsset> => {
186
+ const path = artifact.path.replace(/^\.\//, "")
187
+ const type = bundledAssetType(artifact.type)
188
+ return type === "file"
189
+ ? { path, type, blob: artifact }
190
+ : { path, type, text: await artifact.text() }
191
+ }),
192
+ )
193
+ },
194
+ loadModule: async (specifier) => {
195
+ if (!(await Bun.file(specifier).exists())) return
196
+ return import(specifier)
197
+ },
198
+ path: posix,
199
+ yaml: {
200
+ parse: YAML.parse,
201
+ stringify: (value) => YAML.stringify(value, null, 2),
202
+ },
203
+ storage: {
204
+ list: (root) => [
205
+ ...new Bun.Glob("**/*").scanSync({ cwd: root, onlyFiles: true }),
206
+ ],
207
+ readText: (path) => Bun.file(path).text(),
208
+ readBlob: (path) => Bun.file(path),
209
+ writeText: async (path, content) => {
210
+ await mkdir(dirname(path), { recursive: true })
211
+ await Bun.write(path, content)
212
+ },
213
+ delete: (path) => unlink(path),
214
+ exists: (path) => Bun.file(path).exists(),
215
+ },
216
+ database: {
217
+ path: join,
218
+ open: async (path) => {
219
+ await mkdir(dirname(path), { recursive: true })
220
+ return bunDatabase(path)
221
+ },
222
+ },
223
+ }
package/globals.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ declare module "*.css" {
2
+ const text: string
3
+ export default text
4
+ }
5
+
6
+ declare module "*.html" {
7
+ const text: string
8
+ export default text
9
+ }
10
+
11
+ declare module "*.js" {
12
+ const text: string
13
+ export default text
14
+ }
package/graph.ts ADDED
@@ -0,0 +1,153 @@
1
+ import type { Repository } from "./repository"
2
+ import { resolveRoute } from "./server/router"
3
+ import {
4
+ interpolateText,
5
+ isExternalHref,
6
+ type Source,
7
+ type LinkDirective,
8
+ type SourceLinkType,
9
+ type SourceNode,
10
+ } from "./source"
11
+
12
+ function splitLinkTarget(href: string) {
13
+ const hash = href.indexOf("#")
14
+ const withoutFragment = hash === -1 ? href : href.slice(0, hash)
15
+ const query = withoutFragment.indexOf("?")
16
+ return {
17
+ pathname: query === -1 ? withoutFragment : withoutFragment.slice(0, query),
18
+ query: query === -1 ? undefined : withoutFragment.slice(query + 1),
19
+ fragment: hash === -1 ? undefined : href.slice(hash + 1),
20
+ }
21
+ }
22
+
23
+ export type GraphEdge = {
24
+ from: string
25
+ href: string
26
+ type: SourceLinkType
27
+ label?: string
28
+ directive?: LinkDirective
29
+ to?: string
30
+ fragment?: string
31
+ }
32
+
33
+ type LinkResolution = {
34
+ type: SourceLinkType
35
+ targetPath?: string
36
+ fragment?: string
37
+ }
38
+
39
+ export class Graph {
40
+ private repository: Repository
41
+ nodes = new Map<string, SourceNode>()
42
+ private outgoing = new Map<string, GraphEdge[]>()
43
+ private incoming = new Map<string, GraphEdge[]>()
44
+
45
+ constructor(repository: Repository) {
46
+ this.repository = repository
47
+ }
48
+
49
+ /** Classify authored Source/Block links and rebuild adjacency. */
50
+ async rebuild() {
51
+ this.nodes.clear()
52
+ this.outgoing.clear()
53
+ this.incoming.clear()
54
+ for (const source of this.repository.all()) {
55
+ await this.indexSource(source)
56
+ }
57
+ }
58
+
59
+ async indexSource(source: Source) {
60
+ await this.indexNode(source)
61
+ for (const block of source.blocks) this.nodes.set(block.path, block)
62
+ }
63
+
64
+ linksFrom(path: string) {
65
+ return this.outgoing.get(path) ?? []
66
+ }
67
+
68
+ linksTo(path: string) {
69
+ return this.incoming.get(path) ?? []
70
+ }
71
+
72
+ unresolvedLinks() {
73
+ return [...this.outgoing.values()]
74
+ .flat()
75
+ .filter((edge) => edge.type === "unresolved")
76
+ }
77
+
78
+ neighbors(path: string) {
79
+ return this.linksFrom(path)
80
+ .map((edge) => (edge.to ? this.nodes.get(edge.to) : undefined))
81
+ .filter((node): node is SourceNode => node != null)
82
+ }
83
+
84
+ backlinks(path: string) {
85
+ return this.linksTo(path)
86
+ .map((edge) => this.nodes.get(edge.from))
87
+ .filter((node): node is SourceNode => node != null)
88
+ }
89
+
90
+ private async indexNode(node: SourceNode) {
91
+ this.nodes.set(node.path, node)
92
+ const edges: GraphEdge[] = []
93
+ for (const link of node.links) {
94
+ const resolved = await this.resolveLink(
95
+ node,
96
+ interpolateText(link.href, node.data),
97
+ )
98
+ const edge: GraphEdge = {
99
+ from: node.path,
100
+ href: link.href,
101
+ type: resolved.type,
102
+ label: link.label,
103
+ directive: link.directive,
104
+ to: resolved.targetPath,
105
+ fragment: resolved.fragment,
106
+ }
107
+ edges.push(edge)
108
+ if (edge.type === "source" && edge.to) this.addIncoming(edge.to, edge)
109
+ }
110
+ this.outgoing.set(node.path, edges)
111
+ }
112
+
113
+ private pageSourceAt(targetPath: string) {
114
+ const route = resolveRoute(this.repository, targetPath)
115
+ return route?.type === "page" ? route.source : undefined
116
+ }
117
+
118
+ private addIncoming(path: string, edge: GraphEdge) {
119
+ const list = this.incoming.get(path)
120
+ if (list) list.push(edge)
121
+ else this.incoming.set(path, [edge])
122
+ }
123
+
124
+ private async resolveLink(
125
+ source: SourceNode,
126
+ href: string,
127
+ ): Promise<LinkResolution> {
128
+ if (isExternalHref(href)) return { type: "external" }
129
+
130
+ if (href.startsWith("~/")) return { type: "actor-relative" }
131
+
132
+ const { pathname, query, fragment } = splitLinkTarget(href)
133
+
134
+ if (!pathname && (query !== undefined || fragment !== undefined))
135
+ return { type: "fragment", targetPath: source.path, fragment }
136
+ if (!pathname) return { type: "unresolved" }
137
+
138
+ const targetPath = pathname.startsWith("/")
139
+ ? this.repository.environment.path.normalize(pathname)
140
+ : this.repository.environment.path.join(
141
+ this.repository.environment.path.dirname(source.path),
142
+ pathname,
143
+ )
144
+ // match URL semantics: directory links resolve to their index source
145
+ const linkedSource =
146
+ this.repository.atPath(targetPath) ?? this.pageSourceAt(targetPath)
147
+ if (linkedSource)
148
+ return { type: "source", targetPath: linkedSource.path, fragment }
149
+ if (await this.repository.isFile(targetPath))
150
+ return { type: "asset", targetPath, fragment }
151
+ return { type: "unresolved", targetPath, fragment }
152
+ }
153
+ }
@@ -0,0 +1,314 @@
1
+ import type {
2
+ CompiledPage,
3
+ CompileContext,
4
+ PageContext,
5
+ } from "../plugins/types.ts"
6
+ import {
7
+ deriveSourceMeta,
8
+ interpolate,
9
+ type Source,
10
+ type SourceBlock,
11
+ } from "../source/index.ts"
12
+ import {
13
+ dependencyKey,
14
+ DependencyIndex,
15
+ PageDependencies,
16
+ type Dependency,
17
+ type DependencyContext,
18
+ } from "../dependencies.ts"
19
+ import {
20
+ applyHeadingPermalinks,
21
+ headingIdsForBlock,
22
+ preserveTaskInstructions,
23
+ unwrapBlockFlow,
24
+ wrapBlockSection,
25
+ wrapPageArticle,
26
+ } from "../document.ts"
27
+ import { isPageSource, publicPath } from "../server/router.ts"
28
+ import { escapeHtml } from "../util.ts"
29
+ import { elementTagNames } from "../view/syntax.ts"
30
+ import type { Engine } from "./engine.ts"
31
+ import type { PluginHost, CompiledViewPlan } from "./plugin-host.ts"
32
+
33
+ /** Owns compilation state, page caches, dependencies, and View plans. */
34
+ export class PageCompiler {
35
+ readonly pageCache = new Map<string, CompiledPage>()
36
+ private compiling = new Set<string>()
37
+ private pageBuilds = new WeakMap<Source, Promise<CompiledPage>>()
38
+ private dependencies = new DependencyIndex()
39
+ private viewPlans = new Map<string, CompiledViewPlan>()
40
+ private sourceElementTags = new Map<string, ReadonlySet<string>>()
41
+
42
+ constructor(
43
+ private engine: Engine,
44
+ private plugins: PluginHost,
45
+ ) {}
46
+
47
+ indexSource(source: Source) {
48
+ this.sourceElementTags.set(source.path, elementTagNames(source.content))
49
+ }
50
+
51
+ unindexSource(path: string) {
52
+ this.sourceElementTags.delete(path)
53
+ }
54
+
55
+ dependentsOf(dependency: string | Dependency) {
56
+ return this.dependencies.dependentsOf(
57
+ typeof dependency === "string" ? dependency : dependencyKey(dependency),
58
+ )
59
+ }
60
+
61
+ dependenciesOf(path: string) {
62
+ return this.dependencies.dependenciesOf(path)
63
+ }
64
+
65
+ planFor(path: string) {
66
+ return this.viewPlans.get(path)
67
+ }
68
+
69
+ isPage(source: Source) {
70
+ return isPageSource(source)
71
+ }
72
+
73
+ compilePage(source: Source) {
74
+ return this.engine.perf.measureAsync(
75
+ "compile.page",
76
+ async () => {
77
+ const dependencies = new PageDependencies()
78
+ let html = wrapPageArticle(
79
+ source,
80
+ await this.compilePageBody(source, dependencies),
81
+ )
82
+ const { assets, addAsset, addBrowserBundle } =
83
+ this.engine.documentAssetBag()
84
+ const ctx: PageContext = {
85
+ source,
86
+ addAsset,
87
+ addBrowserBundle,
88
+ depend: dependencies.depend,
89
+ }
90
+ html = await this.plugins.page(html, ctx, this.engine)
91
+ const compiled = await this.plugins.compileViewComponents(
92
+ html,
93
+ source,
94
+ ctx,
95
+ this.engine,
96
+ )
97
+ html = compiled.html
98
+ this.dependencies.replaceDependent(source.path, dependencies.keys)
99
+ if (compiled.plan) {
100
+ this.viewPlans.set(source.path, compiled.plan)
101
+ } else {
102
+ this.viewPlans.delete(source.path)
103
+ }
104
+ return Object.freeze({
105
+ html,
106
+ assets: Object.freeze([...assets]),
107
+ sourceId: source.id,
108
+ route: publicPath(source),
109
+ })
110
+ },
111
+ source.path,
112
+ )
113
+ }
114
+
115
+ /** Return the cached page, compiling it once on the first request. */
116
+ ensurePage(source: Source) {
117
+ const cached = this.pageCache.get(source.path)
118
+ if (cached) return Promise.resolve(cached)
119
+ return this.cachePage(source)
120
+ }
121
+
122
+ private cachePage(source: Source) {
123
+ const existing = this.pageBuilds.get(source)
124
+ if (existing) return existing
125
+
126
+ const promise = this.compilePage(source)
127
+ .then((page) => {
128
+ if (this.engine.repo.atPath(source.path) === source) {
129
+ this.pageCache.set(source.path, page)
130
+ }
131
+ return page
132
+ })
133
+ .finally(() => this.pageBuilds.delete(source))
134
+ this.pageBuilds.set(source, promise)
135
+ return promise
136
+ }
137
+
138
+ private async compilePageBody(
139
+ source: Source,
140
+ dependencies: DependencyContext,
141
+ ) {
142
+ const parts: string[] = []
143
+ if (source.blocks.length) {
144
+ for (const block of source.blocks) {
145
+ parts.push(await this.compileBlock(source, block, dependencies))
146
+ }
147
+ } else {
148
+ parts.push(
149
+ wrapBlockSection(
150
+ {
151
+ id: "block-0",
152
+ path: source.path + "#block-0",
153
+ type: "block",
154
+ },
155
+ await this.compileContent(
156
+ source,
157
+ source.body,
158
+ source.data,
159
+ undefined,
160
+ dependencies,
161
+ ),
162
+ ),
163
+ )
164
+ }
165
+ return parts.filter(Boolean).join("\n")
166
+ }
167
+
168
+ /** Compile a Source as a document without recursively applying its caller. */
169
+ async compileDocument(
170
+ source: Source,
171
+ dependencies: DependencyContext,
172
+ excludedPluginId: string,
173
+ ) {
174
+ const parts: string[] = []
175
+ if (source.blocks.length) {
176
+ for (const block of source.blocks) {
177
+ parts.push(await this.compileBlockContent(source, block, dependencies))
178
+ }
179
+ } else {
180
+ parts.push(
181
+ await this.compileContent(
182
+ source,
183
+ source.body,
184
+ source.data,
185
+ undefined,
186
+ dependencies,
187
+ ),
188
+ )
189
+ }
190
+ let html = parts.filter(Boolean).join("\n")
191
+ const { assets, addAsset, addBrowserBundle } =
192
+ this.engine.documentAssetBag()
193
+ const ctx: PageContext = {
194
+ source,
195
+ addAsset,
196
+ addBrowserBundle,
197
+ depend: dependencies.depend.bind(dependencies),
198
+ }
199
+ html = await this.plugins.page(html, ctx, this.engine, excludedPluginId)
200
+ return { html, assets }
201
+ }
202
+
203
+ async compileBlock(
204
+ source: Source,
205
+ block: SourceBlock,
206
+ dependencies: DependencyContext = new PageDependencies(),
207
+ options?: { id?: string },
208
+ scope: CompileContext["scope"] = "page",
209
+ ) {
210
+ if (this.compiling.has(block.path)) {
211
+ return "<p>[Circular Include: " + escapeHtml(block.path) + "]</p>"
212
+ }
213
+ this.compiling.add(block.path)
214
+ try {
215
+ const inner = await this.compileBlockContent(
216
+ source,
217
+ block,
218
+ dependencies,
219
+ scope,
220
+ )
221
+ const flow = unwrapBlockFlow(inner)
222
+ if (flow !== undefined) return flow
223
+ return wrapBlockSection(block, inner, options)
224
+ } finally {
225
+ this.compiling.delete(block.path)
226
+ }
227
+ }
228
+
229
+ private compileBlockContent(
230
+ source: Source,
231
+ block: SourceBlock,
232
+ dependencies: DependencyContext,
233
+ scope: CompileContext["scope"] = "page",
234
+ ) {
235
+ return this.compileContent(
236
+ source,
237
+ block.body,
238
+ { ...source.data, ...block.data },
239
+ block,
240
+ dependencies,
241
+ scope,
242
+ )
243
+ }
244
+
245
+ /** Compile-time interpolation context: authored data plus source fields. */
246
+ private interpolationContext(source: Source, data: Source["data"]) {
247
+ return {
248
+ ...data,
249
+ source: {
250
+ id: source.id,
251
+ path: source.path,
252
+ name: source.name,
253
+ type: source.type,
254
+ href: publicPath(source),
255
+ ...deriveSourceMeta(source),
256
+ },
257
+ }
258
+ }
259
+
260
+ private async compileContent(
261
+ source: Source,
262
+ markdown: string,
263
+ data: Source["data"],
264
+ block?: SourceBlock,
265
+ dependencies: DependencyContext = new PageDependencies(),
266
+ scope: CompileContext["scope"] = "page",
267
+ ) {
268
+ const ctx: CompileContext = {
269
+ source,
270
+ block,
271
+ scope,
272
+ slots: [],
273
+ elements: this.sourceElementTags.get(source.path) ?? new Set(),
274
+ depend: dependencies.depend,
275
+ }
276
+ let content = this.plugins.compileVariables(markdown, ctx)
277
+ content = interpolate(content, this.interpolationContext(source, data))
278
+ content = await this.plugins.precompile(content, ctx, this.engine)
279
+ let html = this.engine.environment.renderMarkdown(content)
280
+ html = preserveTaskInstructions(html)
281
+ if (block) {
282
+ const headings = headingIdsForBlock(source, block)
283
+ html = applyHeadingPermalinks(html, headings, block.headings)
284
+ }
285
+ return this.plugins.postcompile(html, ctx, this.engine)
286
+ }
287
+
288
+ buildCache() {
289
+ return this.engine.perf.measureAsync("cache.build", async () => {
290
+ for (const source of this.engine.repo.all()) {
291
+ if (this.isPage(source)) await this.ensurePage(source)
292
+ }
293
+ })
294
+ }
295
+
296
+ async recompilePages(paths: Iterable<string>) {
297
+ const seen = new Set<string>()
298
+ for (const pagePath of paths) {
299
+ if (seen.has(pagePath)) continue
300
+ seen.add(pagePath)
301
+ const page =
302
+ this.engine.repo.atPath(pagePath) ?? this.engine.repo.get(pagePath)
303
+ if (!page || !this.isPage(page)) continue
304
+ if (!this.pageCache.has(page.path)) continue
305
+ await this.cachePage(page)
306
+ }
307
+ }
308
+
309
+ forgetCompiled(path: string) {
310
+ this.pageCache.delete(path)
311
+ this.viewPlans.delete(path)
312
+ this.dependencies.deleteDependent(path)
313
+ }
314
+ }