@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,78 @@
1
+ import type { Plugin } from "../types.ts"
2
+ import stylesheet from "./theme.css" with { type: "text" }
3
+
4
+ const LAYOUT_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
5
+
6
+ function invalidLayout(blockPath: string, value: unknown) {
7
+ const rendered =
8
+ typeof value === "string" ? JSON.stringify(value) : typeof value
9
+ return `Invalid Block layout on ${blockPath}: expected a lowercase slug, received ${rendered}.`
10
+ }
11
+
12
+ /** Compile reusable Block composition independently of any viewing mode. */
13
+ export function BlockLayoutPlugin(): Plugin {
14
+ const layouts = new Map<string, string>()
15
+ const pathsBySource = new Map<string, readonly string[]>()
16
+
17
+ function removeSource(sourcePath: string) {
18
+ for (const blockPath of pathsBySource.get(sourcePath) ?? []) {
19
+ layouts.delete(blockPath)
20
+ }
21
+ pathsBySource.delete(sourcePath)
22
+ }
23
+
24
+ return {
25
+ id: "block-layout",
26
+
27
+ index(source, app) {
28
+ const next: Array<readonly [blockPath: string, layout: string]> = []
29
+ for (const block of source.blocks) {
30
+ const authored = block.data.layout
31
+ if (authored === undefined) continue
32
+ if (typeof authored === "string" && LAYOUT_ID.test(authored)) {
33
+ next.push([block.path, authored])
34
+ continue
35
+ }
36
+ app.diagnostics.invalid(source, invalidLayout(block.path, authored))
37
+ }
38
+ removeSource(source.path)
39
+ if (!next.length) return
40
+ pathsBySource.set(
41
+ source.path,
42
+ next.map(([blockPath]) => blockPath),
43
+ )
44
+ for (const [blockPath, layout] of next) layouts.set(blockPath, layout)
45
+ },
46
+
47
+ unindex(sourcePath) {
48
+ removeSource(sourcePath)
49
+ },
50
+
51
+ page(html, ctx, app) {
52
+ if (!layouts.size || !html.includes("data-pmx-block-path")) return html
53
+ let used = false
54
+ const output = app.html.rewrite(html, [
55
+ [
56
+ "section[data-pmx-block-path]",
57
+ {
58
+ element(block) {
59
+ const path = block.getAttribute("data-pmx-block-path")
60
+ const layout = path ? layouts.get(path) : undefined
61
+ if (!layout) return
62
+ used = true
63
+ block.setAttribute("data-pmx-block-layout", layout)
64
+ },
65
+ },
66
+ ],
67
+ ])
68
+ if (used) {
69
+ ctx.addAsset({
70
+ type: "stylesheet",
71
+ name: "block-layout.css",
72
+ text: stylesheet,
73
+ })
74
+ }
75
+ return output
76
+ },
77
+ }
78
+ }
@@ -0,0 +1,90 @@
1
+ .pmx-document [data-pmx-block-layout="center"] {
2
+ text-align: center;
3
+ }
4
+
5
+ .pmx-document
6
+ :is(
7
+ [data-pmx-block-layout="media-start"],
8
+ [data-pmx-block-layout="media-end"]
9
+ ) {
10
+ display: grid;
11
+ grid-template-columns: repeat(2, minmax(0, 1fr));
12
+ gap: clamp(var(--pmx-prose-flow), 4vw, calc(var(--pmx-prose-flow) * 2));
13
+ align-items: center;
14
+ }
15
+
16
+ .pmx-document
17
+ [data-pmx-block-layout="media-start"]
18
+ > :is(
19
+ figure,
20
+ picture,
21
+ img:not([data-pmx-image="background"]),
22
+ video,
23
+ iframe,
24
+ p:has(> img:not([data-pmx-image="background"]):only-child),
25
+ p:has(> picture:only-child),
26
+ p:has(> video:only-child),
27
+ p:has(> iframe:only-child)
28
+ ) {
29
+ grid-column: 1;
30
+ grid-row: 1 / span 100;
31
+ }
32
+
33
+ .pmx-document
34
+ [data-pmx-block-layout="media-start"]
35
+ > :not(
36
+ figure,
37
+ picture,
38
+ img:not([data-pmx-image="background"]),
39
+ video,
40
+ iframe,
41
+ p:has(> img:not([data-pmx-image="background"]):only-child),
42
+ p:has(> picture:only-child),
43
+ p:has(> video:only-child),
44
+ p:has(> iframe:only-child)
45
+ ) {
46
+ grid-column: 2;
47
+ }
48
+
49
+ .pmx-document
50
+ [data-pmx-block-layout="media-end"]
51
+ > :is(
52
+ figure,
53
+ picture,
54
+ img:not([data-pmx-image="background"]),
55
+ video,
56
+ iframe,
57
+ p:has(> img:not([data-pmx-image="background"]):only-child),
58
+ p:has(> picture:only-child),
59
+ p:has(> video:only-child),
60
+ p:has(> iframe:only-child)
61
+ ) {
62
+ grid-column: 2;
63
+ grid-row: 1 / span 100;
64
+ }
65
+
66
+ .pmx-document
67
+ [data-pmx-block-layout="media-end"]
68
+ > :not(
69
+ figure,
70
+ picture,
71
+ img:not([data-pmx-image="background"]),
72
+ video,
73
+ iframe,
74
+ p:has(> img:not([data-pmx-image="background"]):only-child),
75
+ p:has(> picture:only-child),
76
+ p:has(> video:only-child),
77
+ p:has(> iframe:only-child)
78
+ ) {
79
+ grid-column: 1;
80
+ }
81
+
82
+ @media (max-width: 48rem) {
83
+ .pmx-document
84
+ :is(
85
+ [data-pmx-block-layout="media-start"],
86
+ [data-pmx-block-layout="media-end"]
87
+ ) {
88
+ display: block;
89
+ }
90
+ }
@@ -0,0 +1,236 @@
1
+ import { isFenceClose, matchFenceOpen } from "../../source/index.ts"
2
+ import { withoutHtmlComments } from "../../source/markdown/comments.ts"
3
+ import { escapeHtml } from "../../util.ts"
4
+ import {
5
+ bugIconSvg,
6
+ chevronRightIconSvg,
7
+ circleCheckBigIconSvg,
8
+ circleQuestionIconSvg,
9
+ clipboardListIconSvg,
10
+ flameIconSvg,
11
+ infoIconSvg,
12
+ listIconSvg,
13
+ listTodoIconSvg,
14
+ pencilIconSvg,
15
+ quoteIconSvg,
16
+ triangleAlertIconSvg,
17
+ xIconSvg,
18
+ zapIconSvg,
19
+ } from "../../icons/lucide.ts"
20
+ import type { Plugin } from "../types.ts"
21
+ import stylesheet from "./theme.css" with { type: "text" }
22
+
23
+ type Fold = "open" | "closed"
24
+
25
+ type CalloutMarker = Readonly<{
26
+ type: string
27
+ title?: string
28
+ fold?: Fold
29
+ }>
30
+
31
+ type QuoteSegment = Readonly<{
32
+ delimiter: number
33
+ end: number
34
+ }>
35
+
36
+ type QuoteLine = Readonly<{
37
+ depth: number
38
+ rest: string
39
+ segments: readonly QuoteSegment[]
40
+ }>
41
+
42
+ const MARKER = /^\[!([a-z0-9][a-z0-9_-]*)\]([+-]?)(?:[ \t]+(.*?))?[ \t]*$/i
43
+
44
+ const ICONS: Readonly<Record<string, string>> = {
45
+ note: pencilIconSvg,
46
+ abstract: clipboardListIconSvg,
47
+ summary: clipboardListIconSvg,
48
+ tldr: clipboardListIconSvg,
49
+ info: infoIconSvg,
50
+ todo: listTodoIconSvg,
51
+ tip: flameIconSvg,
52
+ hint: flameIconSvg,
53
+ important: flameIconSvg,
54
+ success: circleCheckBigIconSvg,
55
+ check: circleCheckBigIconSvg,
56
+ done: circleCheckBigIconSvg,
57
+ question: circleQuestionIconSvg,
58
+ help: circleQuestionIconSvg,
59
+ faq: circleQuestionIconSvg,
60
+ warning: triangleAlertIconSvg,
61
+ caution: triangleAlertIconSvg,
62
+ attention: triangleAlertIconSvg,
63
+ failure: xIconSvg,
64
+ fail: xIconSvg,
65
+ missing: xIconSvg,
66
+ danger: zapIconSvg,
67
+ error: zapIconSvg,
68
+ bug: bugIconSvg,
69
+ example: listIconSvg,
70
+ quote: quoteIconSvg,
71
+ cite: quoteIconSvg,
72
+ }
73
+
74
+ function parseQuoteLine(line: string): QuoteLine {
75
+ const segments: QuoteSegment[] = []
76
+ let cursor = 0
77
+
78
+ while (cursor < line.length) {
79
+ const match = line.slice(cursor).match(/^( {0,3})>[ \t]?/)
80
+ if (!match) break
81
+ const delimiter = cursor + match[1]!.length
82
+ cursor += match[0].length
83
+ segments.push({ delimiter, end: cursor })
84
+ }
85
+
86
+ return {
87
+ depth: segments.length,
88
+ rest: line.slice(cursor),
89
+ segments,
90
+ }
91
+ }
92
+
93
+ function afterQuoteDepth(line: string, parsed: QuoteLine, depth: number) {
94
+ if (!depth) return line
95
+ return line.slice(parsed.segments[depth - 1]!.end)
96
+ }
97
+
98
+ function markerFromLine(line: QuoteLine): CalloutMarker | undefined {
99
+ if (!line.depth) return
100
+ const match = line.rest.match(MARKER)
101
+ if (!match) return
102
+ const fold =
103
+ match[2] === "+" ? "open" : match[2] === "-" ? "closed" : undefined
104
+ const title = match[3]?.trim()
105
+ return {
106
+ type: match[1]!.toLowerCase(),
107
+ ...(title ? { title } : {}),
108
+ ...(fold ? { fold } : {}),
109
+ }
110
+ }
111
+
112
+ function defaultTitle(type: string) {
113
+ return type
114
+ .split(/[-_]+/)
115
+ .filter(Boolean)
116
+ .map((word) => word[0]!.toUpperCase() + word.slice(1))
117
+ .join(" ")
118
+ }
119
+
120
+ function trimBlankEdges(lines: readonly string[]) {
121
+ let start = 0
122
+ let end = lines.length
123
+ while (start < end && !lines[start]!.trim()) start++
124
+ while (end > start && !lines[end - 1]!.trim()) end--
125
+ return lines.slice(start, end)
126
+ }
127
+
128
+ function prefixLines(markdown: string, prefix: string) {
129
+ if (!prefix) return markdown
130
+ return markdown
131
+ .split("\n")
132
+ .map((line) => prefix + line)
133
+ .join("\n")
134
+ }
135
+
136
+ function renderCallout(marker: CalloutMarker, body: string, prefix: string) {
137
+ const foldable = marker.fold !== undefined
138
+ const container = foldable ? "details" : "aside"
139
+ const title = foldable ? "summary" : "div"
140
+ const icon = ICONS[marker.type] ?? pencilIconSvg
141
+ const open = marker.fold === "open" ? " open" : ""
142
+ const content = body
143
+ ? `\n<div class="pmx-callout-content">\n\n${body}\n\n</div>`
144
+ : ""
145
+ const toggle = foldable
146
+ ? `<span class="pmx-callout-toggle">${chevronRightIconSvg}</span>`
147
+ : ""
148
+ const html = `<${container} class="pmx-callout" data-callout="${escapeHtml(marker.type)}"${open}>
149
+ <${title} class="pmx-callout-title"><span class="pmx-callout-icon">${icon}</span><span class="pmx-callout-title-text">${escapeHtml(marker.title ?? defaultTitle(marker.type))}</span>${toggle}</${title}>${content}
150
+ </${container}>`
151
+ return `${prefixLines(html, prefix)}\n${prefix}`
152
+ }
153
+
154
+ function calloutEnd(lines: readonly string[], start: number, depth: number) {
155
+ let index = start + 1
156
+ while (index < lines.length && parseQuoteLine(lines[index]!).depth >= depth) {
157
+ index++
158
+ }
159
+ return index
160
+ }
161
+
162
+ function transformCallouts(markdown: string): string {
163
+ const lines = markdown.split(/\r?\n/)
164
+ const output: string[] = []
165
+ let fence: { marker: string; depth: number } | undefined
166
+ let inHtmlComment = false
167
+
168
+ for (let index = 0; index < lines.length;) {
169
+ const line = lines[index]!
170
+ const visible = withoutHtmlComments(line, inHtmlComment)
171
+ inHtmlComment = visible.inComment
172
+ const quoted = parseQuoteLine(line)
173
+
174
+ if (fence) {
175
+ output.push(line)
176
+ if (
177
+ quoted.depth >= fence.depth &&
178
+ isFenceClose(afterQuoteDepth(line, quoted, fence.depth), fence.marker)
179
+ ) {
180
+ fence = undefined
181
+ }
182
+ index++
183
+ continue
184
+ }
185
+
186
+ if (visible.line === line) {
187
+ const opening = matchFenceOpen(quoted.depth ? quoted.rest : line)
188
+ if (opening) {
189
+ fence = { marker: opening.marker, depth: quoted.depth }
190
+ output.push(line)
191
+ index++
192
+ continue
193
+ }
194
+ }
195
+
196
+ const marker = visible.line === line ? markerFromLine(quoted) : undefined
197
+ if (!marker) {
198
+ output.push(line)
199
+ index++
200
+ continue
201
+ }
202
+
203
+ const end = calloutEnd(lines, index, quoted.depth)
204
+ const bodyLines = trimBlankEdges(
205
+ lines.slice(index + 1, end).map((bodyLine) => {
206
+ const parsed = parseQuoteLine(bodyLine)
207
+ return afterQuoteDepth(bodyLine, parsed, quoted.depth)
208
+ }),
209
+ )
210
+ const body = bodyLines.length ? transformCallouts(bodyLines.join("\n")) : ""
211
+ const prefix = line.slice(0, quoted.segments.at(-1)!.delimiter)
212
+ output.push(renderCallout(marker, body, prefix))
213
+ index = end
214
+ }
215
+
216
+ return output.join("\n")
217
+ }
218
+
219
+ /** Obsidian-compatible blockquote callouts with native nested and foldable output. */
220
+ export function CalloutPlugin(): Plugin {
221
+ return {
222
+ id: "callout",
223
+ precompile(markdown) {
224
+ return markdown.includes("[!") ? transformCallouts(markdown) : markdown
225
+ },
226
+ page(html, ctx) {
227
+ if (!html.includes('class="pmx-callout"')) return html
228
+ ctx.addAsset({
229
+ type: "stylesheet",
230
+ name: "callout.css",
231
+ text: stylesheet,
232
+ })
233
+ return html
234
+ },
235
+ }
236
+ }
@@ -0,0 +1,138 @@
1
+ @scope (.pmx-prose) to ([data-pmx-prose="off"]) {
2
+ :where(.pmx-callout) {
3
+ --pmx-callout-color: var(--pmx-color-accent);
4
+ margin-block: var(--pmx-prose-flow);
5
+ overflow: clip;
6
+ border: 1px solid
7
+ color-mix(in oklch, var(--pmx-callout-color) 35%, transparent);
8
+ border-inline-start: 0.25em solid var(--pmx-callout-color);
9
+ border-radius: var(--pmx-radius-lg);
10
+ background: color-mix(
11
+ in oklch,
12
+ var(--pmx-callout-color) 9%,
13
+ var(--pmx-color-bg)
14
+ );
15
+ color: var(--pmx-color-fg);
16
+ }
17
+
18
+ :where(.pmx-callout-title) {
19
+ display: flex;
20
+ gap: 0.6em;
21
+ align-items: center;
22
+ padding: 0.7em 0.9em;
23
+ color: var(--pmx-callout-color);
24
+ font-weight: 700;
25
+ line-height: 1.35;
26
+ list-style: none;
27
+ }
28
+
29
+ :where(summary.pmx-callout-title) {
30
+ cursor: pointer;
31
+ }
32
+
33
+ summary.pmx-callout-title::-webkit-details-marker {
34
+ display: none;
35
+ }
36
+
37
+ :where(.pmx-callout-icon, .pmx-callout-toggle) {
38
+ display: inline-flex;
39
+ flex: 0 0 auto;
40
+ }
41
+
42
+ :where(.pmx-callout-icon svg, .pmx-callout-toggle svg) {
43
+ inline-size: 1.1em;
44
+ block-size: 1.1em;
45
+ stroke-width: 2.2;
46
+ }
47
+
48
+ :where(.pmx-callout-title-text) {
49
+ min-inline-size: 0;
50
+ flex: 1;
51
+ }
52
+
53
+ :where(.pmx-callout-toggle) {
54
+ transition: transform 140ms ease-out;
55
+ }
56
+
57
+ :where(details.pmx-callout[open] > .pmx-callout-title .pmx-callout-toggle) {
58
+ transform: rotate(90deg);
59
+ }
60
+
61
+ :where(.pmx-callout-content) {
62
+ padding: 0 0.9em 0.85em;
63
+ }
64
+
65
+ :where(.pmx-callout-content > :first-child) {
66
+ margin-block-start: 0;
67
+ }
68
+
69
+ :where(.pmx-callout-content > :last-child) {
70
+ margin-block-end: 0;
71
+ }
72
+
73
+ :where(
74
+ .pmx-callout[data-callout="abstract"],
75
+ .pmx-callout[data-callout="summary"],
76
+ .pmx-callout[data-callout="tldr"]
77
+ ) {
78
+ --pmx-callout-color: light-dark(oklch(0.54 0.12 215), oklch(0.76 0.11 215));
79
+ }
80
+
81
+ :where(.pmx-callout[data-callout="info"], .pmx-callout[data-callout="todo"]) {
82
+ --pmx-callout-color: light-dark(oklch(0.54 0.17 255), oklch(0.76 0.13 250));
83
+ }
84
+
85
+ :where(
86
+ .pmx-callout[data-callout="tip"],
87
+ .pmx-callout[data-callout="hint"],
88
+ .pmx-callout[data-callout="important"]
89
+ ) {
90
+ --pmx-callout-color: light-dark(oklch(0.52 0.13 165), oklch(0.76 0.12 165));
91
+ }
92
+
93
+ :where(
94
+ .pmx-callout[data-callout="success"],
95
+ .pmx-callout[data-callout="check"],
96
+ .pmx-callout[data-callout="done"]
97
+ ) {
98
+ --pmx-callout-color: light-dark(oklch(0.52 0.15 145), oklch(0.76 0.13 145));
99
+ }
100
+
101
+ :where(
102
+ .pmx-callout[data-callout="question"],
103
+ .pmx-callout[data-callout="help"],
104
+ .pmx-callout[data-callout="faq"]
105
+ ) {
106
+ --pmx-callout-color: light-dark(oklch(0.58 0.14 85), oklch(0.82 0.13 85));
107
+ }
108
+
109
+ :where(
110
+ .pmx-callout[data-callout="warning"],
111
+ .pmx-callout[data-callout="caution"],
112
+ .pmx-callout[data-callout="attention"]
113
+ ) {
114
+ --pmx-callout-color: light-dark(oklch(0.58 0.17 55), oklch(0.8 0.15 65));
115
+ }
116
+
117
+ :where(
118
+ .pmx-callout[data-callout="failure"],
119
+ .pmx-callout[data-callout="fail"],
120
+ .pmx-callout[data-callout="missing"],
121
+ .pmx-callout[data-callout="danger"],
122
+ .pmx-callout[data-callout="error"],
123
+ .pmx-callout[data-callout="bug"]
124
+ ) {
125
+ --pmx-callout-color: var(--pmx-color-danger);
126
+ }
127
+
128
+ :where(.pmx-callout[data-callout="example"]) {
129
+ --pmx-callout-color: light-dark(oklch(0.55 0.16 305), oklch(0.77 0.13 305));
130
+ }
131
+
132
+ :where(
133
+ .pmx-callout[data-callout="quote"],
134
+ .pmx-callout[data-callout="cite"]
135
+ ) {
136
+ --pmx-callout-color: var(--pmx-color-muted);
137
+ }
138
+ }
@@ -0,0 +1,144 @@
1
+ import { publicPath } from "../server/router.ts"
2
+ import type { HtmlElement } from "../environment.ts"
3
+ import { joinHref, splitHref } from "../ops/href.ts"
4
+ import { interpolateText, isExternalHref } from "../source/index.ts"
5
+ import type { Source } from "../source/index.ts"
6
+ import { decodeHtmlEntities } from "../util.ts"
7
+ import type { AppContext, Plugin } from "./types.ts"
8
+
9
+ const REWRITABLE = new Set(["asset", "source"])
10
+ const NEW_TAB_DESCRIPTION_ID = "pmx-new-tab-description"
11
+ const DEFAULT_TARGET_ATTRIBUTE = "data-pmx-default-target"
12
+
13
+ export type ExternalWebLinks = "new-tab" | "same-tab"
14
+
15
+ export type CanonicalLinkPluginOptions = Readonly<{
16
+ /** Default browsing context for authored external HTTP(S) links. */
17
+ externalWebLinks?: ExternalWebLinks
18
+ }>
19
+
20
+ function hasClass(el: HtmlElement, name: string) {
21
+ const current = el.getAttribute("class")
22
+ if (!current) return false
23
+ return current.split(/\s+/).includes(name)
24
+ }
25
+
26
+ function addClass(el: HtmlElement, name: string) {
27
+ if (hasClass(el, name)) return
28
+ const current = el.getAttribute("class")
29
+ el.setAttribute("class", current ? `${current} ${name}` : name)
30
+ }
31
+
32
+ function addTokenAttribute(el: HtmlElement, attribute: string, token: string) {
33
+ const current = el.getAttribute(attribute)?.trim()
34
+ const tokens = current ? current.split(/\s+/) : []
35
+ if (tokens.some((value) => value.toLowerCase() === token.toLowerCase()))
36
+ return
37
+ el.setAttribute(attribute, [...tokens, token].join(" "))
38
+ }
39
+
40
+ function isExternalWebHref(href: string) {
41
+ const normalized = href.trim()
42
+ return /^(?:https?:)?\/\//i.test(normalized)
43
+ }
44
+
45
+ function isPmxOwnedLink(el: HtmlElement) {
46
+ const current = el.getAttribute("class")
47
+ if (!current) return false
48
+ return current.split(/\s+/).some((token) => token.startsWith("pmx-"))
49
+ }
50
+
51
+ function stampLink(
52
+ el: HtmlElement,
53
+ href: string,
54
+ externalWebLinks: ExternalWebLinks,
55
+ ) {
56
+ if (!isPmxOwnedLink(el)) addClass(el, "pmx-link")
57
+ if (isExternalHref(href)) addClass(el, "pmx-link-external")
58
+ if (!isExternalWebHref(href) || el.getAttribute("download") != null) return
59
+
60
+ const authoredTarget = el.getAttribute("target")
61
+ if (authoredTarget == null && externalWebLinks === "new-tab") {
62
+ el.setAttribute("target", "_blank")
63
+ el.setAttribute(DEFAULT_TARGET_ATTRIBUTE, "new-tab")
64
+ }
65
+ if (el.getAttribute("target")?.trim().toLowerCase() !== "_blank") return
66
+
67
+ addClass(el, "pmx-link-new-tab")
68
+ addTokenAttribute(el, "rel", "noopener")
69
+ addTokenAttribute(el, "aria-describedby", NEW_TAB_DESCRIPTION_ID)
70
+ }
71
+
72
+ function rewriteCanonicalLinks(html: string, source: Source, app: AppContext) {
73
+ const byHref = new Map<string, string>()
74
+ for (const edge of app.graph.linksFrom(source.path)) {
75
+ if (!edge.to || !REWRITABLE.has(edge.type)) continue
76
+ const renderedHref = interpolateText(edge.href, source.data)
77
+ const { query, fragment } = splitHref(renderedHref)
78
+ if (edge.type === "source") {
79
+ const target = app.sources.get(edge.to)
80
+ if (target) {
81
+ byHref.set(renderedHref, joinHref(publicPath(target), query, fragment))
82
+ }
83
+ continue
84
+ }
85
+ byHref.set(renderedHref, joinHref(edge.to, query, fragment))
86
+ }
87
+ if (!byHref.size) return html
88
+
89
+ return app.html.rewrite(html, [
90
+ [
91
+ "[href], [src]",
92
+ {
93
+ element(el) {
94
+ for (const attr of ["href", "src"] as const) {
95
+ const value = el.getAttribute(attr)
96
+ const authored = value ? decodeHtmlEntities(value.trim()) : undefined
97
+ const canonical = authored ? byHref.get(authored) : undefined
98
+ if (canonical) el.setAttribute(attr, canonical)
99
+ }
100
+ },
101
+ },
102
+ ],
103
+ ])
104
+ }
105
+
106
+ /** Rewrite authored relative src/href values and stamp final PathMX links. */
107
+ export function CanonicalLinkPlugin(
108
+ options: CanonicalLinkPluginOptions = {},
109
+ ): Plugin {
110
+ const externalWebLinks = options.externalWebLinks ?? "new-tab"
111
+ if (externalWebLinks !== "new-tab" && externalWebLinks !== "same-tab") {
112
+ throw new Error(`Invalid external web-link policy: ${externalWebLinks}`)
113
+ }
114
+ return {
115
+ id: "canonical-links",
116
+ postcompile(html, { source, scope }, app) {
117
+ if (scope !== "embedded") return html
118
+ return rewriteCanonicalLinks(html, source, app)
119
+ },
120
+
121
+ page(html, { source }, app) {
122
+ return rewriteCanonicalLinks(html, source, app)
123
+ },
124
+
125
+ streamSelectors() {
126
+ return {
127
+ body: {
128
+ element(el) {
129
+ el.prepend(
130
+ `<span id="${NEW_TAB_DESCRIPTION_ID}" hidden>Opens in a new tab</span>`,
131
+ { html: true },
132
+ )
133
+ },
134
+ },
135
+ "a[href]": {
136
+ element(el) {
137
+ const href = el.getAttribute("href")
138
+ if (href != null) stampLink(el, href, externalWebLinks)
139
+ },
140
+ },
141
+ }
142
+ },
143
+ }
144
+ }