@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.
- package/LICENSE.md +172 -0
- package/README.md +111 -0
- package/actions/actions.ts +307 -0
- package/actions/draft.ts +105 -0
- package/actions/transaction.ts +164 -0
- package/assets.ts +149 -0
- package/authority/access.ts +820 -0
- package/authority/actors.ts +271 -0
- package/authority/agents.ts +95 -0
- package/authority/credentials.ts +144 -0
- package/authority/model.ts +71 -0
- package/authority/sharing.ts +89 -0
- package/browser-bundle.ts +61 -0
- package/canonical.ts +41 -0
- package/cli/dev.ts +134 -0
- package/cli/export.ts +74 -0
- package/cli/index.ts +52 -0
- package/cli/lint.ts +164 -0
- package/cli/mv.ts +30 -0
- package/cli/perms.ts +182 -0
- package/cli/plugin-packages.ts +159 -0
- package/cli/plugins.ts +95 -0
- package/cli/query.ts +225 -0
- package/cli/readme.md +95 -0
- package/cli/release.ts +75 -0
- package/cli/rm.ts +22 -0
- package/cli/serve.ts +100 -0
- package/cli/tree.ts +131 -0
- package/cli/update.ts +78 -0
- package/cli/utils.ts +53 -0
- package/core.ts +96 -0
- package/database.ts +189 -0
- package/dependencies.ts +60 -0
- package/dist/pathmx.js +1224 -0
- package/document.ts +194 -0
- package/environment.ts +110 -0
- package/environments/bun-plugins.ts +68 -0
- package/environments/bun-publish.ts +194 -0
- package/environments/bun.ts +223 -0
- package/globals.d.ts +14 -0
- package/graph.ts +153 -0
- package/host/compiler.ts +314 -0
- package/host/create.ts +66 -0
- package/host/engine.ts +630 -0
- package/host/plugin-host.ts +487 -0
- package/host/render.ts +102 -0
- package/host/response.ts +20 -0
- package/host/shell.html +15 -0
- package/host/source-sync.ts +254 -0
- package/html-attributes.ts +15 -0
- package/html.ts +48 -0
- package/icons/LICENSE +43 -0
- package/icons/lucide.ts +54 -0
- package/index.ts +75 -0
- package/invalidation.ts +85 -0
- package/log.ts +84 -0
- package/ops/href.ts +87 -0
- package/ops/index.ts +9 -0
- package/ops/move-source.ts +114 -0
- package/ops/plan.ts +47 -0
- package/ops/remove-source.ts +70 -0
- package/package.json +46 -0
- package/path.ts +75 -0
- package/pattern.ts +314 -0
- package/perf.ts +88 -0
- package/plugins/actor-path.ts +43 -0
- package/plugins/actor-profile.ts +55 -0
- package/plugins/agent-browsing.ts +183 -0
- package/plugins/app-component.ts +23 -0
- package/plugins/block-layout/index.ts +78 -0
- package/plugins/block-layout/theme.css +90 -0
- package/plugins/callout/index.ts +236 -0
- package/plugins/callout/theme.css +138 -0
- package/plugins/canonical-links.ts +144 -0
- package/plugins/code-highlight/client.ts +89 -0
- package/plugins/code-highlight/index.ts +147 -0
- package/plugins/code-highlight/theme.css +89 -0
- package/plugins/context.ts +131 -0
- package/plugins/datatype/Datatype[wdth,wght].woff2 +0 -0
- package/plugins/datatype/OFL.txt +96 -0
- package/plugins/datatype/index.ts +287 -0
- package/plugins/definition-list.ts +236 -0
- package/plugins/directive-definitions.ts +13 -0
- package/plugins/embedding/client.ts +88 -0
- package/plugins/embedding/index.ts +43 -0
- package/plugins/image/index.ts +68 -0
- package/plugins/image/theme.css +43 -0
- package/plugins/include.ts +119 -0
- package/plugins/index.ts +91 -0
- package/plugins/input/authoring.ts +236 -0
- package/plugins/input/config.ts +85 -0
- package/plugins/input/index.ts +359 -0
- package/plugins/input/model.ts +173 -0
- package/plugins/input/state.ts +272 -0
- package/plugins/layout.ts +313 -0
- package/plugins/literate/client.ts +208 -0
- package/plugins/literate/index.ts +742 -0
- package/plugins/math/index.ts +140 -0
- package/plugins/meta.ts +42 -0
- package/plugins/navigation/index.ts +158 -0
- package/plugins/navigation/model.ts +364 -0
- package/plugins/preset.ts +65 -0
- package/plugins/query/index.ts +322 -0
- package/plugins/rss/index.ts +46 -0
- package/plugins/runtime/actions.ts +62 -0
- package/plugins/runtime/client.ts +8 -0
- package/plugins/runtime/document.ts +506 -0
- package/plugins/runtime/error.ts +154 -0
- package/plugins/runtime/heading-links.ts +31 -0
- package/plugins/runtime/index.ts +104 -0
- package/plugins/runtime/live.ts +51 -0
- package/plugins/runtime/navigation.ts +113 -0
- package/plugins/runtime/pending.ts +53 -0
- package/plugins/runtime/publication.ts +19 -0
- package/plugins/runtime/start.ts +177 -0
- package/plugins/runtime/static-client.ts +6 -0
- package/plugins/runtime/static-live.ts +188 -0
- package/plugins/runtime/view-transition.ts +72 -0
- package/plugins/runtime/viewport.ts +110 -0
- package/plugins/schema.ts +24 -0
- package/plugins/scripts.ts +140 -0
- package/plugins/source-preview/client.ts +256 -0
- package/plugins/source-preview/index.ts +132 -0
- package/plugins/source-preview/model.ts +11 -0
- package/plugins/source-preview/preview.css +107 -0
- package/plugins/styles.ts +145 -0
- package/plugins/theme/components.ts +10 -0
- package/plugins/theme/foundation.css +358 -0
- package/plugins/theme/index.ts +333 -0
- package/plugins/theme/prose.css +284 -0
- package/plugins/theme/scheme.js +63 -0
- package/plugins/theme/toggle.css +44 -0
- package/plugins/theme/view-transition.css +68 -0
- package/plugins/toc/client.ts +146 -0
- package/plugins/toc/index.ts +232 -0
- package/plugins/toc/theme.css +172 -0
- package/plugins/types.ts +538 -0
- package/problem.ts +41 -0
- package/project.ts +6 -0
- package/publish/build.ts +402 -0
- package/publish/concurrent.ts +32 -0
- package/publish/identity.ts +63 -0
- package/publish/index.ts +11 -0
- package/publish/location.ts +167 -0
- package/publish/model.ts +58 -0
- package/publish/plan.ts +300 -0
- package/publish/validate.ts +150 -0
- package/query/candidate-index.ts +101 -0
- package/query/dependency-index.ts +99 -0
- package/query/fields.ts +216 -0
- package/query/invalidation.ts +48 -0
- package/query/kernel.ts +776 -0
- package/query/model.ts +79 -0
- package/repository.ts +374 -0
- package/runtime-profile.ts +34 -0
- package/schema/model.ts +92 -0
- package/schema/registry.ts +642 -0
- package/schema/standard.ts +226 -0
- package/server/context.ts +94 -0
- package/server/css-imports.ts +66 -0
- package/server/http.ts +333 -0
- package/server/page.ts +21 -0
- package/server/protocols.ts +12 -0
- package/server/representations.ts +66 -0
- package/server/router.ts +131 -0
- package/server/routes.ts +202 -0
- package/server/serve.ts +5 -0
- package/server/watch.ts +120 -0
- package/source/blocks.ts +71 -0
- package/source/format.ts +164 -0
- package/source/index.ts +67 -0
- package/source/interpolate.ts +63 -0
- package/source/markdown/blocks.ts +46 -0
- package/source/markdown/comments.ts +32 -0
- package/source/markdown/fences.ts +67 -0
- package/source/markdown/headings.ts +88 -0
- package/source/markdown/index.ts +34 -0
- package/source/markdown/inline-code.ts +6 -0
- package/source/markdown/links.ts +326 -0
- package/source/markdown/lists.ts +223 -0
- package/source/markdown/prose.ts +74 -0
- package/source/markdown/scan.ts +79 -0
- package/source/markdown/text.ts +82 -0
- package/source/meta.ts +87 -0
- package/source/source.ts +121 -0
- package/source/topmatter.ts +57 -0
- package/source/utils.ts +21 -0
- package/source-diagnostic.ts +63 -0
- package/text-file.ts +26 -0
- package/util.ts +35 -0
- package/view/components.ts +499 -0
- package/view/model.ts +91 -0
- package/view/projection.ts +162 -0
- package/view/session.ts +146 -0
- 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
|
+
}
|
package/source/source.ts
ADDED
|
@@ -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
|
+
}
|