@miadi/reference-ui 0.0.0-stage → 0.1.1

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/src/index.ts ADDED
@@ -0,0 +1,531 @@
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; hasReadme?: 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, and
231
+ // only when the packet has a README to open (#713, V9).
232
+ href: info.hasReadme === false ? episode.href : withQuery(episode.href, `file=${encodeURIComponent(`foundations/${topic}/README.md`)}`),
233
+ slug: topic,
234
+ ...(title ? { title } : {}),
235
+ fields: [],
236
+ episode: episode.badge,
237
+ }
238
+ }
239
+
240
+ export function wheelReferenceView(parsed: ParsedWheelRef, route: string, options: { base?: string } = {}): WheelReferenceView | UnresolvedReferenceView {
241
+ const href = safeHref(route, options.base)
242
+ if (!href) return unresolvedView(parsed.uri, parsed.kind, `Unresolved ${parsed.kind} reference: ${parsed.uri}`, parsed.options, parsed.kind)
243
+ return {
244
+ kind: "wheel",
245
+ wheel: parsed.kind,
246
+ id: parsed.id,
247
+ href,
248
+ ...base(parsed.uri, parsed.options, parsed.kind),
249
+ }
250
+ }
251
+
252
+ /** Put a query before the fragment of an href. */
253
+ export function withQuery(href: string, query: string): string {
254
+ const hash = href.indexOf("#")
255
+ const head = hash === -1 ? href : href.slice(0, hash)
256
+ const tail = hash === -1 ? "" : href.slice(hash)
257
+ return `${head}${head.includes("?") ? "&" : "?"}${query}${tail}`
258
+ }
259
+
260
+ // ── HTML ─────────────────────────────────────────────────────────────────────
261
+
262
+ const ESCAPES: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }
263
+ export function escapeHtml(text: string): string {
264
+ return text.replace(/[&<>"']/g, (c) => ESCAPES[c]!)
265
+ }
266
+
267
+ const DIRECTIONS = new Set(["east", "south", "west", "north"])
268
+
269
+ /** Class names, shared by the HTML renderer and the React components so the markup is one. */
270
+ export function referenceClasses(view: ReferenceView): string {
271
+ if (view.kind === "unresolved") return `mr-ref mr-ref--chip mr-ref--unresolved not-prose`
272
+ const dir = view.kind === "episode" && view.direction && DIRECTIONS.has(view.direction) ? ` mr-dir-${view.direction}` : ""
273
+ const highlight = view.highlight ? " mr-ref--highlight" : ""
274
+ return `mr-ref mr-ref--${view.show} mr-ref--${view.kind}${dir}${highlight} not-prose`
275
+ }
276
+
277
+ /** The text pieces a view shows, in order. Both renderers place these and nothing else. */
278
+ export interface ReferenceParts {
279
+ badge: string
280
+ label: string
281
+ title?: string
282
+ meta?: string
283
+ note?: string
284
+ tooltip: string
285
+ }
286
+
287
+ /**
288
+ * What a view shows. `text` is the link text a markdown author wrote
289
+ * (`[text](uri)`), which outranks the fallback but not an explicit `mc.label`.
290
+ */
291
+ export function referenceParts(view: ReferenceView, text?: string): ReferenceParts {
292
+ // A bare mention linked to itself has no words of its own (#713 review, F10).
293
+ const words = text && !isReferenceUri(text.trim()) ? text : undefined
294
+ const label = view.label || words || view.fallback
295
+ switch (view.kind) {
296
+ case "unresolved":
297
+ return { badge: view.scheme === "foundation" ? "◇" : "⟐", label, note: "(unresolved)", tooltip: view.reason }
298
+ case "wheel":
299
+ return { badge: view.wheel === "ceremony" ? "◆" : "○", label, tooltip: `${view.wheel} ${view.id}: its page asks for your seat` }
300
+ case "episode": {
301
+ const meta = [
302
+ view.label && view.title && view.label !== view.title ? view.title : "",
303
+ view.date ?? "",
304
+ view.theme ?? "",
305
+ view.via.length > 0 ? `via ${view.via.join(" → ")}` : "",
306
+ ].filter(Boolean).join(" · ")
307
+ return view.show === "card"
308
+ ? { badge: `⟐ ${view.badge}${view.direction ? ` · ${view.direction}` : ""}`, label, title: view.label || view.title || label, meta, tooltip: view.title ?? view.name }
309
+ : { badge: `⟐ ${view.badge}`, label, tooltip: `${view.title ?? view.name}${view.date ? ` (${view.date})` : ""}` }
310
+ }
311
+ case "foundation": {
312
+ const kind = view.episode ? `foundation · ${view.episode}` : "foundation"
313
+ // A package number reads `v0.1.6`; a source commit reads `at a9cf5d29b4c9`.
314
+ const version = view.version ? (/^\d+\.\d+/.test(view.version) ? `v${view.version}` : `at ${view.version}`) : ""
315
+ const meta = [view.file ?? "", view.fields.slice(0, 3).join(" · "), version].filter(Boolean).join(" · ")
316
+ return view.show === "card"
317
+ ? { badge: `◇ ${kind}`, label, title: view.label || view.title || label, meta, tooltip: view.title ?? view.slug }
318
+ : { badge: `◇ ${kind}`, label, tooltip: `${view.title ?? view.slug}${view.file ? ` · ${view.file}` : ""}` }
319
+ }
320
+ }
321
+ }
322
+
323
+ /** One reference as an HTML string, for hosts without React. Every text is escaped. */
324
+ export function renderReferenceHtml(view: ReferenceView, text?: string): string {
325
+ const p = referenceParts(view, text)
326
+ const cls = referenceClasses(view)
327
+ const data = `data-miadi-uri="${escapeHtml(view.uri)}" data-miadi-resolved="${view.kind === "unresolved" ? "false" : "true"}"`
328
+ if (view.kind === "unresolved") {
329
+ 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>`
330
+ }
331
+ const head = `<a class="${cls}" href="${escapeHtml(view.href)}" title="${escapeHtml(p.tooltip)}" ${data}>`
332
+ if (view.show === "card" && view.kind !== "wheel") {
333
+ 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>`
334
+ }
335
+ return `${head}<span class="mr-ref__badge">${escapeHtml(p.badge)}</span> <span class="mr-ref__label">${escapeHtml(p.label)}</span></a>`
336
+ }
337
+
338
+ // ── Resolution over HTTP ─────────────────────────────────────────────────────
339
+
340
+ /** The answer of `GET <front>/api/chronicle/resolve?uri=…`, the fields this package reads. */
341
+ export interface ResolveAnswer {
342
+ ok: boolean
343
+ uri?: string
344
+ resolution?: ChronicleResolution
345
+ foundation?: FoundationInfo
346
+ /** For `<ep>/foundations/<topic>`: the episode's own packet. */
347
+ episodeFoundation?: { slug: string; title?: string; exists: boolean; hasReadme?: boolean }
348
+ wheelRef?: { kind: "ceremony" | "circle"; id: string; route: string }
349
+ }
350
+
351
+ /** Answers one reference URI, or null when the front could not be asked. */
352
+ export type ReferenceResolver = (uri: string) => Promise<ResolveAnswer | null>
353
+
354
+ /**
355
+ * A resolver over a Miadi front's public resolve door. The front is an
356
+ * argument, never a default: `createRemoteResolver({ origin: process.env.MIADI_PUBLIC_URL })`.
357
+ * Only that origin is asked. A failure answers null, which renders as an
358
+ * unresolved chip: a broken reference is data, never a page error.
359
+ */
360
+ export function createRemoteResolver(options: {
361
+ origin: string
362
+ fetch?: typeof fetch
363
+ timeoutMs?: number
364
+ /** Extra fetch options, e.g. `{ next: { revalidate: 300 } }` on Next.js. */
365
+ init?: RequestInit & Record<string, unknown>
366
+ }): ReferenceResolver {
367
+ const origin = options.origin.replace(/\/+$/, "")
368
+ const doFetch = options.fetch ?? fetch
369
+ return async (uri) => {
370
+ const controller = new AbortController()
371
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 8_000)
372
+ try {
373
+ // A Headers instance or a tuple list survives the merge (#713 review, F11).
374
+ const headers = new Headers(options.init?.headers)
375
+ headers.set("accept", "application/json")
376
+ const response = await doFetch(`${origin}/api/chronicle/resolve?uri=${encodeURIComponent(uri)}`, {
377
+ ...options.init,
378
+ signal: controller.signal,
379
+ headers,
380
+ })
381
+ if (!response.ok) return null
382
+ const body: unknown = await response.json()
383
+ return body && typeof body === "object" ? (body as ResolveAnswer) : null
384
+ } catch {
385
+ return null
386
+ } finally {
387
+ clearTimeout(timer)
388
+ }
389
+ }
390
+ }
391
+
392
+ const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
393
+
394
+ /** A resolution as far as this package reads it, or undefined (#713 review, F5). */
395
+ function resolutionOf(value: unknown): ChronicleResolution | undefined {
396
+ if (!isObject(value) || !isObject(value.target) || typeof value.target.resolved !== "boolean") return undefined
397
+ return {
398
+ resolved: value.resolved === true,
399
+ target: value.target as unknown as ChronicleResolution["target"],
400
+ waypoints: Array.isArray(value.waypoints) ? (value.waypoints.filter(isObject) as unknown as ChronicleResolution["waypoints"]) : [],
401
+ }
402
+ }
403
+
404
+ /**
405
+ * The view a resolver's answer makes for one URI. The answer is data from the
406
+ * network: a field of the wrong shape makes the reference unresolved, never a
407
+ * thrown page. `base` makes relative routes absolute.
408
+ */
409
+ export function viewFromAnswer(uri: string, answer: ResolveAnswer | null, options: { base?: string } = {}): ReferenceView | null {
410
+ const a: Record<string, unknown> = isObject(answer) ? answer : {}
411
+ const foundation = parseFoundationUri(uri)
412
+ if (foundation) {
413
+ const info = isObject(a.foundation) && a.foundation.slug === foundation.slug ? (a.foundation as unknown as FoundationInfo) : null
414
+ return foundationReferenceView(foundation, info, options)
415
+ }
416
+ const wheel = parseWheelRefUri(uri)
417
+ if (wheel) {
418
+ const route = isObject(a.wheelRef) ? a.wheelRef.route : undefined
419
+ return typeof route === "string"
420
+ ? wheelReferenceView(wheel, route, options)
421
+ : unresolvedView(uri, wheel.kind, `Unresolved ${wheel.kind} reference: ${uri}`, wheel.options, wheel.kind)
422
+ }
423
+ const chronicle = parseMiadiChronicleUri(uri)
424
+ if (!chronicle) return null
425
+ const resolution = resolutionOf(a.resolution)
426
+ if (!resolution) return unresolvedView(uri, "chronicle", `Unresolved chronicle reference: ${uri}`, chronicle.options, chronicle.episodePath)
427
+ if (chronicle.artifact === "foundations" && chronicle.artifactName) {
428
+ const own = isObject(a.episodeFoundation) ? a.episodeFoundation : {}
429
+ return episodeFoundationView(
430
+ chronicle,
431
+ resolution,
432
+ { exists: own.exists === true, hasReadme: own.hasReadme !== false, ...(typeof own.title === "string" ? { title: own.title } : {}) },
433
+ options,
434
+ )
435
+ }
436
+ return episodeReferenceView(chronicle, resolution, options)
437
+ }
438
+
439
+ function safeDecode(s: string): string {
440
+ try {
441
+ return decodeURIComponent(s)
442
+ } catch {
443
+ return s
444
+ }
445
+ }
446
+
447
+ /**
448
+ * One key for every spelling of the same reference: the destination the
449
+ * scanner reports (`TextReference.href`, escapes already processed) and the
450
+ * href a markdown parser hands the link component (percent-encoded, `//`
451
+ * kept or dropped) map to it. The parts are kept apart as a JSON tuple, so
452
+ * two different references never share a key: joining them with `|` or `&`
453
+ * let `mc.label=a%26mc.show%3Dcard` collide with `mc.label=a&mc.show=card`
454
+ * (jgwill/Miadi#713, V6).
455
+ */
456
+ export function referenceKey(href: string): string {
457
+ const text = href.trim()
458
+ const pairs = (query: Record<string, string>) => Object.entries(query).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
459
+ const foundation = parseFoundationUri(text)
460
+ if (foundation) {
461
+ return JSON.stringify(["foundation", foundation.slug, foundation.file ?? "", pairs(foundation.query), pairs(foundation.options), safeDecode(foundation.fragment ?? "")])
462
+ }
463
+ const wheel = parseWheelRefUri(text)
464
+ if (wheel) return JSON.stringify([wheel.kind, wheel.id, pairs(wheel.query), pairs(wheel.options), safeDecode(wheel.fragment ?? "")])
465
+ const chronicle = parseMiadiChronicleUri(text)
466
+ if (chronicle) {
467
+ return JSON.stringify(["chronicle", chronicle.episodePath, pairs(chronicle.query), pairs(chronicle.options), safeDecode(chronicle.fragment ?? "")])
468
+ }
469
+ return JSON.stringify(["other", safeDecode(text)])
470
+ }
471
+
472
+ export interface PreparedReferenceViews {
473
+ /** The markdown to render: directives folded, bare mentions linked when asked. */
474
+ markdown: string
475
+ /** One view per reference, keyed by `referenceKey`. Ask it with `lookupView`. */
476
+ views: Map<string, ReferenceView>
477
+ }
478
+
479
+ /** References past this many on one page render unresolved, so one text cannot make a server ask a front without bound. */
480
+ export const DEFAULT_MAX_REFERENCES = 100
481
+
482
+ /**
483
+ * Prepare a markdown text for rendering: fold `{{ … }}` directives into
484
+ * links, optionally link bare mentions, then resolve every distinct reference
485
+ * once (at most `concurrency` at a time, at most `maxReferences` in all) and
486
+ * build its view. A renderer then finds each link's view with `lookupView`,
487
+ * synchronously.
488
+ */
489
+ export async function prepareReferenceViews(
490
+ markdown: string,
491
+ resolver: ReferenceResolver,
492
+ options: { base?: string; linkBare?: boolean; concurrency?: number; maxReferences?: number } = {},
493
+ ): Promise<PreparedReferenceViews> {
494
+ let text = transformDirectives(markdown)
495
+ if (options.linkBare) text = linkBareReferences(text)
496
+ // Keyed and resolved by the destination as written: a wheel reference's
497
+ // `uri` is canonical and drops its options and fragment (#713, V3).
498
+ const byKey = new Map<string, string>()
499
+ for (const ref of findTextReferences(text)) {
500
+ if (ref.form === "bare" || ref.form === "directive") continue
501
+ const href = ref.href ?? ref.uri
502
+ const key = referenceKey(href)
503
+ if (!byKey.has(key)) byKey.set(key, href)
504
+ }
505
+ const views = new Map<string, ReferenceView>()
506
+ const limit = options.maxReferences ?? DEFAULT_MAX_REFERENCES
507
+ const entries = [...byKey.entries()]
508
+ for (const [key, uri] of entries.slice(limit)) {
509
+ const view = viewFromAnswer(uri, null, { base: options.base })
510
+ if (view) views.set(key, { ...view, ...(view.kind === "unresolved" ? { reason: `Not resolved: more than ${limit} references on this page` } : {}) })
511
+ }
512
+ const asked = entries.slice(0, limit)
513
+ let next = 0
514
+ const worker = async () => {
515
+ while (next < asked.length) {
516
+ const [key, uri] = asked[next++]!
517
+ let answer: ResolveAnswer | null = null
518
+ try {
519
+ answer = await resolver(uri)
520
+ } catch {
521
+ answer = null
522
+ }
523
+ const view = viewFromAnswer(uri, answer, { base: options.base })
524
+ if (view) views.set(key, view)
525
+ }
526
+ }
527
+ await Promise.all(Array.from({ length: Math.min(options.concurrency ?? 6, asked.length) }, worker))
528
+ return { markdown: text, views }
529
+ }
530
+
531
+ export { transformDirectives, linkBareReferences, isMiadiChronicleUri, isFoundationUri, isWheelRefUri }
package/src/react.tsx ADDED
@@ -0,0 +1,108 @@
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
+ if (views instanceof Map) return views.get(key)
86
+ // Own keys only: `constructor` or `__proto__` read from a plain object is not a view (#713, V1).
87
+ return Object.hasOwn(views, key) ? (views as Record<string, ReferenceView>)[key] : undefined
88
+ }
89
+
90
+ /**
91
+ * A link override for react-markdown (`components={{ a }}`): a reference href
92
+ * with a prepared view renders as a Reference; every other link renders as the
93
+ * plain anchor it was, its attributes intact.
94
+ */
95
+ export function createReferenceLink(
96
+ views: ReadonlyMap<string, ReferenceView> | Readonly<Record<string, ReferenceView>>,
97
+ options: { LinkComponent?: LinkLike; externalLinksInNewTab?: boolean } = {},
98
+ ) {
99
+ return function ReferenceLink(props: { href?: string; children?: ReactNode; node?: unknown; [key: string]: unknown }) {
100
+ const { node: _node, ...rest } = props
101
+ const view = lookupView(views, props.href)
102
+ if (view) return <Reference view={view} LinkComponent={options.LinkComponent}>{props.children}</Reference>
103
+ if (options.externalLinksInNewTab && typeof props.href === "string" && /^https?:\/\//.test(props.href)) {
104
+ return <a {...rest} target="_blank" rel="noreferrer" />
105
+ }
106
+ return <a {...rest} />
107
+ }
108
+ }