@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,140 @@
1
+ import temml from "temml"
2
+ import temmlCss from "temml/dist/Temml-Local.css" with { type: "text" }
3
+ import { isFenceClose, matchFenceOpen } from "../../source/index.ts"
4
+ import { escapeHtml } from "../../util.ts"
5
+ import type { AppContext, CompileContext, Plugin } from "../types.ts"
6
+
7
+ type MathEntry = {
8
+ tex: string
9
+ display: boolean
10
+ }
11
+
12
+ const DISPLAY_MATH = /\$\$([\s\S]+?)\$\$/g
13
+ const INLINE_MATH = /\$(?!\s)([^$\n]+?)(?<!\s)\$(?!\d)/g
14
+ const PLACEHOLDER = /<(div|span) data-pathmx-math="(\d+)"><\/(?:div|span)>/g
15
+ const INLINE_CODE = /`[^`\n]+`/g
16
+ const FONT_FACE = /\/\*[\s\S]*?\*\/\s*@font-face\s*\{[^}]*\}/g
17
+
18
+ const stylesheet = temmlCss as unknown as string
19
+
20
+ function maskInlineCode(text: string) {
21
+ const slots: string[] = []
22
+ const masked = text.replace(INLINE_CODE, (match) => {
23
+ const index = slots.length
24
+ slots.push(match)
25
+ return `\0${index}\0`
26
+ })
27
+ return { masked, slots }
28
+ }
29
+
30
+ function unmaskInlineCode(text: string, slots: string[]) {
31
+ return text.replace(/\0(\d+)\0/g, (_match, index: string) => {
32
+ return slots[Number(index)] ?? ""
33
+ })
34
+ }
35
+
36
+ function extractMath(text: string, stash: MathEntry[]) {
37
+ const { masked, slots } = maskInlineCode(text)
38
+ let result = masked.replace(DISPLAY_MATH, (_match, tex: string) => {
39
+ const index = stash.length
40
+ stash.push({ tex: tex.trim(), display: true })
41
+ return `\n\n<div data-pathmx-math="${index}"></div>\n\n`
42
+ })
43
+ result = result.replace(INLINE_MATH, (_match, tex: string) => {
44
+ const index = stash.length
45
+ stash.push({ tex: tex.trim(), display: false })
46
+ return `<span data-pathmx-math="${index}"></span>`
47
+ })
48
+ return unmaskInlineCode(result, slots)
49
+ }
50
+
51
+ function stashMath(markdown: string) {
52
+ const stash: MathEntry[] = []
53
+ const lines = markdown.split(/\r?\n/)
54
+ const out: string[] = []
55
+ const unfenced: string[] = []
56
+ let marker: string | undefined
57
+
58
+ const flush = () => {
59
+ if (!unfenced.length) return
60
+ out.push(extractMath(unfenced.join("\n"), stash))
61
+ unfenced.length = 0
62
+ }
63
+
64
+ for (const line of lines) {
65
+ if (marker) {
66
+ out.push(line)
67
+ if (isFenceClose(line, marker)) marker = undefined
68
+ continue
69
+ }
70
+ const fence = matchFenceOpen(line)
71
+ if (fence) {
72
+ flush()
73
+ out.push(line)
74
+ marker = fence.marker
75
+ continue
76
+ }
77
+ unfenced.push(line)
78
+ }
79
+ flush()
80
+
81
+ return { markdown: out.join("\n"), stash }
82
+ }
83
+
84
+ function renderEntry(entry: MathEntry, app: AppContext) {
85
+ try {
86
+ const math = temml.renderToString(entry.tex, {
87
+ displayMode: entry.display,
88
+ throwOnError: false,
89
+ })
90
+ const kind = entry.display ? "display" : "inline"
91
+ const tag = entry.display ? "div" : "span"
92
+ return `<${tag} data-pathmx-math="${kind}">${math}</${tag}>`
93
+ } catch (error) {
94
+ app.log.warn("render", {
95
+ tex: entry.tex,
96
+ error: error instanceof Error ? error.message : String(error),
97
+ })
98
+ const raw = escapeHtml(entry.tex)
99
+ return entry.display
100
+ ? `<pre data-pathmx-math="error"><code>$$${raw}$$</code></pre>`
101
+ : `<code data-pathmx-math="error">$${raw}$</code>`
102
+ }
103
+ }
104
+
105
+ export function MathPlugin(): Plugin {
106
+ const stashByContext = new WeakMap<CompileContext, MathEntry[]>()
107
+
108
+ return {
109
+ id: "math",
110
+ precompile(markdown, ctx) {
111
+ if (!markdown.includes("$")) {
112
+ stashByContext.set(ctx, [])
113
+ return markdown
114
+ }
115
+ const parsed = stashMath(markdown)
116
+ stashByContext.set(ctx, parsed.stash)
117
+ return parsed.markdown
118
+ },
119
+
120
+ postcompile(html, ctx, app) {
121
+ if (!html.includes("data-pathmx-math")) return html
122
+ const stash = stashByContext.get(ctx) ?? []
123
+ return html.replace(PLACEHOLDER, (match, _tag: string, index: string) => {
124
+ const entry = stash[Number(index)]
125
+ if (!entry) return match
126
+ return renderEntry(entry, app)
127
+ })
128
+ },
129
+
130
+ page(html, ctx) {
131
+ if (!html.includes("data-pathmx-math")) return html
132
+ ctx.addAsset({
133
+ type: "stylesheet",
134
+ name: "math.css",
135
+ text: stylesheet.replace(FONT_FACE, "").trim(),
136
+ })
137
+ return html
138
+ },
139
+ }
140
+ }
@@ -0,0 +1,42 @@
1
+ import { deriveSourceMeta } from "../source/index.ts"
2
+ import type { DocumentAsset, Plugin } from "./types.ts"
3
+
4
+ function meta(attrs: Record<string, string>): DocumentAsset {
5
+ return { type: "meta", attrs }
6
+ }
7
+
8
+ /**
9
+ * Head metadata: document boilerplate (charset, viewport) plus per-page
10
+ * title, description, and Open Graph tags from the source's derived meta.
11
+ */
12
+ export function MetaPlugin(): Plugin {
13
+ return {
14
+ id: "meta",
15
+ documentAssets(ctx) {
16
+ ctx.addAsset(
17
+ meta({ charset: "utf-8" }),
18
+ meta({
19
+ name: "viewport",
20
+ content: "width=device-width, initial-scale=1",
21
+ }),
22
+ )
23
+ },
24
+
25
+ page(html, ctx, app) {
26
+ if (!app.sources.isPage(ctx.source)) return html
27
+ const { title, description, image } = deriveSourceMeta(ctx.source)
28
+ ctx.addAsset(
29
+ { type: "title", text: title },
30
+ meta({ property: "og:title", content: title }),
31
+ )
32
+ if (description) {
33
+ ctx.addAsset(
34
+ meta({ name: "description", content: description }),
35
+ meta({ property: "og:description", content: description }),
36
+ )
37
+ }
38
+ if (image) ctx.addAsset(meta({ property: "og:image", content: image }))
39
+ return html
40
+ },
41
+ }
42
+ }
@@ -0,0 +1,158 @@
1
+ import { html, type Html } from "../../html.ts"
2
+ import type { StandardSchemaV1 } from "../../schema/standard.ts"
3
+ import {
4
+ NavigationModel,
5
+ type CompiledNavigation,
6
+ type VisibleNavigationNode,
7
+ } from "./model.ts"
8
+ import {
9
+ defineViewComponent,
10
+ type AppContext,
11
+ type PageContext,
12
+ type Plugin,
13
+ type ViewComponentUse,
14
+ type ViewContext,
15
+ } from "../types.ts"
16
+
17
+ function renderNavNode(node: VisibleNavigationNode): Html {
18
+ const active = node.active ? html` data-pmx-active="${node.active}"` : null
19
+ const current = node.active === "page" ? html` aria-current="page"` : null
20
+ const children = node.children.length
21
+ ? html`<ul class="pmx-nav-list">
22
+ ${node.children.map(renderNavNode)}
23
+ </ul>`
24
+ : null
25
+ // prettier-ignore
26
+ const content =
27
+ node.type === "resolved"
28
+ ? html`<a class="pmx-nav-link" href="${node.route}"${active}${current}>${node.label}</a>`
29
+ : html`<span class="pmx-nav-link" data-pmx-unresolved="true" aria-disabled="true" title="Unresolved: ${node.href}">${node.label}</span>`
30
+ return html`<li class="pmx-nav-item" ${active}>${content}${children}</li>`
31
+ }
32
+
33
+ function activeTrail(
34
+ nodes: readonly VisibleNavigationNode[],
35
+ ): readonly VisibleNavigationNode[] {
36
+ for (const node of nodes) {
37
+ if (!node.active) continue
38
+ const childTrail = activeTrail(node.children)
39
+ return childTrail.length ? [node, ...childTrail] : [node]
40
+ }
41
+ return []
42
+ }
43
+
44
+ function renderBreadcrumbNode(node: VisibleNavigationNode): Html {
45
+ const active = node.active ? html` data-pmx-active="${node.active}"` : null
46
+ // prettier-ignore
47
+ const content =
48
+ node.active === "page"
49
+ ? html`<span class="pmx-breadcrumbs-current" aria-current="page">${node.label}</span>`
50
+ : node.type === "resolved"
51
+ ? html`<a class="pmx-breadcrumbs-link" href="${node.route}">${node.label}</a>`
52
+ : html`<span class="pmx-breadcrumbs-link" data-pmx-unresolved="true" aria-disabled="true" title="Unresolved: ${node.href}">${node.label}</span>`
53
+ return html`<li class="pmx-breadcrumbs-item" ${active}>${content}</li>`
54
+ }
55
+
56
+ function renderNavigation(
57
+ state: CompiledNavigation,
58
+ view: ViewContext,
59
+ model: NavigationModel,
60
+ ) {
61
+ const nodes = model.visible(state, view)
62
+ return html`<nav
63
+ class="pmx-nav"
64
+ data-pmx-nav="${state.name}"
65
+ aria-label="${state.label}"
66
+ >
67
+ <ul class="pmx-nav-list">
68
+ ${nodes.map(renderNavNode)}
69
+ </ul>
70
+ </nav>`
71
+ }
72
+
73
+ function renderBreadcrumbs(
74
+ state: CompiledNavigation,
75
+ view: ViewContext,
76
+ model: NavigationModel,
77
+ ) {
78
+ const trail = activeTrail(model.visible(state, view))
79
+ if (!trail.length) return ""
80
+ return html`<nav
81
+ class="pmx-breadcrumbs"
82
+ data-pmx-breadcrumbs="${state.name}"
83
+ aria-label="Breadcrumb"
84
+ >
85
+ <ol class="pmx-breadcrumbs-list">
86
+ ${trail.map(renderBreadcrumbNode)}
87
+ </ol>
88
+ </nav>`
89
+ }
90
+
91
+ const navAttrsSchema: StandardSchemaV1 = {
92
+ "~standard": {
93
+ version: 1,
94
+ vendor: "pathmx",
95
+ validate(value) {
96
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
97
+ return { issues: [{ message: `expected one valid name attribute` }] }
98
+ }
99
+ const attrs = value as Record<string, unknown>
100
+ const keys = Object.keys(attrs)
101
+ if (
102
+ keys.length === 1 &&
103
+ keys[0] === "name" &&
104
+ typeof attrs.name === "string" &&
105
+ /^[A-Za-z][\w-]*$/.test(attrs.name)
106
+ ) {
107
+ return { value }
108
+ }
109
+ return { issues: [{ message: `expected one valid name attribute` }] }
110
+ },
111
+ },
112
+ }
113
+
114
+ /** Authored navigation and breadcrumbs projected from one viewer-aware model. */
115
+ export function NavigationPlugin(): Plugin {
116
+ const model = new NavigationModel()
117
+ const compile = (use: ViewComponentUse, ctx: PageContext, app: AppContext) =>
118
+ model.compile(use.props.name!, use, ctx, app)
119
+
120
+ const NavigationComponent = defineViewComponent<CompiledNavigation>({
121
+ tag: "navigation:nav",
122
+ props: navAttrsSchema,
123
+ compile,
124
+ render(_use, compiled, view) {
125
+ return renderNavigation(compiled, view, model)
126
+ },
127
+ })
128
+
129
+ const BreadcrumbsComponent = defineViewComponent<CompiledNavigation>({
130
+ tag: "navigation:breadcrumbs",
131
+ props: navAttrsSchema,
132
+ compile,
133
+ render(_use, compiled, view) {
134
+ return renderBreadcrumbs(compiled, view, model)
135
+ },
136
+ })
137
+
138
+ return {
139
+ id: "navigation",
140
+ schema: {
141
+ sources: {
142
+ navigation: {
143
+ label: "Navigation",
144
+ description: "An authored navigation tree.",
145
+ icon: "network",
146
+ color: "cyan",
147
+ },
148
+ },
149
+ },
150
+ components: [NavigationComponent, BreadcrumbsComponent],
151
+ index(source) {
152
+ model.index(source)
153
+ },
154
+ unindex(sourcePath) {
155
+ model.unindex(sourcePath)
156
+ },
157
+ }
158
+ }
@@ -0,0 +1,364 @@
1
+ import {
2
+ isIndexSource,
3
+ publicPath,
4
+ sourceDirectory,
5
+ } from "../../server/router.ts"
6
+ import type { Source, SourceLink } from "../../source/index.ts"
7
+ import {
8
+ markdownListItemLink,
9
+ parseMarkdownLists,
10
+ type MarkdownList,
11
+ } from "../../source/index.ts"
12
+ import { isExactMarkdownSourceHref } from "../../source/markdown/links.ts"
13
+ import { cleanInlineText } from "../../source/markdown/text.ts"
14
+ import { decodeHtmlEntities } from "../../util.ts"
15
+ import { SourceDiagnosticError } from "../../source-diagnostic.ts"
16
+ import type {
17
+ AppContext,
18
+ PageContext,
19
+ ViewComponentUse,
20
+ ViewContext,
21
+ } from "../types.ts"
22
+
23
+ const NAVIGATION_TYPE = "navigation"
24
+
25
+ type AuthoredNavigationNode = Readonly<{
26
+ label: string
27
+ href: string
28
+ children: readonly AuthoredNavigationNode[]
29
+ }>
30
+
31
+ export type CompiledNavigationNode = Readonly<{
32
+ type: "resolved" | "unresolved"
33
+ label: string
34
+ href: string
35
+ targetId?: string
36
+ targetPath?: string
37
+ route?: string
38
+ sectionDir?: string
39
+ children: readonly CompiledNavigationNode[]
40
+ }>
41
+
42
+ export type CompiledNavigation = Readonly<{
43
+ name: string
44
+ label: string
45
+ nodes: readonly CompiledNavigationNode[]
46
+ }>
47
+
48
+ export type VisibleNavigationNode = CompiledNavigationNode &
49
+ Readonly<{
50
+ active?: "page" | "section" | "ancestor"
51
+ children: readonly VisibleNavigationNode[]
52
+ }>
53
+
54
+ type DraftNavigationNode = {
55
+ label: string
56
+ href: string
57
+ children: DraftNavigationNode[]
58
+ }
59
+
60
+ function navigationError(source: Source, message: string): never {
61
+ throw new SourceDiagnosticError({
62
+ source: source.path,
63
+ plugin: "navigation",
64
+ message: `Invalid navigation in ${source.path}: ${message}`,
65
+ })
66
+ }
67
+
68
+ function exactMarkdownTarget(source: Source, href: string) {
69
+ const target = href.trim()
70
+ if (!isExactMarkdownSourceHref(target)) {
71
+ navigationError(
72
+ source,
73
+ `links must target exact Markdown Sources, received ${target}.`,
74
+ )
75
+ }
76
+ return target
77
+ }
78
+
79
+ function navigationTarget(source: Source, href: string) {
80
+ const target = href.trim()
81
+ if (
82
+ target.startsWith("~/") &&
83
+ target.endsWith(".md") &&
84
+ !target.includes("?") &&
85
+ !target.includes("#") &&
86
+ !target.includes("\\")
87
+ ) {
88
+ return target
89
+ }
90
+ return exactMarkdownTarget(source, target)
91
+ }
92
+
93
+ function navigationNodes(
94
+ source: Source,
95
+ lists: readonly MarkdownList[],
96
+ ): DraftNavigationNode[] {
97
+ const nodes: DraftNavigationNode[] = []
98
+ for (const list of lists) {
99
+ if (list.type !== "unordered") {
100
+ navigationError(
101
+ source,
102
+ `line ${list.items[0]?.line ?? 1} must use a Markdown bullet.`,
103
+ )
104
+ }
105
+ for (const item of list.items) {
106
+ if (item.children.length > 1) {
107
+ navigationError(
108
+ source,
109
+ `line ${item.children[1]?.items[0]?.line ?? item.line} has inconsistent indentation.`,
110
+ )
111
+ }
112
+ const link = markdownListItemLink(item)
113
+ if (!link) {
114
+ navigationError(
115
+ source,
116
+ `line ${item.line} must be a Markdown bullet containing one link.`,
117
+ )
118
+ }
119
+ const label = decodeHtmlEntities(cleanInlineText(link.label))
120
+ if (!label)
121
+ navigationError(source, `line ${item.line} has an empty label.`)
122
+ nodes.push({
123
+ label,
124
+ href: navigationTarget(source, link.href),
125
+ children: navigationNodes(source, item.children),
126
+ })
127
+ }
128
+ }
129
+ return nodes
130
+ }
131
+
132
+ function parseNavigation(source: Source): readonly AuthoredNavigationNode[] {
133
+ const parsed = parseMarkdownLists(source.body)
134
+ const invalid = parsed.other[0]
135
+ if (invalid) {
136
+ navigationError(
137
+ source,
138
+ invalid.text.includes("\t")
139
+ ? `line ${invalid.line} uses a tab for indentation.`
140
+ : `line ${invalid.line} must be a Markdown bullet containing one link.`,
141
+ )
142
+ }
143
+ const nodes = navigationNodes(source, parsed.lists)
144
+ if (!nodes.length) navigationError(source, `expected at least one link.`)
145
+ return nodes
146
+ }
147
+
148
+ function sourceLinks(source: Source) {
149
+ return source.blocks.length
150
+ ? source.blocks.flatMap((block) => block.links)
151
+ : source.links
152
+ }
153
+
154
+ function indexRelations(source: Source) {
155
+ if (!source.body.includes("@nav")) return
156
+ const relations = new Map<string, SourceLink>()
157
+ for (const link of sourceLinks(source)) {
158
+ if (link.directive?.name !== "nav") continue
159
+ if (link.kind !== "definition") {
160
+ navigationError(source, `@nav must use a reference definition.`)
161
+ }
162
+ const [name, ...extra] = link.directive.qualifiers
163
+ if (!name || extra.length) {
164
+ navigationError(source, `@nav requires exactly one name qualifier.`)
165
+ }
166
+ if (relations.has(name)) {
167
+ navigationError(source, `multiple @nav.${name} definitions.`)
168
+ }
169
+ exactMarkdownTarget(source, link.href)
170
+ relations.set(name, link)
171
+ }
172
+ return relations
173
+ }
174
+
175
+ function navigationEdge(source: Source, href: string, app: AppContext) {
176
+ return app.graph
177
+ .linksFrom(source.path)
178
+ .find((candidate) => candidate.href === href)
179
+ }
180
+
181
+ function edgeTarget(source: Source, href: string, app: AppContext) {
182
+ const edge = navigationEdge(source, href, app)
183
+ return edge?.to ? app.sources.get(edge.to) : undefined
184
+ }
185
+
186
+ function unresolvedNode(
187
+ source: Source,
188
+ node: AuthoredNavigationNode,
189
+ children: readonly CompiledNavigationNode[],
190
+ app: AppContext,
191
+ reason: string,
192
+ ): CompiledNavigationNode {
193
+ const edge = navigationEdge(source, node.href, app)
194
+ app.log.warn("link.unresolved", {
195
+ source: source.path,
196
+ href: node.href,
197
+ label: node.label,
198
+ reason,
199
+ })
200
+ return {
201
+ type: "unresolved",
202
+ label: node.label,
203
+ href: node.href,
204
+ ...(edge?.to ? { targetPath: edge.to } : {}),
205
+ children,
206
+ }
207
+ }
208
+
209
+ function compileNodes(
210
+ source: Source,
211
+ nodes: readonly AuthoredNavigationNode[],
212
+ app: AppContext,
213
+ ): CompiledNavigationNode[] {
214
+ return nodes.map((node) => {
215
+ const children = compileNodes(source, node.children, app)
216
+ const target = edgeTarget(source, node.href, app)
217
+ if (!target) {
218
+ return unresolvedNode(source, node, children, app, "Source not found.")
219
+ }
220
+ if (!app.sources.isPage(target)) {
221
+ return unresolvedNode(
222
+ source,
223
+ node,
224
+ children,
225
+ app,
226
+ `Target is a ${target.type} Source, not a page.`,
227
+ )
228
+ }
229
+ const dir = isIndexSource(target) ? sourceDirectory(target) : undefined
230
+ return {
231
+ type: "resolved",
232
+ label: node.label,
233
+ href: node.href,
234
+ targetId: target.id,
235
+ targetPath: target.path,
236
+ route: publicPath(target),
237
+ ...(dir && dir !== "/" ? { sectionDir: dir } : {}),
238
+ children,
239
+ }
240
+ })
241
+ }
242
+
243
+ function recordTargetDependencies(
244
+ nodes: readonly CompiledNavigationNode[],
245
+ depend: PageContext["depend"],
246
+ ) {
247
+ for (const node of nodes) {
248
+ if (node.targetPath) depend({ type: "source", path: node.targetPath })
249
+ recordTargetDependencies(node.children, depend)
250
+ }
251
+ }
252
+
253
+ function activeState(
254
+ node: CompiledNavigationNode,
255
+ children: readonly VisibleNavigationNode[],
256
+ current: Source,
257
+ ): VisibleNavigationNode["active"] {
258
+ if (node.type === "unresolved") return
259
+ if (node.targetId === current.id) return "page"
260
+ if (node.sectionDir && current.path.startsWith(`${node.sectionDir}/`)) {
261
+ return "section"
262
+ }
263
+ if (children.some((child) => child.active)) return "ancestor"
264
+ }
265
+
266
+ function visibleNodes(
267
+ nodes: readonly CompiledNavigationNode[],
268
+ view: ViewContext,
269
+ ): VisibleNavigationNode[] {
270
+ return nodes.flatMap((node) => {
271
+ const children = visibleNodes(node.children, view)
272
+ if (node.type === "resolved" && !view.view.readSource(node.targetId!)) {
273
+ return children
274
+ }
275
+ const active = activeState(node, children, view.source)
276
+ return [{ ...node, children, ...(active ? { active } : {}) }]
277
+ })
278
+ }
279
+
280
+ /** Shared private index used by navigation and breadcrumb renderers. */
281
+ export class NavigationModel {
282
+ private navigations = new Map<string, readonly AuthoredNavigationNode[]>()
283
+ private relations = new Map<string, ReadonlyMap<string, SourceLink>>()
284
+ private compiledNodes = new Map<string, readonly CompiledNavigationNode[]>()
285
+ private visibleViews = new WeakMap<
286
+ ViewContext,
287
+ Map<readonly CompiledNavigationNode[], readonly VisibleNavigationNode[]>
288
+ >()
289
+
290
+ index(source: Source) {
291
+ const indexedRelations = indexRelations(source)
292
+ if (indexedRelations) this.relations.set(source.path, indexedRelations)
293
+ else this.relations.delete(source.path)
294
+ if (source.type === NAVIGATION_TYPE) {
295
+ this.navigations.set(source.path, parseNavigation(source))
296
+ }
297
+ }
298
+
299
+ unindex(sourcePath: string) {
300
+ this.relations.delete(sourcePath)
301
+ this.navigations.delete(sourcePath)
302
+ this.compiledNodes.clear()
303
+ }
304
+
305
+ compile(
306
+ name: string,
307
+ use: ViewComponentUse,
308
+ ctx: PageContext,
309
+ app: AppContext,
310
+ ): CompiledNavigation {
311
+ const relation = this.relations.get(use.origin.path)?.get(name)
312
+ if (!relation) {
313
+ navigationError(use.origin, `missing @nav.${name} definition.`)
314
+ }
315
+ const source = edgeTarget(use.origin, relation.href, app)
316
+ if (!source) {
317
+ navigationError(
318
+ use.origin,
319
+ `navigation Source not found: ${relation.href}.`,
320
+ )
321
+ }
322
+ if (source.type !== NAVIGATION_TYPE) {
323
+ navigationError(
324
+ use.origin,
325
+ `${relation.href} is a ${source.type} Source, not navigation.`,
326
+ )
327
+ }
328
+ if (!app.access.canEmbed(ctx.source.id, source.id)) {
329
+ navigationError(ctx.source, `cannot embed navigation ${source.path}.`)
330
+ }
331
+ const authoredNodes = this.navigations.get(source.path)
332
+ if (!authoredNodes) navigationError(source, `Source was not indexed.`)
333
+ let nodes = this.compiledNodes.get(source.path)
334
+ if (!nodes) {
335
+ nodes = compileNodes(source, authoredNodes, app)
336
+ this.compiledNodes.set(source.path, nodes)
337
+ }
338
+ ctx.depend({ type: "source", path: source.path })
339
+ recordTargetDependencies(nodes, ctx.depend)
340
+ const authoredLabel = source.data.label
341
+ return {
342
+ name,
343
+ label:
344
+ typeof authoredLabel === "string" && authoredLabel.trim()
345
+ ? authoredLabel.trim()
346
+ : name,
347
+ nodes,
348
+ }
349
+ }
350
+
351
+ visible(state: CompiledNavigation, view: ViewContext) {
352
+ let projections = this.visibleViews.get(view)
353
+ if (!projections) {
354
+ projections = new Map()
355
+ this.visibleViews.set(view, projections)
356
+ }
357
+ let nodes = projections.get(state.nodes)
358
+ if (!nodes) {
359
+ nodes = visibleNodes(state.nodes, view)
360
+ projections.set(state.nodes, nodes)
361
+ }
362
+ return nodes
363
+ }
364
+ }