@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 { isFenceClose, matchFenceOpen } from "./fences.ts"
2
+ import { withoutHtmlComments, type HtmlCommentState } from "./comments.ts"
3
+
4
+ const LIST_ITEM = /^( *)([-+*]|(\d+)[.)])[ \t]+(.+?)[ \t]*$/
5
+ const TASK_ITEM = /^\[([ xX])\](?:[ \t]+(.*))?$/
6
+ const TASK_MARK =
7
+ /(?:^|[ \t]+)\{#([A-Za-z][\w-]*)((?:[ \t]+\.[A-Za-z][\w-]*)*)\}$/
8
+ const ITEM_LINK =
9
+ /^\[([^\]\r\n]+)\]\(\s*(?:<([^>\r\n]+)>|([^\s)]+))(?:[ \t]+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)$/
10
+
11
+ export type MarkdownList = Readonly<{
12
+ type: "ordered" | "unordered"
13
+ start?: number
14
+ items: readonly MarkdownListItem[]
15
+ }>
16
+
17
+ export type MarkdownListItem = Readonly<{
18
+ line: number
19
+ text: string
20
+ children: readonly MarkdownList[]
21
+ }>
22
+
23
+ export type MarkdownListLink = Readonly<{
24
+ label: string
25
+ href: string
26
+ }>
27
+
28
+ export type MarkdownTask = Readonly<{
29
+ line: number
30
+ text: string
31
+ checked: boolean
32
+ id?: string
33
+ tags: readonly string[]
34
+ }>
35
+
36
+ export type MarkdownListParse = Readonly<{
37
+ lists: readonly MarkdownList[]
38
+ tasks: readonly MarkdownTask[]
39
+ other: readonly Readonly<{ line: number; text: string }>[]
40
+ }>
41
+
42
+ type MutableList = {
43
+ type: MarkdownList["type"]
44
+ start?: number
45
+ items: MutableListItem[]
46
+ }
47
+
48
+ type MutableListItem = {
49
+ line: number
50
+ text: string
51
+ children: MutableList[]
52
+ }
53
+
54
+ const EMPTY_CHILDREN = Object.freeze([]) as unknown as MutableList[]
55
+
56
+ function couldBeListItem(line: string) {
57
+ let index = 0
58
+ while (line.charCodeAt(index) === 32) index++
59
+ const marker = line.charCodeAt(index)
60
+ if (marker === 45 || marker === 43 || marker === 42) {
61
+ const separator = line.charCodeAt(index + 1)
62
+ return separator === 32 || separator === 9
63
+ }
64
+ if (marker < 48 || marker > 57) return false
65
+ while (line.charCodeAt(index) >= 48 && line.charCodeAt(index) <= 57) index++
66
+ const terminator = line.charCodeAt(index)
67
+ if (terminator !== 46 && terminator !== 41) return false
68
+ const separator = line.charCodeAt(index + 1)
69
+ return separator === 32 || separator === 9
70
+ }
71
+
72
+ function freezeLists(lists: MutableList[]): readonly MarkdownList[] {
73
+ for (const list of lists) {
74
+ for (const item of list.items) {
75
+ if (item.children !== EMPTY_CHILDREN) freezeLists(item.children)
76
+ Object.freeze(item)
77
+ }
78
+ Object.freeze(list.items)
79
+ Object.freeze(list)
80
+ }
81
+ return Object.freeze(lists)
82
+ }
83
+
84
+ function markdownTask(item: MarkdownListItem): MarkdownTask | undefined {
85
+ const task = item.text.match(TASK_ITEM)
86
+ if (!task) return
87
+ const label = task[2] ?? ""
88
+ const mark = label.match(TASK_MARK)
89
+ const tags = mark?.[2]
90
+ ? mark[2]
91
+ .trim()
92
+ .split(/[ \t]+/)
93
+ .map((tag) => tag.slice(1))
94
+ : []
95
+ return Object.freeze({
96
+ line: item.line,
97
+ text: mark ? label.slice(0, mark.index).trimEnd() : label,
98
+ checked: task[1]!.toLowerCase() === "x",
99
+ ...(mark ? { id: mark[1]! } : {}),
100
+ tags: Object.freeze(tags),
101
+ })
102
+ }
103
+
104
+ function tasksFromLists(lists: readonly MarkdownList[]) {
105
+ const tasks: MarkdownTask[] = []
106
+ const collect = (nested: readonly MarkdownList[]) => {
107
+ for (const list of nested) {
108
+ for (const item of list.items) {
109
+ const task = markdownTask(item)
110
+ if (task) tasks.push(task)
111
+ collect(item.children)
112
+ }
113
+ }
114
+ }
115
+ collect(lists)
116
+ return Object.freeze(tasks)
117
+ }
118
+
119
+ /** Incrementally build nested Markdown lists from already-visible lines. */
120
+ export class MarkdownListCollector {
121
+ readonly lists: MutableList[] = []
122
+ readonly other: Array<{ line: number; text: string }> = []
123
+ private levels: Array<{ indent: number; list: MutableList }> = []
124
+ private finished?: readonly MarkdownList[]
125
+
126
+ constructor(private collectOther = false) {}
127
+
128
+ separate() {
129
+ this.levels.length = 0
130
+ }
131
+
132
+ add(line: string, lineNumber: number) {
133
+ if (!line.trim()) return
134
+ const match = couldBeListItem(line) ? line.match(LIST_ITEM) : null
135
+ if (!match) {
136
+ this.separate()
137
+ if (this.collectOther) this.other.push({ line: lineNumber, text: line })
138
+ return
139
+ }
140
+
141
+ const indent = match[1]!.length
142
+ const type = match[3] ? "ordered" : "unordered"
143
+ const start = match[3] ? Number(match[3]) : undefined
144
+ while (this.levels.length && indent < this.levels.at(-1)!.indent) {
145
+ this.levels.pop()
146
+ }
147
+
148
+ let list: MutableList
149
+ const current = this.levels.at(-1)
150
+ if (current?.indent === indent && current.list.type === type) {
151
+ list = current.list
152
+ } else {
153
+ if (current?.indent === indent) this.levels.pop()
154
+ const parent = this.levels.at(-1)?.list.items.at(-1)
155
+ list = {
156
+ type,
157
+ ...(start !== undefined ? { start } : {}),
158
+ items: [],
159
+ }
160
+ if (parent) {
161
+ if (parent.children === EMPTY_CHILDREN) parent.children = []
162
+ parent.children.push(list)
163
+ } else this.lists.push(list)
164
+ this.levels.push({ indent, list })
165
+ }
166
+
167
+ list.items.push({
168
+ line: lineNumber,
169
+ text: match[4]!,
170
+ children: EMPTY_CHILDREN,
171
+ })
172
+ }
173
+
174
+ finish() {
175
+ return (this.finished ??= freezeLists(this.lists))
176
+ }
177
+ }
178
+
179
+ /** Parse list structure without building a complete Markdown AST. */
180
+ export function parseMarkdownLists(markdown: string): MarkdownListParse {
181
+ const collector = new MarkdownListCollector(true)
182
+ const hasHtmlComments = markdown.includes("<!--")
183
+ let marker: string | undefined
184
+ let inHtmlComment = false
185
+ for (const [index, authoredLine] of markdown.split(/\r?\n/).entries()) {
186
+ if (marker) {
187
+ if (isFenceClose(authoredLine, marker)) marker = undefined
188
+ continue
189
+ }
190
+ const visible: HtmlCommentState | undefined =
191
+ hasHtmlComments || inHtmlComment
192
+ ? withoutHtmlComments(authoredLine, inHtmlComment)
193
+ : undefined
194
+ if (visible) inHtmlComment = visible.inComment
195
+ const visibleLine = visible?.line ?? authoredLine
196
+ const fence = matchFenceOpen(visibleLine)
197
+ if (fence) {
198
+ marker = fence.marker
199
+ collector.add(visibleLine, index + 1)
200
+ continue
201
+ }
202
+ collector.add(visibleLine, index + 1)
203
+ }
204
+ const lists = collector.finish()
205
+ let tasks: readonly MarkdownTask[] | undefined
206
+ return {
207
+ lists,
208
+ /** Derive the flat projection only when a consumer requests tasks. */
209
+ get tasks() {
210
+ return (tasks ??= tasksFromLists(lists))
211
+ },
212
+ other: collector.other,
213
+ }
214
+ }
215
+
216
+ /** Return the one exact inline Markdown link authored by a list item. */
217
+ export function markdownListItemLink(
218
+ item: Pick<MarkdownListItem, "text">,
219
+ ): MarkdownListLink | undefined {
220
+ const match = item.text.match(ITEM_LINK)
221
+ if (!match) return
222
+ return { label: match[1]!, href: (match[2] ?? match[3] ?? "").trim() }
223
+ }
@@ -0,0 +1,74 @@
1
+ import { withoutHtmlComments } from "./comments.ts"
2
+ import { isFenceClose, matchFenceOpen } from "./fences.ts"
3
+
4
+ const HTML_COMMENT_SOURCE = "<!--[\\s\\S]*?(?:-->|$)"
5
+ const INLINE_CODE_SOURCE = "(`+)[\\s\\S]*?\\1"
6
+
7
+ function transformOutside(
8
+ markdown: string,
9
+ protectedSource: string,
10
+ transform: (prose: string) => string,
11
+ ) {
12
+ const protectedSyntax = new RegExp(protectedSource, "g")
13
+ let cursor = 0
14
+ let output = ""
15
+ for (const match of markdown.matchAll(protectedSyntax)) {
16
+ output += transform(markdown.slice(cursor, match.index))
17
+ output += match[0]
18
+ cursor = match.index + match[0].length
19
+ }
20
+ return output + transform(markdown.slice(cursor))
21
+ }
22
+
23
+ /** Transform rendered HTML text without touching preserved comments. */
24
+ export function transformOutsideHtmlComments(
25
+ html: string,
26
+ transform: (visible: string) => string,
27
+ ) {
28
+ if (!html.includes("<!--")) return transform(html)
29
+ return transformOutside(html, HTML_COMMENT_SOURCE, transform)
30
+ }
31
+
32
+ /** Transform visible Markdown prose without touching comments or code. */
33
+ export function transformMarkdownProse(
34
+ markdown: string,
35
+ transform: (prose: string) => string,
36
+ ) {
37
+ const lines = markdown.split(/\r?\n/)
38
+ const output: string[] = []
39
+ const prose: string[] = []
40
+ let fence: string | undefined
41
+ let inHtmlComment = false
42
+
43
+ const flush = () => {
44
+ if (!prose.length) return
45
+ output.push(
46
+ transformOutside(
47
+ prose.join("\n"),
48
+ `${HTML_COMMENT_SOURCE}|${INLINE_CODE_SOURCE}`,
49
+ transform,
50
+ ),
51
+ )
52
+ prose.length = 0
53
+ }
54
+
55
+ for (const line of lines) {
56
+ if (fence) {
57
+ output.push(line)
58
+ if (isFenceClose(line, fence)) fence = undefined
59
+ continue
60
+ }
61
+ const visible = withoutHtmlComments(line, inHtmlComment)
62
+ inHtmlComment = visible.inComment
63
+ const opening = matchFenceOpen(visible.line)
64
+ if (opening) {
65
+ flush()
66
+ fence = opening.marker
67
+ output.push(line)
68
+ continue
69
+ }
70
+ prose.push(line)
71
+ }
72
+ flush()
73
+ return output.join("\n")
74
+ }
@@ -0,0 +1,79 @@
1
+ import { headingFromLine, type MarkdownHeading } from "./headings.ts"
2
+ import {
3
+ isFenceClose,
4
+ matchFenceOpen,
5
+ type MarkdownCodeFence,
6
+ } from "./fences.ts"
7
+ import { withoutHtmlComments, type HtmlCommentState } from "./comments.ts"
8
+
9
+ export type MarkdownStructure = Readonly<{
10
+ /** Ordered, fence-free paragraphs that may contain authored links. */
11
+ linkFragments: string[]
12
+ headings: MarkdownHeading[]
13
+ fences: MarkdownCodeFence[]
14
+ }>
15
+
16
+ /** Scan fence boundaries once and share that context with structural indexes. */
17
+ export function scanMarkdownStructure(markdown: string): MarkdownStructure {
18
+ const lines = markdown.split(/\r?\n/)
19
+ const headings: MarkdownHeading[] = []
20
+ const fences: MarkdownCodeFence[] = []
21
+ const linkFragments: string[] = []
22
+ let linkLines: string[] = []
23
+ const headingCounts = new Map<string, number>()
24
+ const hasHtmlComments = markdown.includes("<!--")
25
+ let inHtmlComment = false
26
+ let open:
27
+ { marker: string; lang: string; info: string; body: string[] } | undefined
28
+ const flushLinkLines = () => {
29
+ if (linkLines.some((line) => line.includes("[") || /<a\b/i.test(line))) {
30
+ linkFragments.push(linkLines.join("\n"))
31
+ }
32
+ linkLines = []
33
+ }
34
+
35
+ for (let index = 0; index < lines.length; index++) {
36
+ const line = lines[index] ?? ""
37
+ if (open) {
38
+ if (!isFenceClose(line, open.marker)) {
39
+ open.body.push(line)
40
+ continue
41
+ }
42
+ fences.push({
43
+ lang: open.lang,
44
+ info: open.info,
45
+ body: open.body.join("\n"),
46
+ })
47
+ open = undefined
48
+ continue
49
+ }
50
+
51
+ const visible: HtmlCommentState | undefined =
52
+ hasHtmlComments || inHtmlComment
53
+ ? withoutHtmlComments(line, inHtmlComment)
54
+ : undefined
55
+ if (visible) inHtmlComment = visible.inComment
56
+ const visibleLine = visible?.line ?? line
57
+
58
+ const fence = matchFenceOpen(visibleLine)
59
+ if (fence) {
60
+ flushLinkLines()
61
+ open = {
62
+ marker: fence.marker,
63
+ lang: fence.info.split(/\s+/, 1)[0] ?? "",
64
+ info: fence.info,
65
+ body: [],
66
+ }
67
+ continue
68
+ }
69
+
70
+ const heading = headingFromLine(visibleLine, headingCounts)
71
+ if (heading) headings.push(heading)
72
+
73
+ if (visibleLine.trim()) linkLines.push(visibleLine)
74
+ else flushLinkLines()
75
+ }
76
+
77
+ flushLinkLines()
78
+ return { linkFragments, headings, fences }
79
+ }
@@ -0,0 +1,82 @@
1
+ import { decodeHtmlEntities } from "../../util.ts"
2
+ import { isFenceClose, matchFenceOpen } from "./fences.ts"
3
+
4
+ /** Collapse inline markdown to plain text: drop images, keep link labels, strip tags and emphasis. */
5
+ export function cleanInlineText(text: string) {
6
+ return decodeHtmlEntities(text)
7
+ .replace(/!\[[^\]\n]*\]\([^)\n]*\)/g, " ")
8
+ .replace(/\[([^\]\n]+)\]\([^)\n]*\)/g, "$1")
9
+ .replace(/<[^>]*>/g, " ")
10
+ .replace(/[`*_~]/g, "")
11
+ .replace(/\s+/g, " ")
12
+ .trim()
13
+ }
14
+
15
+ function isStructuralLine(line: string) {
16
+ return (
17
+ line.startsWith("#") ||
18
+ line.startsWith("<") ||
19
+ line.startsWith(">") ||
20
+ line.startsWith("|") ||
21
+ /^([-*_]\s*){3,}$/.test(line) ||
22
+ /^[-*+]\s+\S/.test(line) ||
23
+ /^\d+[.)]\s+\S/.test(line) ||
24
+ /^!\[[^\]\n]*\]\([^)\n]*\)\s*$/.test(line) ||
25
+ /^\[[^\]\n]+\]\([^)\n]*\)\s*$/.test(line) ||
26
+ /^\[[^\]\n]+\]:/.test(line)
27
+ )
28
+ }
29
+
30
+ function truncateAtWord(text: string, maxLength: number) {
31
+ if (text.length <= maxLength) return text
32
+ const trimmed = text
33
+ .slice(0, maxLength)
34
+ .replace(/\s+\S*$/, "")
35
+ .trim()
36
+ return `${trimmed || text.slice(0, maxLength).trim()}...`
37
+ }
38
+
39
+ /**
40
+ * First prose paragraph of a markdown body: contiguous plain-text lines
41
+ * outside fences, skipping comments, headings, markup, lists, tables, and
42
+ * link/image-only lines. Short scraps (under `minLength`) are skipped and
43
+ * long text is truncated at a word boundary.
44
+ */
45
+ export function firstParagraph(
46
+ markdown: string,
47
+ { minLength = 12, maxLength = 160 } = {},
48
+ ) {
49
+ const lines = markdown.replace(/<!--[\s\S]*?-->/g, "").split(/\r?\n/)
50
+ let paragraph: string[] = []
51
+ let marker: string | undefined
52
+
53
+ const flush = () => {
54
+ if (!paragraph.length) return
55
+ const text = cleanInlineText(paragraph.join(" "))
56
+ paragraph = []
57
+ if (text.length >= minLength) return truncateAtWord(text, maxLength)
58
+ }
59
+
60
+ for (const line of lines) {
61
+ if (marker) {
62
+ if (isFenceClose(line, marker)) marker = undefined
63
+ continue
64
+ }
65
+ const fence = matchFenceOpen(line)
66
+ if (fence) {
67
+ marker = fence.marker
68
+ const text = flush()
69
+ if (text) return text
70
+ continue
71
+ }
72
+ const trimmed = line.trim()
73
+ if (!trimmed || isStructuralLine(trimmed)) {
74
+ const text = flush()
75
+ if (text) return text
76
+ continue
77
+ }
78
+ paragraph.push(trimmed)
79
+ }
80
+
81
+ return flush() ?? ""
82
+ }
package/source/meta.ts ADDED
@@ -0,0 +1,87 @@
1
+ import { posixPath } from "../path.ts"
2
+ import { interpolateText } from "./interpolate.ts"
3
+ import { parseLinks } from "./markdown/links.ts"
4
+ import { cleanInlineText, firstParagraph } from "./markdown/text.ts"
5
+ import type { Source, SourceNode } from "./source.ts"
6
+ import type { SourceBlock } from "./blocks.ts"
7
+
8
+ export type SourceMeta = {
9
+ title: string
10
+ description: string
11
+ image: string
12
+ }
13
+
14
+ export type BlockMeta = SourceMeta
15
+
16
+ const sourceCache = new WeakMap<Source, SourceMeta>()
17
+ const blockCache = new WeakMap<SourceBlock, BlockMeta>()
18
+
19
+ /** Resolve a relative image href against the source folder; leave absolute, external, protocol, fragment, and query hrefs alone. */
20
+ function resolveImage(sourcePath: string, href: string) {
21
+ const trimmed = href.trim()
22
+ if (
23
+ !trimmed ||
24
+ trimmed.startsWith("/") ||
25
+ trimmed.startsWith("#") ||
26
+ trimmed.startsWith("?") ||
27
+ /^(?:\/\/|[a-z][a-z0-9+.-]*:)/i.test(trimmed)
28
+ ) {
29
+ return trimmed
30
+ }
31
+ return posixPath.resolve(posixPath.dirname(sourcePath), trimmed)
32
+ }
33
+
34
+ function firstImageHref(node: SourceNode) {
35
+ // Parse after token fill so `{{ isbn }}` inside a URL is a complete href.
36
+ const filled = interpolateText(node.body, node.data)
37
+ return (
38
+ parseLinks(filled).find((link) => link.kind === "image")?.href ??
39
+ node.links.find((link) => link.kind === "image")?.href ??
40
+ ""
41
+ )
42
+ }
43
+
44
+ function authored(value: unknown) {
45
+ return typeof value === "string" && value.trim() ? value.trim() : undefined
46
+ }
47
+
48
+ function deriveNodeMeta(
49
+ node: SourceNode,
50
+ sourcePath: string,
51
+ fallbackTitle = "",
52
+ ): SourceMeta {
53
+ const headingTitle = cleanInlineText(node.headings[0]?.text ?? "")
54
+ const fill = (text: string) => interpolateText(text, node.data).trim()
55
+ const imageHref = fill(authored(node.data.image) ?? firstImageHref(node))
56
+ return {
57
+ title: fill(authored(node.data.title) ?? (headingTitle || fallbackTitle)),
58
+ description: fill(
59
+ authored(node.data.description) ?? firstParagraph(node.body),
60
+ ),
61
+ image: imageHref ? resolveImage(sourcePath, imageHref) : "",
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Source meta with frontmatter overrides and content fallbacks:
67
+ * title from the first heading, description from the first paragraph,
68
+ * image from the first image link (resolved against the source folder).
69
+ */
70
+ export function deriveSourceMeta(source: Source): SourceMeta {
71
+ const cached = sourceCache.get(source)
72
+ if (cached) return cached
73
+
74
+ const meta = deriveNodeMeta(source, source.path, source.name)
75
+ if (!meta.title) meta.title = source.name
76
+ sourceCache.set(source, meta)
77
+ return meta
78
+ }
79
+
80
+ /** Block-local metadata using the same derivation rules without inheriting Source data. */
81
+ export function deriveBlockMeta(block: SourceBlock, source: Source): BlockMeta {
82
+ const cached = blockCache.get(block)
83
+ if (cached) return cached
84
+ const meta = deriveNodeMeta(block, source.path)
85
+ blockCache.set(block, meta)
86
+ return meta
87
+ }
@@ -0,0 +1,121 @@
1
+ import { posixPath } from "../path.ts"
2
+ import type { PathMXYaml } from "../environment.ts"
3
+ import { createBlock, type SourceBlock } from "./blocks.ts"
4
+ import {
5
+ assignHeadingIds,
6
+ splitMarkdownBlocks,
7
+ type MarkdownCodeFence,
8
+ type MarkdownHeading,
9
+ type MarkdownList,
10
+ type MarkdownTask,
11
+ type SourceLink,
12
+ } from "./markdown/index.ts"
13
+ import type { Topmatter } from "./topmatter.ts"
14
+ import { splitFrontmatter } from "./topmatter.ts"
15
+ import { parseSourceTypeAndQualifiers } from "./utils.ts"
16
+
17
+ /** Shared Source / Block document fields. */
18
+ export type SourceNode = {
19
+ id: string
20
+ path: string
21
+ /** Source: path hint or frontmatter. Block: authored topmatter type, or `"block"`. */
22
+ type: string
23
+ /** Authored local metadata only. Source `data` is markdown frontmatter; Block `data` is Block topmatter. */
24
+ data: Topmatter
25
+ links: SourceLink[]
26
+ headings: MarkdownHeading[]
27
+ fences: MarkdownCodeFence[]
28
+ lists: readonly MarkdownList[]
29
+ tasks: readonly MarkdownTask[]
30
+ /** Raw serialization: Source file text, or the Block section including topmatter. */
31
+ content: string
32
+ /** Compile input: Source after frontmatter, or Block after topmatter. */
33
+ body: string
34
+ }
35
+
36
+ export type Source = SourceNode & {
37
+ name: string
38
+ qualifiers: readonly string[]
39
+ blocks: SourceBlock[]
40
+ }
41
+
42
+ export const DEFAULT_SOURCE_TYPE = "page"
43
+
44
+ export function sourceIdFromPath(path: string, type = DEFAULT_SOURCE_TYPE) {
45
+ if (posixPath.extname(path) !== ".md") return path
46
+ const id = path.slice(0, -3)
47
+ const { typeHint } = parseSourceTypeAndQualifiers(path)
48
+ return typeHint ? id : `${id}.${type}`
49
+ }
50
+
51
+ export function resolveSourceId(path: string, data: Topmatter) {
52
+ if (typeof data.id === "string" && data.id.trim()) return data.id.trim()
53
+ const type =
54
+ typeof data.type === "string" && data.type.trim()
55
+ ? data.type.trim()
56
+ : DEFAULT_SOURCE_TYPE
57
+ return sourceIdFromPath(path, type)
58
+ }
59
+
60
+ export function createSource(
61
+ path: string,
62
+ content: string,
63
+ yaml?: PathMXYaml,
64
+ ): Source {
65
+ const { data, body } = splitFrontmatter(content, yaml)
66
+ const { name, typeHint, qualifiers } = parseSourceTypeAndQualifiers(path)
67
+ const authoredType =
68
+ typeof data.type === "string" && data.type.trim()
69
+ ? data.type.trim()
70
+ : undefined
71
+ const type = typeHint ?? authoredType ?? DEFAULT_SOURCE_TYPE
72
+ const blocks: SourceBlock[] = []
73
+ return {
74
+ id: resolveSourceId(path, data),
75
+ path,
76
+ name,
77
+ type,
78
+ qualifiers,
79
+ data,
80
+ links: [],
81
+ headings: [],
82
+ fences: [],
83
+ /** Source-level list access remains lazy across its indexed Blocks. */
84
+ get lists() {
85
+ return blocks.flatMap((block) => block.lists)
86
+ },
87
+ /** Source-level tasks aggregate the same lazy Block structures. */
88
+ get tasks() {
89
+ return blocks.flatMap((block) => block.tasks)
90
+ },
91
+ content,
92
+ body,
93
+ blocks,
94
+ }
95
+ }
96
+
97
+ /** Fill Source indexes from Block parses. Heading ids are unique across the Source. */
98
+ export function rollupSourceIndexes(source: Source) {
99
+ const links = new Map<string, SourceLink>()
100
+ const headings: MarkdownHeading[] = []
101
+ const fences: MarkdownCodeFence[] = []
102
+ for (const block of source.blocks) {
103
+ for (const link of block.links) {
104
+ if (!links.has(link.href)) links.set(link.href, link)
105
+ }
106
+ headings.push(...block.headings)
107
+ fences.push(...block.fences)
108
+ }
109
+ source.links = [...links.values()]
110
+ source.headings = assignHeadingIds(headings)
111
+ source.fences = fences
112
+ }
113
+
114
+ /** Rebuild the canonical Block tree and derived Source indexes from authored Markdown. */
115
+ export function indexSourceDocument(source: Source, yaml: PathMXYaml) {
116
+ const blocks = splitMarkdownBlocks(source.body).map((content, ordinal) =>
117
+ createBlock(source, ordinal, content, {}, yaml),
118
+ )
119
+ source.blocks.splice(0, source.blocks.length, ...blocks)
120
+ rollupSourceIndexes(source)
121
+ }