@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,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,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
|
+
}
|