@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,202 @@
1
+ import type { Engine } from "../host/engine.ts"
2
+ import type { Plugin, ServerRoute } from "../plugins/types.ts"
3
+ import { createServerContext } from "./context.ts"
4
+ import { protocolRoute } from "./protocols.ts"
5
+
6
+ type RegisteredRoute = ServerRoute & {
7
+ plugin: string
8
+ relative: string
9
+ prefix?: string
10
+ }
11
+
12
+ const API_ROOT = "/api"
13
+
14
+ const METHOD_ORDER = [
15
+ "GET",
16
+ "HEAD",
17
+ "POST",
18
+ "PUT",
19
+ "PATCH",
20
+ "DELETE",
21
+ "OPTIONS",
22
+ ] as const
23
+ const ROUTE_METHODS = new Set([
24
+ "GET",
25
+ "POST",
26
+ "PUT",
27
+ "PATCH",
28
+ "DELETE",
29
+ "OPTIONS",
30
+ "*",
31
+ ])
32
+
33
+ function canonicalRoutePath(path: string) {
34
+ if (path.startsWith("/") || path.includes("?") || path.includes("#")) {
35
+ throw new Error(`Invalid server route path: ${path}`)
36
+ }
37
+ const wildcard = path === "*" || path.endsWith("/*")
38
+ const base = path === "*" ? "" : wildcard ? path.slice(0, -2) : path
39
+ if (base.includes("*") || base.includes("\\") || /%2f|%5c/i.test(base)) {
40
+ throw new Error(`Invalid server route path: ${path}`)
41
+ }
42
+ const mounted = `${API_ROOT}/${base}`
43
+ const url = new URL(mounted, "http://pathmx.local")
44
+ if (
45
+ url.pathname !== mounted ||
46
+ url.search ||
47
+ url.hash ||
48
+ base.split("/").includes("..")
49
+ ) {
50
+ throw new Error(`Invalid server route path: ${path}`)
51
+ }
52
+ return wildcard ? (base ? `${base}/*` : "*") : base
53
+ }
54
+
55
+ function mountedRoute(plugin: string, path: string) {
56
+ const suffix = path ? `/${path}` : ""
57
+ return `${API_ROOT}/${plugin}${suffix}`
58
+ }
59
+
60
+ function relativeRequestPath(plugin: string, pathname: string) {
61
+ const root = `${API_ROOT}/${plugin}`
62
+ return pathname === root ? "" : pathname.slice(root.length + 1)
63
+ }
64
+
65
+ function prefixFor(path: string) {
66
+ return path.endsWith("/*") ? path.slice(0, -1) : undefined
67
+ }
68
+
69
+ function methodsOverlap(a: ServerRoute["method"], b: ServerRoute["method"]) {
70
+ return a === "*" || b === "*" || a === b
71
+ }
72
+
73
+ function pathsOverlap(a: RegisteredRoute, b: RegisteredRoute) {
74
+ const aPrefix = a.prefix
75
+ const bPrefix = b.prefix
76
+ if (!aPrefix && !bPrefix) return a.path === b.path
77
+ if (aPrefix && bPrefix) {
78
+ return aPrefix.startsWith(bPrefix) || bPrefix.startsWith(aPrefix)
79
+ }
80
+ if (aPrefix) return b.path.startsWith(aPrefix)
81
+ if (bPrefix) return a.path.startsWith(bPrefix)
82
+ return false
83
+ }
84
+
85
+ function overlapMessage(a: RegisteredRoute, b: RegisteredRoute) {
86
+ return `Overlapping server routes: ${a.method} ${a.path} (${a.plugin}) and ${b.method} ${b.path} (${b.plugin})`
87
+ }
88
+
89
+ function withoutBody(response: Response) {
90
+ return new Response(null, {
91
+ status: response.status,
92
+ statusText: response.statusText,
93
+ headers: response.headers,
94
+ })
95
+ }
96
+
97
+ export class ServerRouteRegistry {
98
+ private routes: RegisteredRoute[] = []
99
+ private exact = new Map<string, RegisteredRoute[]>()
100
+ private prefixes: RegisteredRoute[] = []
101
+
102
+ prepare(plugin: Plugin) {
103
+ const pending = (plugin.routes ?? []).map((route) => {
104
+ if (!ROUTE_METHODS.has(route.method)) {
105
+ throw new Error(`Invalid server route method: ${route.method}`)
106
+ }
107
+ const relative = canonicalRoutePath(route.path)
108
+ const path = mountedRoute(plugin.id, relative)
109
+ return {
110
+ ...route,
111
+ path,
112
+ relative,
113
+ plugin: plugin.id,
114
+ prefix: prefixFor(path),
115
+ } satisfies RegisteredRoute
116
+ })
117
+ const candidates = [...this.routes]
118
+ for (const route of pending) {
119
+ for (const existing of candidates) {
120
+ if (
121
+ pathsOverlap(route, existing) &&
122
+ (route.path !== existing.path ||
123
+ methodsOverlap(route.method, existing.method))
124
+ ) {
125
+ throw new Error(overlapMessage(existing, route))
126
+ }
127
+ }
128
+ candidates.push(route)
129
+ }
130
+ return pending
131
+ }
132
+
133
+ register(routes: readonly RegisteredRoute[]) {
134
+ for (const route of routes) {
135
+ this.routes.push(route)
136
+ if (route.prefix) {
137
+ this.prefixes.push(route)
138
+ continue
139
+ }
140
+ const exact = this.exact.get(route.path) ?? []
141
+ exact.push(route)
142
+ this.exact.set(route.path, exact)
143
+ }
144
+ }
145
+
146
+ async dispatch(
147
+ request: Request,
148
+ url: URL,
149
+ app: Engine,
150
+ publicUrl: URL = url,
151
+ ) {
152
+ const pathname = protocolRoute(url.pathname) ?? url.pathname
153
+ const exact = this.exact.get(pathname)
154
+ const matches = exact ? [...exact] : []
155
+ if (!exact) {
156
+ for (const route of this.prefixes) {
157
+ const prefix = route.prefix
158
+ if (prefix && pathname.startsWith(prefix)) matches.push(route)
159
+ }
160
+ }
161
+ if (!matches.length) return
162
+
163
+ const method = request.method.toUpperCase()
164
+ const route = matches.find(
165
+ (candidate) =>
166
+ candidate.method === "*" ||
167
+ candidate.method === method ||
168
+ (method === "HEAD" && candidate.method === "GET"),
169
+ )
170
+ if (!route) {
171
+ const allowed = new Set(
172
+ matches.flatMap((candidate) =>
173
+ candidate.method === "GET" ? ["GET", "HEAD"] : [candidate.method],
174
+ ),
175
+ )
176
+ return new Response("Method Not Allowed", {
177
+ status: 405,
178
+ headers: {
179
+ Allow: METHOD_ORDER.filter((candidate) =>
180
+ allowed.has(candidate),
181
+ ).join(", "),
182
+ },
183
+ })
184
+ }
185
+
186
+ const response = await app.perf.measureAsync(
187
+ `route.${route.plugin}`,
188
+ async () =>
189
+ route.handle(
190
+ createServerContext(
191
+ app,
192
+ route.plugin,
193
+ relativeRequestPath(route.plugin, pathname),
194
+ request,
195
+ publicUrl,
196
+ ),
197
+ ),
198
+ route.path,
199
+ )
200
+ return method === "HEAD" ? withoutBody(response) : response
201
+ }
202
+ }
@@ -0,0 +1,5 @@
1
+ import { join } from "node:path"
2
+ import { startServer } from "../cli/serve.ts"
3
+
4
+ const PATHS_ROOT = join(import.meta.dir, "..", "..", "paths")
5
+ await startServer({ root: PATHS_ROOT })
@@ -0,0 +1,120 @@
1
+ import watcher from "@parcel/watcher"
2
+ import { realpathSync } from "node:fs"
3
+ import { relative } from "node:path"
4
+ import type { Engine } from "../host/engine.ts"
5
+ import { SourceDiagnosticError } from "../source-diagnostic.ts"
6
+ import { CssImportIndex } from "./css-imports.ts"
7
+
8
+ type UnresolvedLink = {
9
+ source: string
10
+ href: string
11
+ target?: string
12
+ }
13
+
14
+ function unresolvedLinks(app: Engine) {
15
+ const unresolved = new Map<string, UnresolvedLink>()
16
+ for (const edge of app.graph.unresolvedLinks()) {
17
+ const key = `${edge.from}\0${edge.href}`
18
+ unresolved.set(key, {
19
+ source: edge.from,
20
+ href: edge.href,
21
+ target: edge.to,
22
+ })
23
+ }
24
+ return unresolved
25
+ }
26
+
27
+ function reportLinkChanges(
28
+ app: Engine,
29
+ previous?: ReadonlyMap<string, UnresolvedLink>,
30
+ ) {
31
+ const current = unresolvedLinks(app)
32
+ for (const [key, link] of current) {
33
+ if (previous?.has(key)) continue
34
+ app.log.warn("link.unresolved", link)
35
+ }
36
+ if (previous) {
37
+ for (const [key, link] of previous) {
38
+ if (current.has(key)) continue
39
+ app.log.info("link.resolved", { source: link.source, href: link.href })
40
+ }
41
+ }
42
+ return current
43
+ }
44
+
45
+ export function watchPaths(app: Engine, root: string) {
46
+ const base = realpathSync(root)
47
+ const cssImports = new CssImportIndex()
48
+ let queue = Promise.resolve()
49
+ let unresolved = reportLinkChanges(app)
50
+
51
+ const indexStylesheet = async (rel: string) => {
52
+ const path = app.repo.graphPath(rel)
53
+ const storagePath = app.repo.storagePath(path)
54
+ if (!storagePath) return cssImports.replace(path)
55
+ try {
56
+ cssImports.replace(
57
+ path,
58
+ await app.environment.storage.readText(storagePath),
59
+ )
60
+ } catch {
61
+ cssImports.replace(path)
62
+ }
63
+ }
64
+
65
+ const initializeStylesheets = async () => {
66
+ const files = [...(await app.environment.storage.list(base))].filter(
67
+ (file) => file.endsWith(".css"),
68
+ )
69
+ for (let start = 0; start < files.length; start += 16) {
70
+ await Promise.all(files.slice(start, start + 16).map(indexStylesheet))
71
+ }
72
+ }
73
+
74
+ const enqueue = (work: () => Promise<void>) => {
75
+ queue = queue.then(work).catch((error: Error) => {
76
+ if (error instanceof SourceDiagnosticError) {
77
+ app.log.error("source.invalid", error.diagnostic)
78
+ return
79
+ }
80
+ app.log.error("watch.error", { message: error.message })
81
+ })
82
+ }
83
+
84
+ const subscription = initializeStylesheets().then(() =>
85
+ watcher.subscribe(base, (error, events) => {
86
+ if (error) {
87
+ app.log.error("watch.error", { message: error.message })
88
+ return
89
+ }
90
+ enqueue(async () => {
91
+ const changedStyles = new Set<string>()
92
+ for (const event of events) {
93
+ const rel = relative(base, event.path)
94
+ if (!rel || rel.startsWith("..")) continue
95
+ app.log.debug("watch", { file: rel, type: event.type })
96
+ if (rel.endsWith(".md")) await app.syncFromStorage(rel)
97
+ else if (rel.endsWith(".css")) {
98
+ await indexStylesheet(rel)
99
+ for (const path of cssImports.affected(app.repo.graphPath(rel))) {
100
+ changedStyles.add(path)
101
+ }
102
+ } else if (rel.endsWith(".js")) app.invalidateAsset(rel)
103
+ }
104
+ if (changedStyles.size) app.invalidateAssets(changedStyles)
105
+ unresolved = reportLinkChanges(app, unresolved)
106
+ })
107
+ }),
108
+ )
109
+ subscription.catch((error: Error) =>
110
+ app.log.error("watch.error", { message: error.message }),
111
+ )
112
+
113
+ return {
114
+ subscription,
115
+ async close() {
116
+ const sub = await subscription.catch(() => undefined)
117
+ await sub?.unsubscribe()
118
+ },
119
+ }
120
+ }
@@ -0,0 +1,71 @@
1
+ import { parseLinkFragments } from "./markdown/links.ts"
2
+ import { parseMarkdownLists } from "./markdown/lists.ts"
3
+ import { scanMarkdownStructure } from "./markdown/scan.ts"
4
+ import type { PathMXYaml } from "../environment.ts"
5
+ import { SourceDiagnosticError } from "../source-diagnostic.ts"
6
+ import type { Source, SourceNode } from "./source.ts"
7
+ import {
8
+ splitTopmatter,
9
+ type Topmatter,
10
+ YamlMapParseError,
11
+ } from "./topmatter.ts"
12
+
13
+ export type SourceBlock = SourceNode
14
+
15
+ function authoredType(data: Topmatter) {
16
+ return typeof data.type === "string" && data.type.trim()
17
+ ? data.type.trim()
18
+ : "block"
19
+ }
20
+
21
+ function authoredId(data: Topmatter) {
22
+ return typeof data.id === "string" && data.id.trim()
23
+ ? data.id.trim()
24
+ : undefined
25
+ }
26
+
27
+ export function createBlock(
28
+ parent: Source,
29
+ ordinal: number,
30
+ content: string,
31
+ topmatter: Topmatter = {},
32
+ yaml?: PathMXYaml,
33
+ ): SourceBlock {
34
+ let split: ReturnType<typeof splitTopmatter>
35
+ try {
36
+ split = splitTopmatter(content, yaml)
37
+ } catch (error) {
38
+ if (!(error instanceof YamlMapParseError)) throw error
39
+ throw new SourceDiagnosticError({
40
+ source: parent.path,
41
+ plugin: "source",
42
+ message: `Invalid Block topmatter in ${parent.path} (block ${ordinal + 1}): ${error.message}`,
43
+ })
44
+ }
45
+ const data = { ...split.data, ...topmatter }
46
+ const body = split.body.trim()
47
+ const id = authoredId(data) ?? `block-${ordinal}`
48
+ const structure = scanMarkdownStructure(body)
49
+ let listStructure: ReturnType<typeof parseMarkdownLists> | undefined
50
+ const parseListStructure = () => (listStructure ??= parseMarkdownLists(body))
51
+
52
+ return {
53
+ id,
54
+ path: `${parent.path}#${id}`,
55
+ type: authoredType(data),
56
+ data,
57
+ links: parseLinkFragments(structure.linkFragments),
58
+ headings: structure.headings,
59
+ fences: structure.fences,
60
+ /** Lists are cached only when a plugin asks for their structure. */
61
+ get lists() {
62
+ return parseListStructure().lists
63
+ },
64
+ /** Tasks share the lazy list parse instead of rescanning Markdown. */
65
+ get tasks() {
66
+ return parseListStructure().tasks
67
+ },
68
+ content,
69
+ body,
70
+ }
71
+ }
@@ -0,0 +1,164 @@
1
+ import type { PathMXYaml } from "../environment.ts"
2
+ import { splitMarkdownBlocks } from "./markdown/blocks.ts"
3
+ import { splitFrontmatter, splitTopmatter } from "./topmatter.ts"
4
+ import type { Topmatter } from "./topmatter.ts"
5
+
6
+ export type BlockDraft = {
7
+ id?: string
8
+ type?: string
9
+ data?: Topmatter
10
+ body?: string
11
+ }
12
+
13
+ export type BlockUpdate =
14
+ BlockDraft | ((current: Readonly<BlockDraft>) => BlockDraft)
15
+
16
+ function compact(data: Topmatter) {
17
+ return Object.fromEntries(
18
+ Object.entries(data).filter(
19
+ ([, value]) => value !== undefined && value !== "",
20
+ ),
21
+ )
22
+ }
23
+
24
+ export function formatFrontmatter(data: Topmatter, yaml: PathMXYaml) {
25
+ const fields = compact(data)
26
+ if (!Object.keys(fields).length) return ""
27
+ return `---\n${yaml.stringify(fields).trimEnd()}\n---\n`
28
+ }
29
+
30
+ export function formatBlock(block: BlockDraft, yaml: PathMXYaml) {
31
+ const data = compact({
32
+ ...block.data,
33
+ ...(block.id ? { id: block.id } : {}),
34
+ ...(block.type ? { type: block.type } : {}),
35
+ })
36
+ const comment = Object.keys(data).length
37
+ ? `<!--\n${yaml.stringify(data).trimEnd()}\n-->`
38
+ : ""
39
+ const body = (block.body ?? "").trim()
40
+ return [comment, body].filter(Boolean).join("\n\n")
41
+ }
42
+
43
+ export function appendBlockSection(
44
+ content: string,
45
+ block: BlockDraft,
46
+ yaml: PathMXYaml,
47
+ ) {
48
+ const { data, body } = splitFrontmatter(content, yaml)
49
+ const sections = splitMarkdownBlocks(body)
50
+ sections.push(formatBlock(block, yaml))
51
+ return joinSections(data, sections, yaml)
52
+ }
53
+
54
+ function sameTypeIndex(sections: string[], type: string | undefined) {
55
+ if (!type) return 0
56
+ const index = sections.findIndex(
57
+ (section) =>
58
+ new RegExp(`(?:^|\\n)type:\\s*${type}\\s*$`, "m").test(section) ||
59
+ section.includes(`<${type}`),
60
+ )
61
+ return index === -1 ? sections.length : index
62
+ }
63
+
64
+ /** Insert a block before the first existing block of the same type (newest first). */
65
+ export function prependBlockSection(
66
+ content: string,
67
+ block: BlockDraft,
68
+ yaml: PathMXYaml,
69
+ ) {
70
+ const { data, body } = splitFrontmatter(content, yaml)
71
+ const sections = splitMarkdownBlocks(body)
72
+ sections.splice(
73
+ sameTypeIndex(sections, block.type),
74
+ 0,
75
+ formatBlock(block, yaml),
76
+ )
77
+ return `${formatFrontmatter(data, yaml)}${sections.join("\n\n---\n\n")}\n`
78
+ }
79
+
80
+ export function formatNewSource(
81
+ data: Topmatter,
82
+ yaml: PathMXYaml,
83
+ blocks: BlockDraft[] = [],
84
+ ) {
85
+ const front = formatFrontmatter(data, yaml)
86
+ if (!blocks.length) return front
87
+ const body = blocks
88
+ .map((block) => formatBlock(block, yaml))
89
+ .join("\n\n---\n\n")
90
+ return `${front}${front ? "\n" : ""}${body}\n`
91
+ }
92
+
93
+ export function patchFrontmatter(
94
+ content: string,
95
+ patch: Topmatter,
96
+ yaml: PathMXYaml,
97
+ ) {
98
+ const { data, body } = splitFrontmatter(content, yaml)
99
+ return `${formatFrontmatter({ ...data, ...patch }, yaml)}${body}`
100
+ }
101
+
102
+ function sectionId(section: string, ordinal: number, yaml: PathMXYaml) {
103
+ const { data } = splitTopmatter(section, yaml)
104
+ return typeof data.id === "string" && data.id.trim()
105
+ ? data.id.trim()
106
+ : `block-${ordinal}`
107
+ }
108
+
109
+ function sectionIndex(sections: string[], id: string, yaml: PathMXYaml) {
110
+ const index = sections.findIndex(
111
+ (section, ordinal) => sectionId(section, ordinal, yaml) === id,
112
+ )
113
+ if (index === -1) throw new Error(`Block not found: ${id}`)
114
+ return index
115
+ }
116
+
117
+ function joinSections(data: Topmatter, sections: string[], yaml: PathMXYaml) {
118
+ const front = formatFrontmatter(data, yaml)
119
+ if (!sections.length) return front
120
+ return `${front}${sections.join("\n\n---\n\n")}\n`
121
+ }
122
+
123
+ /** Merge topmatter into an existing block; replace body when `patch.body` is set. */
124
+ export function updateBlockSection(
125
+ content: string,
126
+ id: string,
127
+ update: BlockUpdate,
128
+ yaml: PathMXYaml,
129
+ ) {
130
+ const { data, body } = splitFrontmatter(content, yaml)
131
+ const sections = splitMarkdownBlocks(body)
132
+ const index = sectionIndex(sections, id, yaml)
133
+ const current = splitTopmatter(sections[index]!, yaml)
134
+ const { id: currentId, type: currentType, ...currentData } = current.data
135
+ const snapshot: BlockDraft = {
136
+ ...(typeof currentId === "string" ? { id: currentId } : {}),
137
+ ...(typeof currentType === "string" ? { type: currentType } : {}),
138
+ data: currentData,
139
+ body: current.body,
140
+ }
141
+ const patch = typeof update === "function" ? update(snapshot) : update
142
+ sections[index] = formatBlock(
143
+ {
144
+ id: patch.id ?? snapshot.id,
145
+ type: patch.type ?? snapshot.type,
146
+ data: { ...snapshot.data, ...patch.data },
147
+ body: patch.body !== undefined ? patch.body : current.body,
148
+ },
149
+ yaml,
150
+ )
151
+ return joinSections(data, sections, yaml)
152
+ }
153
+
154
+ export function removeBlockSection(
155
+ content: string,
156
+ id: string,
157
+ yaml: PathMXYaml,
158
+ ) {
159
+ const { data, body } = splitFrontmatter(content, yaml)
160
+ const sections = splitMarkdownBlocks(body)
161
+ const index = sectionIndex(sections, id, yaml)
162
+ sections.splice(index, 1)
163
+ return joinSections(data, sections, yaml)
164
+ }
@@ -0,0 +1,67 @@
1
+ export {
2
+ DEFAULT_SOURCE_TYPE,
3
+ createSource,
4
+ indexSourceDocument,
5
+ resolveSourceId,
6
+ rollupSourceIndexes,
7
+ sourceIdFromPath,
8
+ type Source,
9
+ type SourceNode,
10
+ } from "./source.ts"
11
+ export { createBlock, type SourceBlock } from "./blocks.ts"
12
+ export {
13
+ assignHeadingIds,
14
+ collectHeadings,
15
+ isExternalHref,
16
+ isFenceClose,
17
+ matchFenceOpen,
18
+ parseCodeFences,
19
+ parseLinkDirective,
20
+ parseLinks,
21
+ rewriteLinks,
22
+ unlinkLinks,
23
+ splitMarkdownBlocks,
24
+ type FenceOpen,
25
+ type LinkDirective,
26
+ type MarkdownCodeFence,
27
+ type MarkdownHeading,
28
+ type MarkdownList,
29
+ type MarkdownListItem,
30
+ type MarkdownListLink,
31
+ type MarkdownListParse,
32
+ type MarkdownTask,
33
+ type SourceLink,
34
+ type SourceLinkKind,
35
+ type SourceLinkType,
36
+ markdownListItemLink,
37
+ parseMarkdownLists,
38
+ } from "./markdown/index.ts"
39
+ export {
40
+ appendBlockSection,
41
+ prependBlockSection,
42
+ removeBlockSection,
43
+ updateBlockSection,
44
+ formatBlock,
45
+ formatFrontmatter,
46
+ formatNewSource,
47
+ patchFrontmatter,
48
+ type BlockDraft,
49
+ type BlockUpdate,
50
+ } from "./format.ts"
51
+ export { interpolate, interpolateText } from "./interpolate.ts"
52
+ export {
53
+ deriveBlockMeta,
54
+ deriveSourceMeta,
55
+ type BlockMeta,
56
+ type SourceMeta,
57
+ } from "./meta.ts"
58
+ export {
59
+ parseYamlMap,
60
+ splitFrontmatter,
61
+ splitTopmatter,
62
+ type Topmatter,
63
+ } from "./topmatter.ts"
64
+ export {
65
+ parseSourceTypeAndQualifiers,
66
+ type SourcePathNameMeta,
67
+ } from "./utils.ts"
@@ -0,0 +1,63 @@
1
+ import type { Topmatter } from "./topmatter.ts"
2
+ import { isFenceClose, matchFenceOpen } from "./markdown/fences.ts"
3
+ import { escapeHtml } from "../util.ts"
4
+
5
+ const TOKEN = /\{\{\s*([A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*)\s*\}\}/g
6
+
7
+ function lookup(data: Topmatter, path: string) {
8
+ let current: unknown = data
9
+ for (const key of path.split(".")) {
10
+ if (!current || typeof current !== "object" || Array.isArray(current))
11
+ return ""
12
+ current = (current as Record<string, unknown>)[key]
13
+ }
14
+ if (
15
+ typeof current === "string" ||
16
+ typeof current === "number" ||
17
+ typeof current === "boolean"
18
+ ) {
19
+ return String(current)
20
+ }
21
+ return ""
22
+ }
23
+
24
+ function interpolateLine(line: string, data: Topmatter) {
25
+ return line.replace(TOKEN, (_match, path: string) =>
26
+ escapeHtml(lookup(data, path)),
27
+ )
28
+ }
29
+
30
+ /**
31
+ * Fill `{{ key }}` tokens in a plain-text value without HTML-escaping.
32
+ * For derived values (meta text, attributes) that are escaped at render time.
33
+ */
34
+ export function interpolateText(text: string, data: Topmatter) {
35
+ return text.replace(TOKEN, (_match, path: string) => lookup(data, path))
36
+ }
37
+
38
+ /**
39
+ * Replace `{{ key }}` / `{{ nested.key }}` from a node's data bag.
40
+ * Missing paths become `""`. Fenced code is left untouched.
41
+ */
42
+ export function interpolate(markdown: string, data: Topmatter) {
43
+ const lines = markdown.split(/\r?\n/)
44
+ const out: string[] = []
45
+ let marker: string | undefined
46
+
47
+ for (const line of lines) {
48
+ if (marker) {
49
+ out.push(line)
50
+ if (isFenceClose(line, marker)) marker = undefined
51
+ continue
52
+ }
53
+ const fence = matchFenceOpen(line)
54
+ if (fence) {
55
+ marker = fence.marker
56
+ out.push(line)
57
+ continue
58
+ }
59
+ out.push(interpolateLine(line, data))
60
+ }
61
+
62
+ return out.join("\n")
63
+ }