@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,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
|
+
}
|
package/plugins/meta.ts
ADDED
|
@@ -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
|
+
}
|