@miadi/reference-ui 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +82 -2
- package/dist/index.d.ts +215 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +386 -0
- package/dist/index.js.map +1 -0
- package/dist/react.d.ts +47 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +55 -0
- package/dist/react.js.map +1 -0
- package/package.json +74 -3
- package/src/index.ts +538 -0
- package/src/react.tsx +106 -0
- package/src/reference-ui.css +174 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @miadi/reference-ui — the presentation half of Miadi references.
|
|
3
|
+
*
|
|
4
|
+
* `@miadi/inquiry-weave/references` owns the ADDRESS: parse, scan, fold
|
|
5
|
+
* `{{ … }}` directives. This package owns how a reference LOOKS once a
|
|
6
|
+
* resolver has answered: a view model, an HTML renderer with no framework,
|
|
7
|
+
* and (on `./react`) React components with the same markup.
|
|
8
|
+
*
|
|
9
|
+
* The split is the Dexter reference model's: what a link points to and how it
|
|
10
|
+
* is presented are separate layers with separate interfaces. Finding the
|
|
11
|
+
* target is the host's job, because only the host knows who is reading — the
|
|
12
|
+
* Miadi app reads its catalog on disk as the viewer, a review service asks a
|
|
13
|
+
* public front. So nothing here holds a URL, a token or a chronicle root.
|
|
14
|
+
* `createRemoteResolver` takes the front as an argument.
|
|
15
|
+
*
|
|
16
|
+
* Ref: jgwill/Miadi#713. Grounding: foundations/resource-addressing-and-terminal-hypermedia/.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
findTextReferences,
|
|
21
|
+
foundationRoute,
|
|
22
|
+
isFoundationUri,
|
|
23
|
+
isMiadiChronicleUri,
|
|
24
|
+
isWheelRefUri,
|
|
25
|
+
linkBareReferences,
|
|
26
|
+
parseFoundationUri,
|
|
27
|
+
parseMiadiChronicleUri,
|
|
28
|
+
parseWheelRefUri,
|
|
29
|
+
transformDirectives,
|
|
30
|
+
type ChronicleResolution,
|
|
31
|
+
type ParsedChronicleUri,
|
|
32
|
+
type ParsedFoundationUri,
|
|
33
|
+
type ParsedWheelRef,
|
|
34
|
+
} from "@miadi/inquiry-weave/references"
|
|
35
|
+
|
|
36
|
+
export type ReferenceScheme = "chronicle" | "foundation" | "ceremony" | "circle"
|
|
37
|
+
export type ReferenceShow = "chip" | "card"
|
|
38
|
+
|
|
39
|
+
interface ViewBase {
|
|
40
|
+
/** The URI as written. Rendered into `data-miadi-uri`. */
|
|
41
|
+
uri: string
|
|
42
|
+
/** The author's label (`mc.label`), when one was given. */
|
|
43
|
+
label?: string
|
|
44
|
+
/** What the reference shows when no label and no link text exist. */
|
|
45
|
+
fallback: string
|
|
46
|
+
show: ReferenceShow
|
|
47
|
+
highlight: boolean
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface EpisodeReferenceView extends ViewBase {
|
|
51
|
+
kind: "episode"
|
|
52
|
+
href: string
|
|
53
|
+
/** `ep 550`, or the folder name when the episode carries no number. */
|
|
54
|
+
badge: string
|
|
55
|
+
name: string
|
|
56
|
+
title?: string
|
|
57
|
+
date?: string
|
|
58
|
+
direction?: string
|
|
59
|
+
theme?: string
|
|
60
|
+
/** Waypoints the path travelled through, `ep 092 → ep 126`. */
|
|
61
|
+
via: string[]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface FoundationReferenceView extends ViewBase {
|
|
65
|
+
kind: "foundation"
|
|
66
|
+
href: string
|
|
67
|
+
slug: string
|
|
68
|
+
file?: string
|
|
69
|
+
title?: string
|
|
70
|
+
/** The `@miadi/foundations` version the page was read from. */
|
|
71
|
+
version?: string
|
|
72
|
+
fields: string[]
|
|
73
|
+
/** Set for a packet an episode wrote itself: the episode's badge. */
|
|
74
|
+
episode?: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface WheelReferenceView extends ViewBase {
|
|
78
|
+
kind: "wheel"
|
|
79
|
+
wheel: "ceremony" | "circle"
|
|
80
|
+
id: string
|
|
81
|
+
/** The page that asks the reader for their seat. Nothing of the record is shown. */
|
|
82
|
+
href: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface UnresolvedReferenceView extends ViewBase {
|
|
86
|
+
kind: "unresolved"
|
|
87
|
+
scheme: ReferenceScheme
|
|
88
|
+
/** Why, for the title attribute. */
|
|
89
|
+
reason: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type ReferenceView = EpisodeReferenceView | FoundationReferenceView | WheelReferenceView | UnresolvedReferenceView
|
|
93
|
+
|
|
94
|
+
/** True for the four schemes this package renders. */
|
|
95
|
+
export function isReferenceUri(url: string | undefined): boolean {
|
|
96
|
+
return isMiadiChronicleUri(url) || isFoundationUri(url) || isWheelRefUri(url)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A markdown renderer's URL filter that lets the four schemes through and
|
|
101
|
+
* hands everything else to the host's own filter. react-markdown strips
|
|
102
|
+
* unknown schemes by default, which would blank every reference.
|
|
103
|
+
*/
|
|
104
|
+
export function referenceUrlTransform(fallback: (url: string) => string): (url: string) => string {
|
|
105
|
+
return (url) => (isReferenceUri(url) ? url : fallback(url))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The href a view may carry: a path on this host (`/x`, never `//x`), made
|
|
110
|
+
* absolute on `base` when one is given, or an http(s) URL on `base`'s own
|
|
111
|
+
* origin. Anything else — `javascript:`, `//evil.example`, another origin —
|
|
112
|
+
* is refused, and the view renders unresolved. A resolver's answer is data
|
|
113
|
+
* from the network, and the HTML renderer cannot rely on React's own
|
|
114
|
+
* `javascript:` guard (jgwill/Miadi#713 review, F1).
|
|
115
|
+
*/
|
|
116
|
+
export function safeHref(route: unknown, base?: string): string | null {
|
|
117
|
+
if (typeof route !== "string" || /[\u0000-\u001f\u007f\s\\]/.test(route)) return null
|
|
118
|
+
if (/^\/(?!\/)/.test(route)) return base ? `${base.replace(/\/+$/, "")}${route}` : route
|
|
119
|
+
if (!base) return null
|
|
120
|
+
try {
|
|
121
|
+
const url = new URL(route)
|
|
122
|
+
const origin = new URL(base).origin
|
|
123
|
+
return (url.protocol === "https:" || url.protocol === "http:") && url.origin === origin ? url.href : null
|
|
124
|
+
} catch {
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const showOf = (options: Record<string, string>): ReferenceShow => (options.show === "card" ? "card" : "chip")
|
|
129
|
+
const pad = (n: number) => String(n).padStart(3, "0")
|
|
130
|
+
|
|
131
|
+
function base(uri: string, options: Record<string, string>, fallback: string): ViewBase {
|
|
132
|
+
return {
|
|
133
|
+
uri,
|
|
134
|
+
...(options.label ? { label: options.label } : {}),
|
|
135
|
+
fallback,
|
|
136
|
+
show: showOf(options),
|
|
137
|
+
highlight: options.highlight === "true",
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function unresolvedView(uri: string, scheme: ReferenceScheme, reason: string, options: Record<string, string> = {}, fallback?: string): UnresolvedReferenceView {
|
|
142
|
+
return { kind: "unresolved", scheme, reason, ...base(uri, options, fallback ?? uri.replace(/^miadi-[a-z]+:(\/\/)?/i, "")) }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The view of an episode reference, from a parse and a resolution
|
|
147
|
+
* (`resolveChronicleRef`, or the `resolution` field of `/api/chronicle/resolve`).
|
|
148
|
+
* `base` makes the room route absolute for a host on another origin.
|
|
149
|
+
*/
|
|
150
|
+
export function episodeReferenceView(
|
|
151
|
+
parsed: ParsedChronicleUri,
|
|
152
|
+
resolution: ChronicleResolution,
|
|
153
|
+
options: { base?: string } = {},
|
|
154
|
+
): EpisodeReferenceView | UnresolvedReferenceView {
|
|
155
|
+
const target = resolution?.target
|
|
156
|
+
const waypoints = Array.isArray(resolution?.waypoints) ? resolution.waypoints : []
|
|
157
|
+
const href = target?.resolved === true && typeof target.name === "string" ? safeHref(target.route, options.base) : null
|
|
158
|
+
if (!target || !href) {
|
|
159
|
+
return unresolvedView(parsed.uri, "chronicle", `Unresolved chronicle reference: ${parsed.uri}`, parsed.options, parsed.episodePath)
|
|
160
|
+
}
|
|
161
|
+
const title = typeof target.title === "string" ? target.title : undefined
|
|
162
|
+
return {
|
|
163
|
+
kind: "episode",
|
|
164
|
+
// A bare mention or a link with no words of its own shows the title.
|
|
165
|
+
...base(parsed.uri, parsed.options, title ?? parsed.episodePath),
|
|
166
|
+
href,
|
|
167
|
+
badge: typeof target.number === "number" ? `ep ${pad(target.number)}` : target.name!,
|
|
168
|
+
name: target.name!,
|
|
169
|
+
...(title ? { title } : {}),
|
|
170
|
+
...(typeof target.date === "string" ? { date: target.date } : {}),
|
|
171
|
+
...(typeof target.direction === "string" ? { direction: target.direction } : {}),
|
|
172
|
+
...(typeof target.theme === "string" ? { theme: target.theme } : {}),
|
|
173
|
+
via: waypoints.map((w) => (typeof w?.number === "number" ? `ep ${w.number}` : String(w?.segment ?? ""))),
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** What a host knows about one packet: from `@miadi/foundations`, or the `foundation` field of `/api/chronicle/resolve`. */
|
|
178
|
+
export interface FoundationInfo {
|
|
179
|
+
slug: string
|
|
180
|
+
exists: boolean
|
|
181
|
+
title?: string
|
|
182
|
+
version?: string
|
|
183
|
+
fields?: string[]
|
|
184
|
+
/** Route of the packet or file page; defaults to `/foundations/<slug>[/<file>]`. */
|
|
185
|
+
route?: string
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function foundationReferenceView(
|
|
189
|
+
parsed: ParsedFoundationUri,
|
|
190
|
+
info: FoundationInfo | null,
|
|
191
|
+
options: { base?: string } = {},
|
|
192
|
+
): FoundationReferenceView | UnresolvedReferenceView {
|
|
193
|
+
const fallback = parsed.file ? `${parsed.slug}/${parsed.file}` : parsed.slug
|
|
194
|
+
const href = info && info.exists === true ? safeHref(info.route ?? foundationRoute(parsed), options.base) : null
|
|
195
|
+
if (!info || !href) {
|
|
196
|
+
return unresolvedView(parsed.uri, "foundation", `Unresolved foundation reference: ${parsed.uri}`, parsed.options, fallback)
|
|
197
|
+
}
|
|
198
|
+
const title = typeof info.title === "string" ? info.title : undefined
|
|
199
|
+
return {
|
|
200
|
+
kind: "foundation",
|
|
201
|
+
...base(parsed.uri, parsed.options, title && !parsed.file ? title : fallback),
|
|
202
|
+
href,
|
|
203
|
+
slug: parsed.slug,
|
|
204
|
+
...(parsed.file ? { file: parsed.file } : {}),
|
|
205
|
+
...(title ? { title } : {}),
|
|
206
|
+
...(typeof info.version === "string" ? { version: info.version } : {}),
|
|
207
|
+
fields: Array.isArray(info.fields) ? info.fields.filter((f): f is string => typeof f === "string") : [],
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* An episode's own packet, `miadi-chronicle:<ep>/foundations/<topic>`: a
|
|
213
|
+
* foundation chip whose page is the episode room opened on the packet README.
|
|
214
|
+
*/
|
|
215
|
+
export function episodeFoundationView(
|
|
216
|
+
parsed: ParsedChronicleUri,
|
|
217
|
+
resolution: ChronicleResolution,
|
|
218
|
+
info: { title?: string; exists: boolean },
|
|
219
|
+
options: { base?: string } = {},
|
|
220
|
+
): FoundationReferenceView | UnresolvedReferenceView {
|
|
221
|
+
const topic = parsed.artifactName ?? ""
|
|
222
|
+
const episode = episodeReferenceView(parsed, resolution, options)
|
|
223
|
+
if (episode.kind !== "episode" || !info.exists || !topic) {
|
|
224
|
+
return unresolvedView(parsed.uri, "chronicle", `Unresolved episode foundation: ${parsed.uri}`, parsed.options, topic || parsed.episodePath)
|
|
225
|
+
}
|
|
226
|
+
const title = typeof info.title === "string" ? info.title : undefined
|
|
227
|
+
return {
|
|
228
|
+
kind: "foundation",
|
|
229
|
+
...base(parsed.uri, parsed.options, title ?? topic),
|
|
230
|
+
// The room's own `?file=` door, its query placed before any fragment.
|
|
231
|
+
href: withQuery(episode.href, `file=${encodeURIComponent(`foundations/${topic}/README.md`)}`),
|
|
232
|
+
slug: topic,
|
|
233
|
+
...(title ? { title } : {}),
|
|
234
|
+
fields: [],
|
|
235
|
+
episode: episode.badge,
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function wheelReferenceView(parsed: ParsedWheelRef, route: string, options: { base?: string } = {}): WheelReferenceView | UnresolvedReferenceView {
|
|
240
|
+
const href = safeHref(route, options.base)
|
|
241
|
+
if (!href) return unresolvedView(parsed.uri, parsed.kind, `Unresolved ${parsed.kind} reference: ${parsed.uri}`, parsed.options, parsed.kind)
|
|
242
|
+
return {
|
|
243
|
+
kind: "wheel",
|
|
244
|
+
wheel: parsed.kind,
|
|
245
|
+
id: parsed.id,
|
|
246
|
+
href,
|
|
247
|
+
...base(parsed.uri, parsed.options, parsed.kind),
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Put a query before the fragment of an href. */
|
|
252
|
+
export function withQuery(href: string, query: string): string {
|
|
253
|
+
const hash = href.indexOf("#")
|
|
254
|
+
const head = hash === -1 ? href : href.slice(0, hash)
|
|
255
|
+
const tail = hash === -1 ? "" : href.slice(hash)
|
|
256
|
+
return `${head}${head.includes("?") ? "&" : "?"}${query}${tail}`
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── HTML ─────────────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
const ESCAPES: Record<string, string> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }
|
|
262
|
+
export function escapeHtml(text: string): string {
|
|
263
|
+
return text.replace(/[&<>"']/g, (c) => ESCAPES[c]!)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const DIRECTIONS = new Set(["east", "south", "west", "north"])
|
|
267
|
+
|
|
268
|
+
/** Class names, shared by the HTML renderer and the React components so the markup is one. */
|
|
269
|
+
export function referenceClasses(view: ReferenceView): string {
|
|
270
|
+
if (view.kind === "unresolved") return `mr-ref mr-ref--chip mr-ref--unresolved not-prose`
|
|
271
|
+
const dir = view.kind === "episode" && view.direction && DIRECTIONS.has(view.direction) ? ` mr-dir-${view.direction}` : ""
|
|
272
|
+
const highlight = view.highlight ? " mr-ref--highlight" : ""
|
|
273
|
+
return `mr-ref mr-ref--${view.show} mr-ref--${view.kind}${dir}${highlight} not-prose`
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The text pieces a view shows, in order. Both renderers place these and nothing else. */
|
|
277
|
+
export interface ReferenceParts {
|
|
278
|
+
badge: string
|
|
279
|
+
label: string
|
|
280
|
+
title?: string
|
|
281
|
+
meta?: string
|
|
282
|
+
note?: string
|
|
283
|
+
tooltip: string
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* What a view shows. `text` is the link text a markdown author wrote
|
|
288
|
+
* (`[text](uri)`), which outranks the fallback but not an explicit `mc.label`.
|
|
289
|
+
*/
|
|
290
|
+
export function referenceParts(view: ReferenceView, text?: string): ReferenceParts {
|
|
291
|
+
// A bare mention linked to itself has no words of its own (#713 review, F10).
|
|
292
|
+
const words = text && !isReferenceUri(text.trim()) ? text : undefined
|
|
293
|
+
const label = view.label || words || view.fallback
|
|
294
|
+
switch (view.kind) {
|
|
295
|
+
case "unresolved":
|
|
296
|
+
return { badge: view.scheme === "foundation" ? "◇" : "⟐", label, note: "(unresolved)", tooltip: view.reason }
|
|
297
|
+
case "wheel":
|
|
298
|
+
return { badge: view.wheel === "ceremony" ? "◆" : "○", label, tooltip: `${view.wheel} ${view.id}: its page asks for your seat` }
|
|
299
|
+
case "episode": {
|
|
300
|
+
const meta = [
|
|
301
|
+
view.label && view.title && view.label !== view.title ? view.title : "",
|
|
302
|
+
view.date ?? "",
|
|
303
|
+
view.theme ?? "",
|
|
304
|
+
view.via.length > 0 ? `via ${view.via.join(" → ")}` : "",
|
|
305
|
+
].filter(Boolean).join(" · ")
|
|
306
|
+
return view.show === "card"
|
|
307
|
+
? { badge: `⟐ ${view.badge}${view.direction ? ` · ${view.direction}` : ""}`, label, title: view.label || view.title || label, meta, tooltip: view.title ?? view.name }
|
|
308
|
+
: { badge: `⟐ ${view.badge}`, label, tooltip: `${view.title ?? view.name}${view.date ? ` (${view.date})` : ""}` }
|
|
309
|
+
}
|
|
310
|
+
case "foundation": {
|
|
311
|
+
const kind = view.episode ? `foundation · ${view.episode}` : "foundation"
|
|
312
|
+
// A package number reads `v0.1.6`; a source commit reads `at a9cf5d29b4c9`.
|
|
313
|
+
const version = view.version ? (/^\d+\.\d+/.test(view.version) ? `v${view.version}` : `at ${view.version}`) : ""
|
|
314
|
+
const meta = [view.file ?? "", view.fields.slice(0, 3).join(" · "), version].filter(Boolean).join(" · ")
|
|
315
|
+
return view.show === "card"
|
|
316
|
+
? { badge: `◇ ${kind}`, label, title: view.label || view.title || label, meta, tooltip: view.title ?? view.slug }
|
|
317
|
+
: { badge: `◇ ${kind}`, label, tooltip: `${view.title ?? view.slug}${view.file ? ` · ${view.file}` : ""}` }
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** One reference as an HTML string, for hosts without React. Every text is escaped. */
|
|
323
|
+
export function renderReferenceHtml(view: ReferenceView, text?: string): string {
|
|
324
|
+
const p = referenceParts(view, text)
|
|
325
|
+
const cls = referenceClasses(view)
|
|
326
|
+
const data = `data-miadi-uri="${escapeHtml(view.uri)}" data-miadi-resolved="${view.kind === "unresolved" ? "false" : "true"}"`
|
|
327
|
+
if (view.kind === "unresolved") {
|
|
328
|
+
return `<span class="${cls}" title="${escapeHtml(p.tooltip)}" ${data}><span class="mr-ref__badge">${escapeHtml(p.badge)}</span> <span class="mr-ref__label">${escapeHtml(p.label)}</span> <span class="mr-ref__note">${escapeHtml(p.note!)}</span></span>`
|
|
329
|
+
}
|
|
330
|
+
const head = `<a class="${cls}" href="${escapeHtml(view.href)}" title="${escapeHtml(p.tooltip)}" ${data}>`
|
|
331
|
+
if (view.show === "card" && view.kind !== "wheel") {
|
|
332
|
+
return `${head}<span class="mr-ref__badge">${escapeHtml(p.badge)}</span><span class="mr-ref__title">${escapeHtml(p.title!)}</span>${p.meta ? `<span class="mr-ref__meta">${escapeHtml(p.meta)}</span>` : ""}</a>`
|
|
333
|
+
}
|
|
334
|
+
return `${head}<span class="mr-ref__badge">${escapeHtml(p.badge)}</span> <span class="mr-ref__label">${escapeHtml(p.label)}</span></a>`
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ── Resolution over HTTP ─────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
/** The answer of `GET <front>/api/chronicle/resolve?uri=…`, the fields this package reads. */
|
|
340
|
+
export interface ResolveAnswer {
|
|
341
|
+
ok: boolean
|
|
342
|
+
uri?: string
|
|
343
|
+
resolution?: ChronicleResolution
|
|
344
|
+
foundation?: FoundationInfo
|
|
345
|
+
/** For `<ep>/foundations/<topic>`: the episode's own packet. */
|
|
346
|
+
episodeFoundation?: { slug: string; title?: string; exists: boolean }
|
|
347
|
+
wheelRef?: { kind: "ceremony" | "circle"; id: string; route: string }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Answers one reference URI, or null when the front could not be asked. */
|
|
351
|
+
export type ReferenceResolver = (uri: string) => Promise<ResolveAnswer | null>
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* A resolver over a Miadi front's public resolve door. The front is an
|
|
355
|
+
* argument, never a default: `createRemoteResolver({ origin: process.env.MIADI_PUBLIC_URL })`.
|
|
356
|
+
* Only that origin is asked. A failure answers null, which renders as an
|
|
357
|
+
* unresolved chip: a broken reference is data, never a page error.
|
|
358
|
+
*/
|
|
359
|
+
export function createRemoteResolver(options: {
|
|
360
|
+
origin: string
|
|
361
|
+
fetch?: typeof fetch
|
|
362
|
+
timeoutMs?: number
|
|
363
|
+
/** Extra fetch options, e.g. `{ next: { revalidate: 300 } }` on Next.js. */
|
|
364
|
+
init?: RequestInit & Record<string, unknown>
|
|
365
|
+
}): ReferenceResolver {
|
|
366
|
+
const origin = options.origin.replace(/\/+$/, "")
|
|
367
|
+
const doFetch = options.fetch ?? fetch
|
|
368
|
+
return async (uri) => {
|
|
369
|
+
const controller = new AbortController()
|
|
370
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 8_000)
|
|
371
|
+
try {
|
|
372
|
+
// A Headers instance or a tuple list survives the merge (#713 review, F11).
|
|
373
|
+
const headers = new Headers(options.init?.headers)
|
|
374
|
+
headers.set("accept", "application/json")
|
|
375
|
+
const response = await doFetch(`${origin}/api/chronicle/resolve?uri=${encodeURIComponent(uri)}`, {
|
|
376
|
+
...options.init,
|
|
377
|
+
signal: controller.signal,
|
|
378
|
+
headers,
|
|
379
|
+
})
|
|
380
|
+
if (!response.ok) return null
|
|
381
|
+
const body: unknown = await response.json()
|
|
382
|
+
return body && typeof body === "object" ? (body as ResolveAnswer) : null
|
|
383
|
+
} catch {
|
|
384
|
+
return null
|
|
385
|
+
} finally {
|
|
386
|
+
clearTimeout(timer)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
|
|
392
|
+
|
|
393
|
+
/** A resolution as far as this package reads it, or undefined (#713 review, F5). */
|
|
394
|
+
function resolutionOf(value: unknown): ChronicleResolution | undefined {
|
|
395
|
+
if (!isObject(value) || !isObject(value.target) || typeof value.target.resolved !== "boolean") return undefined
|
|
396
|
+
return {
|
|
397
|
+
resolved: value.resolved === true,
|
|
398
|
+
target: value.target as unknown as ChronicleResolution["target"],
|
|
399
|
+
waypoints: Array.isArray(value.waypoints) ? (value.waypoints.filter(isObject) as unknown as ChronicleResolution["waypoints"]) : [],
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The view a resolver's answer makes for one URI. The answer is data from the
|
|
405
|
+
* network: a field of the wrong shape makes the reference unresolved, never a
|
|
406
|
+
* thrown page. `base` makes relative routes absolute.
|
|
407
|
+
*/
|
|
408
|
+
export function viewFromAnswer(uri: string, answer: ResolveAnswer | null, options: { base?: string } = {}): ReferenceView | null {
|
|
409
|
+
const a: Record<string, unknown> = isObject(answer) ? answer : {}
|
|
410
|
+
const foundation = parseFoundationUri(uri)
|
|
411
|
+
if (foundation) {
|
|
412
|
+
const info = isObject(a.foundation) && a.foundation.slug === foundation.slug ? (a.foundation as unknown as FoundationInfo) : null
|
|
413
|
+
return foundationReferenceView(foundation, info, options)
|
|
414
|
+
}
|
|
415
|
+
const wheel = parseWheelRefUri(uri)
|
|
416
|
+
if (wheel) {
|
|
417
|
+
const route = isObject(a.wheelRef) ? a.wheelRef.route : undefined
|
|
418
|
+
return typeof route === "string"
|
|
419
|
+
? wheelReferenceView(wheel, route, options)
|
|
420
|
+
: unresolvedView(uri, wheel.kind, `Unresolved ${wheel.kind} reference: ${uri}`, wheel.options, wheel.kind)
|
|
421
|
+
}
|
|
422
|
+
const chronicle = parseMiadiChronicleUri(uri)
|
|
423
|
+
if (!chronicle) return null
|
|
424
|
+
const resolution = resolutionOf(a.resolution)
|
|
425
|
+
if (!resolution) return unresolvedView(uri, "chronicle", `Unresolved chronicle reference: ${uri}`, chronicle.options, chronicle.episodePath)
|
|
426
|
+
if (chronicle.artifact === "foundations" && chronicle.artifactName) {
|
|
427
|
+
const own = isObject(a.episodeFoundation) ? a.episodeFoundation : {}
|
|
428
|
+
return episodeFoundationView(
|
|
429
|
+
chronicle,
|
|
430
|
+
resolution,
|
|
431
|
+
{ exists: own.exists === true, ...(typeof own.title === "string" ? { title: own.title } : {}) },
|
|
432
|
+
options,
|
|
433
|
+
)
|
|
434
|
+
}
|
|
435
|
+
return episodeReferenceView(chronicle, resolution, options)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const ENTITIES: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" }
|
|
439
|
+
|
|
440
|
+
function safeDecode(s: string): string {
|
|
441
|
+
try {
|
|
442
|
+
return decodeURIComponent(s)
|
|
443
|
+
} catch {
|
|
444
|
+
return s
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* One key for every spelling of the same reference: the form written in the
|
|
450
|
+
* markdown source and the href a markdown parser hands the link component
|
|
451
|
+
* (backslash escapes and character references processed, destinations
|
|
452
|
+
* percent-encoded, `//` dropped, a wheel id in canonical form) all map to it.
|
|
453
|
+
* `prepareReferenceViews` keys its views by it and `lookupView` asks by it,
|
|
454
|
+
* so a view is never missed for a spelling (jgwill/Miadi#713 review, F6).
|
|
455
|
+
*/
|
|
456
|
+
export function referenceKey(href: string): string {
|
|
457
|
+
const text = href
|
|
458
|
+
.trim()
|
|
459
|
+
.replace(/\\([!-/:-@[-`{-~])/g, "$1")
|
|
460
|
+
.replace(/&(#x[0-9a-f]{1,6}|#[0-9]{1,7}|[a-z]{2,6});/gi, (whole, name: string) => {
|
|
461
|
+
if (name[0] === "#") {
|
|
462
|
+
const code = name[1] === "x" || name[1] === "X" ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10)
|
|
463
|
+
return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole
|
|
464
|
+
}
|
|
465
|
+
return ENTITIES[name.toLowerCase()] ?? whole
|
|
466
|
+
})
|
|
467
|
+
const params = (query: Record<string, string>, options: Record<string, string>) =>
|
|
468
|
+
[...Object.entries(query).map(([k, v]) => `${k}=${v}`), ...Object.entries(options).map(([k, v]) => `mc.${k}=${v}`)].sort().join("&")
|
|
469
|
+
const foundation = parseFoundationUri(text)
|
|
470
|
+
if (foundation) {
|
|
471
|
+
return `foundation|${foundation.slug}|${foundation.file ?? ""}|${params(foundation.query, foundation.options)}|${safeDecode(foundation.fragment ?? "")}`
|
|
472
|
+
}
|
|
473
|
+
const wheel = parseWheelRefUri(text)
|
|
474
|
+
if (wheel) return `${wheel.kind}|${wheel.id}|${params(wheel.query, wheel.options)}|${safeDecode(wheel.fragment ?? "")}`
|
|
475
|
+
const chronicle = parseMiadiChronicleUri(text)
|
|
476
|
+
if (chronicle) {
|
|
477
|
+
return `chronicle|${chronicle.episodePath}|${params(chronicle.query, chronicle.options)}|${safeDecode(chronicle.fragment ?? "")}`
|
|
478
|
+
}
|
|
479
|
+
return safeDecode(text)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export interface PreparedReferenceViews {
|
|
483
|
+
/** The markdown to render: directives folded, bare mentions linked when asked. */
|
|
484
|
+
markdown: string
|
|
485
|
+
/** One view per reference, keyed by `referenceKey`. Ask it with `lookupView`. */
|
|
486
|
+
views: Map<string, ReferenceView>
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** References past this many on one page render unresolved, so one text cannot make a server ask a front without bound. */
|
|
490
|
+
export const DEFAULT_MAX_REFERENCES = 100
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Prepare a markdown text for rendering: fold `{{ … }}` directives into
|
|
494
|
+
* links, optionally link bare mentions, then resolve every distinct reference
|
|
495
|
+
* once (at most `concurrency` at a time, at most `maxReferences` in all) and
|
|
496
|
+
* build its view. A renderer then finds each link's view with `lookupView`,
|
|
497
|
+
* synchronously.
|
|
498
|
+
*/
|
|
499
|
+
export async function prepareReferenceViews(
|
|
500
|
+
markdown: string,
|
|
501
|
+
resolver: ReferenceResolver,
|
|
502
|
+
options: { base?: string; linkBare?: boolean; concurrency?: number; maxReferences?: number } = {},
|
|
503
|
+
): Promise<PreparedReferenceViews> {
|
|
504
|
+
let text = transformDirectives(markdown)
|
|
505
|
+
if (options.linkBare) text = linkBareReferences(text)
|
|
506
|
+
const byKey = new Map<string, string>()
|
|
507
|
+
for (const ref of findTextReferences(text)) {
|
|
508
|
+
if (ref.form === "bare") continue
|
|
509
|
+
const key = referenceKey(ref.uri)
|
|
510
|
+
if (!byKey.has(key)) byKey.set(key, ref.uri)
|
|
511
|
+
}
|
|
512
|
+
const views = new Map<string, ReferenceView>()
|
|
513
|
+
const limit = options.maxReferences ?? DEFAULT_MAX_REFERENCES
|
|
514
|
+
const entries = [...byKey.entries()]
|
|
515
|
+
for (const [key, uri] of entries.slice(limit)) {
|
|
516
|
+
const view = viewFromAnswer(uri, null, { base: options.base })
|
|
517
|
+
if (view) views.set(key, { ...view, ...(view.kind === "unresolved" ? { reason: `Not resolved: more than ${limit} references on this page` } : {}) })
|
|
518
|
+
}
|
|
519
|
+
const asked = entries.slice(0, limit)
|
|
520
|
+
let next = 0
|
|
521
|
+
const worker = async () => {
|
|
522
|
+
while (next < asked.length) {
|
|
523
|
+
const [key, uri] = asked[next++]!
|
|
524
|
+
let answer: ResolveAnswer | null = null
|
|
525
|
+
try {
|
|
526
|
+
answer = await resolver(uri)
|
|
527
|
+
} catch {
|
|
528
|
+
answer = null
|
|
529
|
+
}
|
|
530
|
+
const view = viewFromAnswer(uri, answer, { base: options.base })
|
|
531
|
+
if (view) views.set(key, view)
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
await Promise.all(Array.from({ length: Math.min(options.concurrency ?? 6, asked.length) }, worker))
|
|
535
|
+
return { markdown: text, views }
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export { transformDirectives, linkBareReferences, isMiadiChronicleUri, isFoundationUri, isWheelRefUri }
|
package/src/react.tsx
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @miadi/reference-ui/react — the React host of the reference views. The
|
|
3
|
+
* markup is `renderReferenceHtml`'s, element for element (a parity test holds
|
|
4
|
+
* it there), so a page rendered on the server by React and a page rendered as
|
|
5
|
+
* a string look the same.
|
|
6
|
+
*
|
|
7
|
+
* The link element is the host's: pass `LinkComponent` (Next's `Link`) to keep
|
|
8
|
+
* client navigation; the default is a plain `<a>`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ComponentType, ReactNode } from "react"
|
|
12
|
+
import {
|
|
13
|
+
referenceClasses,
|
|
14
|
+
referenceKey,
|
|
15
|
+
referenceParts,
|
|
16
|
+
type ReferenceView,
|
|
17
|
+
} from "./index.js"
|
|
18
|
+
|
|
19
|
+
export type LinkLike = ComponentType<{
|
|
20
|
+
href: string
|
|
21
|
+
className?: string
|
|
22
|
+
title?: string
|
|
23
|
+
children?: ReactNode
|
|
24
|
+
[data: `data-${string}`]: string | undefined
|
|
25
|
+
}>
|
|
26
|
+
|
|
27
|
+
export interface ReferenceProps {
|
|
28
|
+
view: ReferenceView
|
|
29
|
+
/** The link text the author wrote, used when the reference carries no label. */
|
|
30
|
+
children?: ReactNode
|
|
31
|
+
LinkComponent?: LinkLike
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function textOf(children: ReactNode): string | undefined {
|
|
35
|
+
if (typeof children === "string") return children
|
|
36
|
+
if (Array.isArray(children) && children.every((c) => typeof c === "string")) return children.join("")
|
|
37
|
+
return undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One reference: a chip, a card, a wheel page link, or the unresolved chip. */
|
|
41
|
+
export function Reference({ view, children, LinkComponent }: ReferenceProps) {
|
|
42
|
+
const text = textOf(children)
|
|
43
|
+
const p = referenceParts(view, text)
|
|
44
|
+
// A label that is not plain text (an emphasised link text) is shown as the author wrote it.
|
|
45
|
+
const label: ReactNode = view.label || text !== undefined || children === undefined || children === null ? p.label : children
|
|
46
|
+
const className = referenceClasses(view)
|
|
47
|
+
const data = { "data-miadi-uri": view.uri, "data-miadi-resolved": view.kind === "unresolved" ? "false" : "true" }
|
|
48
|
+
|
|
49
|
+
if (view.kind === "unresolved") {
|
|
50
|
+
return (
|
|
51
|
+
<span className={className} title={p.tooltip} {...data}>
|
|
52
|
+
<span className="mr-ref__badge">{p.badge}</span> <span className="mr-ref__label">{label}</span>{" "}
|
|
53
|
+
<span className="mr-ref__note">{p.note}</span>
|
|
54
|
+
</span>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
const Link: LinkLike = LinkComponent ?? ((props) => <a {...props} />)
|
|
58
|
+
if (view.show === "card" && view.kind !== "wheel") {
|
|
59
|
+
return (
|
|
60
|
+
<Link className={className} href={view.href} title={p.tooltip} {...data}>
|
|
61
|
+
<span className="mr-ref__badge">{p.badge}</span>
|
|
62
|
+
<span className="mr-ref__title">{p.title}</span>
|
|
63
|
+
{p.meta ? <span className="mr-ref__meta">{p.meta}</span> : null}
|
|
64
|
+
</Link>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
return (
|
|
68
|
+
<Link className={className} href={view.href} title={p.tooltip} {...data}>
|
|
69
|
+
<span className="mr-ref__badge">{p.badge}</span> <span className="mr-ref__label">{label}</span>
|
|
70
|
+
</Link>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Find the view for an href as a markdown parser hands it over. `views` is
|
|
76
|
+
* what `prepareReferenceViews` returned, or the same entries as a plain
|
|
77
|
+
* object after crossing from a server component to a client one.
|
|
78
|
+
*/
|
|
79
|
+
export function lookupView(
|
|
80
|
+
views: ReadonlyMap<string, ReferenceView> | Readonly<Record<string, ReferenceView>>,
|
|
81
|
+
href: string | undefined,
|
|
82
|
+
): ReferenceView | undefined {
|
|
83
|
+
if (!href) return undefined
|
|
84
|
+
const key = referenceKey(href)
|
|
85
|
+
return views instanceof Map ? views.get(key) : (views as Record<string, ReferenceView>)[key]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A link override for react-markdown (`components={{ a }}`): a reference href
|
|
90
|
+
* with a prepared view renders as a Reference; every other link renders as the
|
|
91
|
+
* plain anchor it was, its attributes intact.
|
|
92
|
+
*/
|
|
93
|
+
export function createReferenceLink(
|
|
94
|
+
views: ReadonlyMap<string, ReferenceView> | Readonly<Record<string, ReferenceView>>,
|
|
95
|
+
options: { LinkComponent?: LinkLike; externalLinksInNewTab?: boolean } = {},
|
|
96
|
+
) {
|
|
97
|
+
return function ReferenceLink(props: { href?: string; children?: ReactNode; node?: unknown; [key: string]: unknown }) {
|
|
98
|
+
const { node: _node, ...rest } = props
|
|
99
|
+
const view = lookupView(views, props.href)
|
|
100
|
+
if (view) return <Reference view={view} LinkComponent={options.LinkComponent}>{props.children}</Reference>
|
|
101
|
+
if (options.externalLinksInNewTab && typeof props.href === "string" && /^https?:\/\//.test(props.href)) {
|
|
102
|
+
return <a {...rest} target="_blank" rel="noreferrer" />
|
|
103
|
+
}
|
|
104
|
+
return <a {...rest} />
|
|
105
|
+
}
|
|
106
|
+
}
|