@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,46 @@
1
+ import { isFenceClose, matchFenceOpen } from "./fences.ts"
2
+
3
+ function isBlank(line: string | undefined) {
4
+ return !line?.trim()
5
+ }
6
+
7
+ /**
8
+ * Split ordinary PathMX Markdown into blank-line-isolated Blocks. A thematic
9
+ * divider inside a fenced code example remains authored content, not a Block
10
+ * boundary.
11
+ */
12
+ export function splitMarkdownBlocks(markdown: string) {
13
+ const lines = markdown.split(/\r?\n/)
14
+ const blocks: string[] = []
15
+ let blockStart = 0
16
+ let marker: string | undefined
17
+
18
+ for (let index = 0; index < lines.length; index++) {
19
+ const line = lines[index] ?? ""
20
+
21
+ if (marker) {
22
+ if (isFenceClose(line, marker)) marker = undefined
23
+ continue
24
+ }
25
+
26
+ const fence = matchFenceOpen(line)
27
+ if (fence) {
28
+ marker = fence.marker
29
+ continue
30
+ }
31
+
32
+ if (
33
+ line.trim() !== "---" ||
34
+ !isBlank(lines[index - 1]) ||
35
+ !isBlank(lines[index + 1])
36
+ )
37
+ continue
38
+ const block = lines.slice(blockStart, index).join("\n").trim()
39
+ if (block) blocks.push(block)
40
+ blockStart = index + 1
41
+ }
42
+
43
+ const block = lines.slice(blockStart).join("\n").trim()
44
+ if (block) blocks.push(block)
45
+ return blocks
46
+ }
@@ -0,0 +1,32 @@
1
+ export type HtmlCommentState = Readonly<{
2
+ line: string
3
+ inComment: boolean
4
+ }>
5
+
6
+ /** Remove non-rendered HTML comments while preserving visible text around them. */
7
+ export function withoutHtmlComments(
8
+ line: string,
9
+ startsInComment: boolean,
10
+ ): HtmlCommentState {
11
+ let inComment = startsInComment
12
+ let visible = ""
13
+ let cursor = 0
14
+ while (cursor < line.length) {
15
+ if (inComment) {
16
+ const end = line.indexOf("-->", cursor)
17
+ if (end === -1) return { line: visible, inComment }
18
+ inComment = false
19
+ cursor = end + 3
20
+ continue
21
+ }
22
+ const start = line.indexOf("<!--", cursor)
23
+ if (start === -1) {
24
+ visible += line.slice(cursor)
25
+ break
26
+ }
27
+ visible += line.slice(cursor, start)
28
+ inComment = true
29
+ cursor = start + 4
30
+ }
31
+ return { line: visible, inComment }
32
+ }
@@ -0,0 +1,67 @@
1
+ export type MarkdownCodeFence = {
2
+ lang: string
3
+ info: string
4
+ body: string
5
+ }
6
+
7
+ export type FenceOpen = {
8
+ indent: string
9
+ marker: string
10
+ /** The trimmed info string after the fence marker, e.g. `js no-copy`. */
11
+ info: string
12
+ }
13
+
14
+ const FENCE_LINE = /^(\s*)([`~]{3,})(.*)$/
15
+
16
+ /** Match a fence opening line (``` / ~~~), or `undefined` for ordinary text. */
17
+ export function matchFenceOpen(line: string): FenceOpen | undefined {
18
+ const match = line.match(FENCE_LINE)
19
+ if (!match) return
20
+ return { indent: match[1]!, marker: match[2]!, info: match[3]!.trim() }
21
+ }
22
+
23
+ /** A closing fence uses the same character, is at least as long, and carries no info. */
24
+ export function isFenceClose(line: string, marker: string) {
25
+ const match = line.match(FENCE_LINE)
26
+ return Boolean(
27
+ match &&
28
+ match[2]![0] === marker[0] &&
29
+ match[2]!.length >= marker.length &&
30
+ !match[3]!.trim(),
31
+ )
32
+ }
33
+
34
+ /**
35
+ * Collect fenced code blocks (``` / ~~~) in source order. `lang` is the first
36
+ * info-string word; `body` is the interior text, untrimmed.
37
+ */
38
+ export function parseCodeFences(markdown: string): MarkdownCodeFence[] {
39
+ const fences: MarkdownCodeFence[] = []
40
+ const lines = markdown.split(/\r?\n/)
41
+ let open:
42
+ | { marker: string; lang: string; info: string; start: number }
43
+ | undefined
44
+
45
+ for (let index = 0; index < lines.length; index++) {
46
+ const line = lines[index] ?? ""
47
+
48
+ if (open) {
49
+ if (isFenceClose(line, open.marker)) {
50
+ fences.push({
51
+ lang: open.lang,
52
+ info: open.info,
53
+ body: lines.slice(open.start, index).join("\n"),
54
+ })
55
+ open = undefined
56
+ }
57
+ continue
58
+ }
59
+
60
+ const fence = matchFenceOpen(line)
61
+ if (!fence) continue
62
+ const lang = fence.info.split(/\s+/, 1)[0] ?? ""
63
+ open = { marker: fence.marker, lang, info: fence.info, start: index + 1 }
64
+ }
65
+
66
+ return fences
67
+ }
@@ -0,0 +1,88 @@
1
+ import { isFenceClose, matchFenceOpen } from "./fences.ts"
2
+
3
+ export type MarkdownHeading = {
4
+ depth: number
5
+ id: string
6
+ text: string
7
+ }
8
+
9
+ function plainHeadingText(markdown: string) {
10
+ return markdown
11
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
12
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
13
+ .replace(/`([^`]*)`/g, "$1")
14
+ .replace(/<[^>]*>/g, "")
15
+ .replace(/\\([\\`*_{}\[\]()#+.!-])/g, "$1")
16
+ .replace(/[\*_~]/g, "")
17
+ .trim()
18
+ }
19
+
20
+ /** Match `Bun.markdown.html(..., { headings: { ids: true } })` slugs. */
21
+ function slugify(text: string) {
22
+ return text
23
+ .toLowerCase()
24
+ .replace(/[^a-z0-9\s-]/g, "")
25
+ .trim()
26
+ .replace(/[\s-]+/g, "-")
27
+ }
28
+
29
+ function nextHeadingId(text: string, counts: Map<string, number>) {
30
+ const base = slugify(text)
31
+ const count = counts.get(base) ?? 0
32
+ counts.set(base, count + 1)
33
+ return count === 0 ? base : `${base}-${count}`
34
+ }
35
+
36
+ /** @internal Parse one already fence-filtered Markdown line. */
37
+ export function headingFromLine(
38
+ line: string,
39
+ counts: Map<string, number>,
40
+ ): MarkdownHeading | undefined {
41
+ const match = line.match(/^\s{0,3}(#{1,6})[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$/)
42
+ if (!match?.[1] || !match[2]) return
43
+ const text = plainHeadingText(match[2])
44
+ if (!text) return
45
+ return {
46
+ depth: match[1].length,
47
+ id: nextHeadingId(text, counts),
48
+ text,
49
+ }
50
+ }
51
+
52
+ /** Reassign heading ids so they are unique across a whole Source. */
53
+ export function assignHeadingIds(
54
+ headings: readonly MarkdownHeading[],
55
+ ): MarkdownHeading[] {
56
+ const counts = new Map<string, number>()
57
+ return headings.map((heading) => ({
58
+ ...heading,
59
+ id: nextHeadingId(heading.text, counts),
60
+ }))
61
+ }
62
+
63
+ /**
64
+ * Collect ATX headings while leaving examples inside fenced code untouched.
65
+ * Heading `id`s match `Bun.markdown.html(..., { headings: { ids: true } })`.
66
+ */
67
+ export function collectHeadings(markdown: string): MarkdownHeading[] {
68
+ const headings: MarkdownHeading[] = []
69
+ const counts = new Map<string, number>()
70
+ let marker: string | undefined
71
+
72
+ for (const line of markdown.split(/\r?\n/)) {
73
+ if (marker) {
74
+ if (isFenceClose(line, marker)) marker = undefined
75
+ continue
76
+ }
77
+ const fence = matchFenceOpen(line)
78
+ if (fence) {
79
+ marker = fence.marker
80
+ continue
81
+ }
82
+
83
+ const heading = headingFromLine(line, counts)
84
+ if (heading) headings.push(heading)
85
+ }
86
+
87
+ return headings
88
+ }
@@ -0,0 +1,34 @@
1
+ export {
2
+ isExternalHref,
3
+ parseLinkDirective,
4
+ parseLinks,
5
+ rewriteLinks,
6
+ unlinkLinks,
7
+ type LinkDirective,
8
+ type SourceLink,
9
+ type SourceLinkKind,
10
+ type SourceLinkType,
11
+ } from "./links.ts"
12
+ export { splitMarkdownBlocks } from "./blocks.ts"
13
+ export {
14
+ markdownListItemLink,
15
+ parseMarkdownLists,
16
+ type MarkdownList,
17
+ type MarkdownListItem,
18
+ type MarkdownListLink,
19
+ type MarkdownListParse,
20
+ type MarkdownTask,
21
+ } from "./lists.ts"
22
+ export {
23
+ assignHeadingIds,
24
+ collectHeadings,
25
+ type MarkdownHeading,
26
+ } from "./headings.ts"
27
+ export {
28
+ isFenceClose,
29
+ matchFenceOpen,
30
+ parseCodeFences,
31
+ type FenceOpen,
32
+ type MarkdownCodeFence,
33
+ } from "./fences.ts"
34
+ export { cleanInlineText, firstParagraph } from "./text.ts"
@@ -0,0 +1,6 @@
1
+ const INLINE_CODE = /(`+)([\s\S]*?)\1/g
2
+
3
+ /** @internal Preserve offsets while hiding inline code from syntax indexes. */
4
+ export function maskInlineCode(markdown: string) {
5
+ return markdown.replace(INLINE_CODE, (code) => " ".repeat(code.length))
6
+ }
@@ -0,0 +1,326 @@
1
+ import { maskInlineCode } from "./inline-code.ts"
2
+
3
+ export type SourceLinkType =
4
+ "source" | "asset" | "external" | "fragment" | "actor-relative" | "unresolved"
5
+
6
+ export type SourceLinkKind = "inline" | "definition" | "html" | "image"
7
+
8
+ export type LinkDirective = {
9
+ name: string
10
+ qualifiers: string[]
11
+ }
12
+
13
+ export type SourceLink = {
14
+ href: string
15
+ type: SourceLinkType
16
+ kind: SourceLinkKind
17
+ label?: string
18
+ directive?: LinkDirective
19
+ targetPath?: string
20
+ fragment?: string
21
+ }
22
+
23
+ // This is deliberately source-local syntax indexing, not Markdown AST parsing
24
+ // or PathMX graph resolution. Classification stays unresolved until
25
+ // Graph.rebuild().
26
+ const INTERPOLATION_TOKEN = String.raw`\{\{\s*[A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*\s*\}\}`
27
+ const INLINE_DESTINATION = String.raw`(?:(?:${INTERPOLATION_TOKEN})|[^\s)])+`
28
+ const INLINE_LINK = new RegExp(
29
+ String.raw`(!?)\[([^\]]*)\]\(\s*(?:<([^>\r\n]+)>|(${INLINE_DESTINATION}))`,
30
+ "g",
31
+ )
32
+ const HTML_ANCHOR = /<a\b[^>]*\bhref\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/gi
33
+ const DEFINITION =
34
+ /^[ \t]{0,3}\[([^\]]+)\]:[ \t]+(?:<([^>\r\n]+)>|(\S+))(?:[ \t]+(?:"([^"]*)"|'([^']*)'|\(([^)]*)\)))?[ \t]*$/gm
35
+ const DIRECTIVE = /^@([A-Za-z][\w-]*)((?:\.[A-Za-z][\w-]*)*)(?:\s+([\s\S]*))?$/
36
+
37
+ const EXTERNAL_HREF = /^[a-z][a-z\d+.-]*:/i
38
+
39
+ /** Protocol or protocol-relative destinations leave the current PathMX site. */
40
+ export function isExternalHref(href: string) {
41
+ const normalized = href.trim()
42
+ return EXTERNAL_HREF.test(normalized) || normalized.startsWith("//")
43
+ }
44
+
45
+ /** @internal Structural relationships target exact authored Markdown files. */
46
+ export function isExactMarkdownSourceHref(value: string) {
47
+ const href = value.trim()
48
+ return Boolean(
49
+ href &&
50
+ href.endsWith(".md") &&
51
+ !href.includes("?") &&
52
+ !href.includes("#") &&
53
+ !href.includes("\\") &&
54
+ !href.startsWith("//") &&
55
+ !href.startsWith("~/") &&
56
+ !/^[a-z][a-z\d+.-]*:/i.test(href),
57
+ )
58
+ }
59
+
60
+ /** @internal Split one authored link label into its directive and visible text. */
61
+ export function parseDirectiveLabel(raw: string | undefined): {
62
+ label?: string
63
+ directive?: LinkDirective
64
+ } {
65
+ const text = raw?.trim()
66
+ if (!text) return {}
67
+ const match = text.match(DIRECTIVE)
68
+ if (!match) return { label: text }
69
+ const qualifiers = match[2]
70
+ ? match[2].slice(1).split(".").filter(Boolean)
71
+ : []
72
+ const rest = match[3]?.trim()
73
+ return {
74
+ directive: { name: match[1]!, qualifiers },
75
+ ...(rest ? { label: rest } : {}),
76
+ }
77
+ }
78
+
79
+ function addLink(
80
+ links: SourceLink[],
81
+ seen: Set<string>,
82
+ href: string | undefined,
83
+ kind: SourceLinkKind,
84
+ rawLabel?: string,
85
+ ) {
86
+ const dest = href?.trim()
87
+ if (!dest) return
88
+ if (kind !== "definition") {
89
+ const key = `${kind}:${dest}`
90
+ if (seen.has(key)) return
91
+ seen.add(key)
92
+ }
93
+ const { label, directive } = parseDirectiveLabel(rawLabel)
94
+ links.push({
95
+ href: dest,
96
+ type: "unresolved",
97
+ kind,
98
+ ...(label ? { label } : {}),
99
+ ...(directive ? { directive } : {}),
100
+ })
101
+ }
102
+
103
+ /** @internal Extract links from ordered, already fence-filtered fragments. */
104
+ export function parseLinkFragments(fragments: readonly string[]): SourceLink[] {
105
+ const links: SourceLink[] = []
106
+ const seen = new Set<string>()
107
+ for (const fragment of fragments) {
108
+ const markdown = maskInlineCode(fragment)
109
+ const found: Array<{
110
+ index: number
111
+ href?: string
112
+ kind: SourceLinkKind
113
+ label?: string
114
+ }> = []
115
+
116
+ for (const match of markdown.matchAll(DEFINITION)) {
117
+ found.push({
118
+ index: match.index ?? 0,
119
+ href: match[2] ?? match[3],
120
+ kind: "definition",
121
+ label: match[1],
122
+ })
123
+ }
124
+ for (const match of markdown.matchAll(INLINE_LINK)) {
125
+ found.push({
126
+ index: match.index ?? 0,
127
+ href: match[3] ?? match[4],
128
+ kind: match[1] === "!" ? "image" : "inline",
129
+ label: match[2],
130
+ })
131
+ }
132
+ for (const match of markdown.matchAll(HTML_ANCHOR)) {
133
+ found.push({
134
+ index: match.index ?? 0,
135
+ href: match[1] ?? match[2] ?? match[3],
136
+ kind: "html",
137
+ })
138
+ }
139
+
140
+ found.sort((a, b) => a.index - b.index)
141
+ for (const item of found) {
142
+ addLink(links, seen, item.href, item.kind, item.label)
143
+ }
144
+ }
145
+ return links
146
+ }
147
+
148
+ /**
149
+ * Return ordered link destinations from Markdown. Inline and HTML hrefs are
150
+ * de-duplicated; each reference definition is kept for authored directives.
151
+ */
152
+ export function parseVisibleLinks(markdown: string): SourceLink[] {
153
+ return parseLinkFragments([markdown])
154
+ }
155
+
156
+ export function parseLinks(markdown: string): SourceLink[] {
157
+ return parseVisibleLinks(markdown)
158
+ }
159
+
160
+ /** `@include status` → `{ name: "include", qualifiers: [] }`. Caption is not returned. */
161
+ export function parseLinkDirective(label: string): LinkDirective | undefined {
162
+ return parseDirectiveLabel(label).directive
163
+ }
164
+
165
+ function scan(re: RegExp, content: string) {
166
+ return content.matchAll(new RegExp(re.source, re.flags))
167
+ }
168
+
169
+ function hrefSpan(
170
+ match: RegExpMatchArray,
171
+ href: string,
172
+ after: string,
173
+ ): { start: number; end: number } | undefined {
174
+ if (match.index == null) return
175
+ const token = after === "<" ? `<${href}` : href
176
+ const offset = match[0].indexOf(token, match[0].lastIndexOf(after))
177
+ if (offset === -1) return
178
+ const start = match.index + offset + (after === "<" ? 1 : 0)
179
+ return { start, end: start + href.length }
180
+ }
181
+
182
+ function inlineHrefSpan(match: RegExpMatchArray) {
183
+ const href = match[3] ?? match[4]
184
+ if (!href) return
185
+ return hrefSpan(match, href, match[3] != null ? "<" : "(")
186
+ }
187
+
188
+ function htmlHrefSpan(match: RegExpMatchArray) {
189
+ const href = match[1] ?? match[2] ?? match[3]
190
+ if (!href || match.index == null) return
191
+ const eq = match[0].search(/href\s*=/i)
192
+ if (eq === -1) return
193
+ let i = match[0].indexOf("=", eq) + 1
194
+ while (i < match[0].length && /\s/.test(match[0][i]!)) i++
195
+ if (match[0][i] === '"' || match[0][i] === "'") i++
196
+ return { start: match.index + i, end: match.index + i + href.length }
197
+ }
198
+
199
+ function definitionHrefSpan(match: RegExpMatchArray) {
200
+ const href = match[2] ?? match[3]
201
+ if (!href) return
202
+ return hrefSpan(match, href, match[2] != null ? "<" : "]:")
203
+ }
204
+
205
+ function applyReplacements(
206
+ content: string,
207
+ replacements: Array<{ start: number; end: number; text: string }>,
208
+ ) {
209
+ replacements.sort((a, b) => b.start - a.start)
210
+ let result = content
211
+ for (const { start, end, text } of replacements) {
212
+ result = `${result.slice(0, start)}${text}${result.slice(end)}`
213
+ }
214
+ return result
215
+ }
216
+
217
+ function closingParenEnd(content: string, afterHref: number) {
218
+ let i = afterHref
219
+ while (i < content.length && (content[i] === " " || content[i] === "\t")) i++
220
+ const quote = content[i]
221
+ if (quote === '"' || quote === "'") {
222
+ i++
223
+ while (i < content.length && content[i] !== quote) i++
224
+ if (content[i] === quote) i++
225
+ } else if (content[i] === "(") {
226
+ i++
227
+ while (i < content.length && content[i] !== ")") i++
228
+ if (content[i] === ")") i++
229
+ }
230
+ while (i < content.length && (content[i] === " " || content[i] === "\t")) i++
231
+ if (content[i] === ")") i++
232
+ return i
233
+ }
234
+
235
+ function htmlElementSpan(content: string, match: RegExpMatchArray) {
236
+ if (match.index == null) return
237
+ const gt = content.indexOf(">", match.index)
238
+ if (gt === -1) return
239
+ if (content[gt - 1] === "/") {
240
+ return { start: match.index, end: gt + 1, inner: "" }
241
+ }
242
+ const close = content.toLowerCase().indexOf("</a>", gt + 1)
243
+ if (close === -1) return
244
+ return {
245
+ start: match.index,
246
+ end: close + 4,
247
+ inner: content.slice(gt + 1, close),
248
+ }
249
+ }
250
+
251
+ function definitionLineSpan(content: string, match: RegExpMatchArray) {
252
+ if (match.index == null) return
253
+ let end = match.index + match[0].length
254
+ if (content[end] === "\r") end++
255
+ if (content[end] === "\n") end++
256
+ return { start: match.index, end }
257
+ }
258
+
259
+ /**
260
+ * Rewrite every markdown/HTML/definition href whose mapper returns a new value.
261
+ * Uses the same regexes as `parseLinks` so the rewriter sees the same links.
262
+ */
263
+ export function rewriteLinks(
264
+ content: string,
265
+ mapHref: (href: string) => string | undefined,
266
+ ): string {
267
+ const replacements: Array<{ start: number; end: number; text: string }> = []
268
+
269
+ const replace = (
270
+ href: string | undefined,
271
+ span: { start: number; end: number } | undefined,
272
+ ) => {
273
+ if (!href || !span) return
274
+ const next = mapHref(href)
275
+ if (next == null || next === href) return
276
+ replacements.push({ ...span, text: next })
277
+ }
278
+
279
+ for (const match of scan(INLINE_LINK, content)) {
280
+ replace(match[3] ?? match[4], inlineHrefSpan(match))
281
+ }
282
+ for (const match of scan(HTML_ANCHOR, content)) {
283
+ replace(match[1] ?? match[2] ?? match[3], htmlHrefSpan(match))
284
+ }
285
+ for (const match of scan(DEFINITION, content)) {
286
+ replace(match[2] ?? match[3], definitionHrefSpan(match))
287
+ }
288
+
289
+ return applyReplacements(content, replacements)
290
+ }
291
+
292
+ /**
293
+ * Remove links whose href matches. Inline/image links collapse to their label,
294
+ * HTML anchors collapse to inner text, and reference-definition lines are deleted.
295
+ */
296
+ export function unlinkLinks(
297
+ content: string,
298
+ shouldUnlink: (href: string) => boolean,
299
+ ): string {
300
+ const replacements: Array<{ start: number; end: number; text: string }> = []
301
+
302
+ for (const match of scan(INLINE_LINK, content)) {
303
+ const href = match[3] ?? match[4]
304
+ const span = inlineHrefSpan(match)
305
+ if (!href || !span || match.index == null || !shouldUnlink(href)) continue
306
+ replacements.push({
307
+ start: match.index,
308
+ end: closingParenEnd(content, span.end),
309
+ text: match[2] ?? "",
310
+ })
311
+ }
312
+ for (const match of scan(HTML_ANCHOR, content)) {
313
+ const href = match[1] ?? match[2] ?? match[3]
314
+ const span = htmlElementSpan(content, match)
315
+ if (!href || !span || !shouldUnlink(href)) continue
316
+ replacements.push({ ...span, text: span.inner })
317
+ }
318
+ for (const match of scan(DEFINITION, content)) {
319
+ const href = match[2] ?? match[3]
320
+ const span = definitionLineSpan(content, match)
321
+ if (!href || !span || !shouldUnlink(href)) continue
322
+ replacements.push({ ...span, text: "" })
323
+ }
324
+
325
+ return applyReplacements(content, replacements)
326
+ }