@elabs-ai/components-editor 4.0.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 +149 -0
- package/dist/chunk-LBC5VJBD.js +3748 -0
- package/dist/chunk-LBC5VJBD.js.map +1 -0
- package/dist/index.css +233 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +199 -0
- package/dist/index.js +362 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/monaco-environment.d.ts +2 -0
- package/dist/lib/monaco-environment.js +44 -0
- package/dist/lib/monaco-environment.js.map +1 -0
- package/dist/markdown/frontmatter.d.ts +11 -0
- package/dist/markdown/frontmatter.js +33 -0
- package/dist/markdown/frontmatter.js.map +1 -0
- package/dist/markdown/index.css +233 -0
- package/dist/markdown/index.css.map +1 -0
- package/dist/markdown/index.d.ts +1486 -0
- package/dist/markdown/index.js +4882 -0
- package/dist/markdown/index.js.map +1 -0
- package/dist/markdown/parse.d.ts +25 -0
- package/dist/markdown/parse.js +14 -0
- package/dist/markdown/parse.js.map +1 -0
- package/dist/markdown-editor-DfBZibAn.d.ts +702 -0
- package/package.json +101 -0
- package/src/ai-objects/ai-objects-integration.test.tsx +128 -0
- package/src/ai-objects/ai-objects.stories.tsx +233 -0
- package/src/ai-objects/ai-objects.test.tsx +205 -0
- package/src/ai-objects/decision-card.tsx +178 -0
- package/src/ai-objects/directives.ts +136 -0
- package/src/ai-objects/entity.tsx +181 -0
- package/src/ai-objects/index.ts +34 -0
- package/src/ai-objects/knowledge-card.tsx +143 -0
- package/src/calc-block/calc-block.stories.tsx +297 -0
- package/src/calc-block/calc-block.test.tsx +164 -0
- package/src/calc-block/calc-block.tsx +398 -0
- package/src/calc-block/calc-editor-monaco.ts +214 -0
- package/src/calc-block/calc-editor-prose.ts +128 -0
- package/src/calc-block/calc-editor.css +80 -0
- package/src/calc-block/calc-editor.stories.tsx +272 -0
- package/src/calc-block/calc-editor.test.ts +228 -0
- package/src/calc-block/calc-editor.ts +270 -0
- package/src/calc-block/calc-inline.stories.tsx +44 -0
- package/src/calc-block/calc-inline.test.tsx +44 -0
- package/src/calc-block/calc-inline.tsx +75 -0
- package/src/calc-block/index.ts +20 -0
- package/src/calc-block/types.ts +164 -0
- package/src/code-editor/code-editor.stories.tsx +218 -0
- package/src/code-editor/code-editor.test.tsx +134 -0
- package/src/code-editor/code-editor.tsx +250 -0
- package/src/code-editor/index.ts +6 -0
- package/src/code-workspace/code-workspace.stories.tsx +51 -0
- package/src/code-workspace/code-workspace.test.tsx +146 -0
- package/src/code-workspace/code-workspace.tsx +253 -0
- package/src/code-workspace/index.ts +6 -0
- package/src/copy-button/copy-button.tsx +51 -0
- package/src/copy-button/index.ts +1 -0
- package/src/css.d.ts +3 -0
- package/src/diff-editor/diff-editor.stories.tsx +44 -0
- package/src/diff-editor/diff-editor.test.tsx +68 -0
- package/src/diff-editor/diff-editor.tsx +151 -0
- package/src/diff-editor/index.ts +1 -0
- package/src/editor-context-menu/editor-context-menu.tsx +120 -0
- package/src/editor-context-menu/index.ts +1 -0
- package/src/editor-toolbar/editor-toolbar.tsx +83 -0
- package/src/editor-toolbar/index.ts +1 -0
- package/src/index.ts +48 -0
- package/src/lib/editor-completions-monaco.test.ts +206 -0
- package/src/lib/editor-completions-monaco.ts +127 -0
- package/src/lib/editor-completions.test.ts +156 -0
- package/src/lib/editor-completions.ts +153 -0
- package/src/lib/editor-content-access-prose.test.ts +411 -0
- package/src/lib/editor-content-access-prose.ts +172 -0
- package/src/lib/editor-content-access.test.ts +182 -0
- package/src/lib/editor-content-access.ts +109 -0
- package/src/lib/languages.ts +32 -0
- package/src/lib/markdown/diff.test.ts +80 -0
- package/src/lib/markdown/diff.ts +194 -0
- package/src/lib/markdown/directives.ts +342 -0
- package/src/lib/markdown/frontmatter.ts +50 -0
- package/src/lib/markdown/markdown-scale.test.ts +65 -0
- package/src/lib/markdown/markdown-scale.ts +62 -0
- package/src/lib/markdown/merge.test.ts +64 -0
- package/src/lib/markdown/merge.ts +158 -0
- package/src/lib/markdown/slugify.test.ts +156 -0
- package/src/lib/markdown/slugify.ts +47 -0
- package/src/lib/monaco-environment.ts +103 -0
- package/src/lib/monaco-theme-bridge.ts +293 -0
- package/src/lib/use-data-theme.ts +61 -0
- package/src/markdown/frontmatter.ts +14 -0
- package/src/markdown/index.ts +323 -0
- package/src/markdown/parse.test.ts +60 -0
- package/src/markdown/parse.ts +38 -0
- package/src/markdown-academic/citations.test.ts +77 -0
- package/src/markdown-academic/citations.tsx +442 -0
- package/src/markdown-academic/footnotes.tsx +235 -0
- package/src/markdown-academic/index.ts +32 -0
- package/src/markdown-academic/math.tsx +163 -0
- package/src/markdown-academic/toc.tsx +88 -0
- package/src/markdown-editor/completions/completions-menu.tsx +96 -0
- package/src/markdown-editor/completions/completions-prose.test.ts +356 -0
- package/src/markdown-editor/completions/completions-prose.ts +313 -0
- package/src/markdown-editor/completions/completions-widget.tsx +75 -0
- package/src/markdown-editor/completions/index.ts +37 -0
- package/src/markdown-editor/directive-nodes.ts +151 -0
- package/src/markdown-editor/directive-views.test.tsx +189 -0
- package/src/markdown-editor/directive-views.tsx +795 -0
- package/src/markdown-editor/exit-keymap.test.ts +88 -0
- package/src/markdown-editor/exit-keymap.ts +87 -0
- package/src/markdown-editor/index.ts +6 -0
- package/src/markdown-editor/markdown-editor.css +250 -0
- package/src/markdown-editor/markdown-editor.directives.test.tsx +105 -0
- package/src/markdown-editor/markdown-editor.fill.test.ts +34 -0
- package/src/markdown-editor/markdown-editor.paste-embed.test.tsx +297 -0
- package/src/markdown-editor/markdown-editor.stories.tsx +560 -0
- package/src/markdown-editor/markdown-editor.strictmode.test.tsx +38 -0
- package/src/markdown-editor/markdown-editor.table.test.tsx +287 -0
- package/src/markdown-editor/markdown-editor.tsx +560 -0
- package/src/markdown-editor/milkdown-react/editor.tsx +33 -0
- package/src/markdown-editor/milkdown-react/index.ts +8 -0
- package/src/markdown-editor/milkdown-react/types.ts +27 -0
- package/src/markdown-editor/milkdown-react/use-editor.ts +27 -0
- package/src/markdown-editor/milkdown-react/use-get-editor.ts +56 -0
- package/src/markdown-editor/milkdown-react/use-instance.ts +23 -0
- package/src/markdown-editor/paste-embed.ts +355 -0
- package/src/markdown-editor/slash/brand-slash-commands.test.ts +194 -0
- package/src/markdown-editor/slash/brand-slash-commands.ts +324 -0
- package/src/markdown-editor/slash/brand-slash-plugin.test.ts +254 -0
- package/src/markdown-editor/slash/brand-slash-plugin.ts +385 -0
- package/src/markdown-editor/slash/index.ts +107 -0
- package/src/markdown-editor/slash/insert-directive.test.ts +263 -0
- package/src/markdown-editor/slash/insert-directive.ts +345 -0
- package/src/markdown-editor/slash/monaco-slash-menu.test.tsx +198 -0
- package/src/markdown-editor/slash/monaco-slash-menu.tsx +356 -0
- package/src/markdown-editor/slash/shortcut-monaco.ts +69 -0
- package/src/markdown-editor/slash/shortcut.test.ts +157 -0
- package/src/markdown-editor/slash/shortcut.ts +57 -0
- package/src/markdown-editor/slash/slash-menu.stories.tsx +139 -0
- package/src/markdown-editor/slash/slash-menu.tsx +115 -0
- package/src/markdown-editor/slash/slash-scroll.test.tsx +52 -0
- package/src/markdown-editor/slash/slash-widget.tsx +97 -0
- package/src/markdown-editor/slash/source-slash-trigger.test.ts +42 -0
- package/src/markdown-editor/slash/source-slash-trigger.ts +46 -0
- package/src/markdown-editor/table-view.tsx +249 -0
- package/src/markdown-iteration/directive.tsx +92 -0
- package/src/markdown-iteration/edit-context.ts +56 -0
- package/src/markdown-iteration/index.ts +24 -0
- package/src/markdown-iteration/iteration-block.test.tsx +138 -0
- package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +293 -0
- package/src/markdown-iteration/iteration-builder-dialog.tsx +312 -0
- package/src/markdown-iteration/iteration-builder.test.ts +252 -0
- package/src/markdown-iteration/iteration-builder.ts +303 -0
- package/src/markdown-iteration/iteration.test.ts +56 -0
- package/src/markdown-iteration/iteration.tsx +333 -0
- package/src/markdown-iteration/template-dialog.stories.tsx +63 -0
- package/src/markdown-iteration/template-dialog.test.tsx +104 -0
- package/src/markdown-iteration/template-dialog.tsx +116 -0
- package/src/markdown-outline/document-outline.stories.tsx +76 -0
- package/src/markdown-outline/document-outline.tsx +83 -0
- package/src/markdown-outline/index.ts +2 -0
- package/src/markdown-outline/markdown-outline.test.ts +49 -0
- package/src/markdown-outline/markdown-outline.ts +60 -0
- package/src/markdown-preview/code-fence.tsx +193 -0
- package/src/markdown-preview/index.ts +8 -0
- package/src/markdown-preview/markdown-preview-academic.stories.tsx +203 -0
- package/src/markdown-preview/markdown-preview-academic.test.tsx +257 -0
- package/src/markdown-preview/markdown-preview-iteration-calc.test.tsx +71 -0
- package/src/markdown-preview/markdown-preview-iteration-nested.test.tsx +58 -0
- package/src/markdown-preview/markdown-preview-iteration.stories.tsx +187 -0
- package/src/markdown-preview/markdown-preview-iteration.test.tsx +90 -0
- package/src/markdown-preview/markdown-preview-linking.test.tsx +61 -0
- package/src/markdown-preview/markdown-preview-transclusion.test.tsx +33 -0
- package/src/markdown-preview/markdown-preview.stories.tsx +108 -0
- package/src/markdown-preview/markdown-preview.test.tsx +411 -0
- package/src/markdown-preview/markdown-preview.tsx +1541 -0
- package/src/markdown-toolbar/index.ts +8 -0
- package/src/markdown-toolbar/markdown-commands.test.ts +84 -0
- package/src/markdown-toolbar/markdown-commands.ts +104 -0
- package/src/markdown-toolbar/markdown-toolbar.stories.tsx +56 -0
- package/src/markdown-toolbar/markdown-toolbar.tsx +263 -0
- package/src/markdown-workspace/focus-writing.test.ts +52 -0
- package/src/markdown-workspace/focus-writing.ts +38 -0
- package/src/markdown-workspace/index.ts +6 -0
- package/src/markdown-workspace/markdown-workspace.stories.tsx +688 -0
- package/src/markdown-workspace/markdown-workspace.test.tsx +326 -0
- package/src/markdown-workspace/markdown-workspace.tsx +787 -0
- package/src/mermaid-diagram/index.ts +1 -0
- package/src/mermaid-diagram/mermaid-diagram.stories.tsx +42 -0
- package/src/mermaid-diagram/mermaid-diagram.test.tsx +86 -0
- package/src/mermaid-diagram/mermaid-diagram.tsx +318 -0
- package/src/mermaid-diagram/mermaid-viewer.test.tsx +89 -0
- package/src/mermaid-diagram/mermaid-viewer.tsx +367 -0
- package/src/mermaid-diagram/real-parse.test.ts +42 -0
- package/src/mermaid-diagram/remediate.test.ts +60 -0
- package/src/mermaid-diagram/remediate.ts +124 -0
- package/src/mermaid-workspace/index.ts +1 -0
- package/src/mermaid-workspace/mermaid-workspace.stories.tsx +28 -0
- package/src/mermaid-workspace/mermaid-workspace.tsx +71 -0
- package/src/metric-block/index.ts +1 -0
- package/src/metric-block/metric-block.stories.tsx +40 -0
- package/src/metric-block/metric-block.tsx +14 -0
- package/src/monaco-workers.d.ts +10 -0
- package/src/prose/index.ts +14 -0
- package/src/prose/prose.stories.tsx +44 -0
- package/src/prose/prose.test.tsx +44 -0
- package/src/prose/prose.tsx +21 -0
- package/src/timeline/index.ts +18 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Citations — Pandoc / Better-BibTeX keys (`[@smith2020]`) resolved through a
|
|
5
|
+
* consumer hook (`resolveCitation(key) => CitationData | null`).
|
|
6
|
+
*
|
|
7
|
+
* The library NEVER owns the bibliography database or CSL formatting — that lives
|
|
8
|
+
* in the app (pass `formatted` for a citeproc-rendered reference, or the lightweight
|
|
9
|
+
* `author`/`year`/`title` bits for the built-in assembler). The library renders:
|
|
10
|
+
* inline cites (numeric `[1]` or author-year `(Smith 2020)`) and a generated
|
|
11
|
+
* bibliography, with consistent numbering shared between them.
|
|
12
|
+
*
|
|
13
|
+
* `[@key]` stays a plain text node in mdast (it is NOT markdown link syntax), so a
|
|
14
|
+
* text-node transform (`remarkBrandCitations`) rewrites the spans — the same shape
|
|
15
|
+
* as the wikilink resolver. Numbering is owned by `collectCitations` (a single
|
|
16
|
+
* pre-pass over the source) so an inline `[1]` and bibliography entry 1 always agree.
|
|
17
|
+
*/
|
|
18
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
19
|
+
import { Separator } from "@elabs-ai/components-ui";
|
|
20
|
+
import {
|
|
21
|
+
createContext,
|
|
22
|
+
forwardRef,
|
|
23
|
+
useContext,
|
|
24
|
+
useId,
|
|
25
|
+
type HTMLAttributes,
|
|
26
|
+
type ReactNode,
|
|
27
|
+
} from "react";
|
|
28
|
+
import { visit } from "unist-util-visit";
|
|
29
|
+
|
|
30
|
+
/* ------------------------------------------------------------------ */
|
|
31
|
+
/* Public contract */
|
|
32
|
+
/* ------------------------------------------------------------------ */
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolved citation data, returned by the consumer's `resolveCitation` hook.
|
|
36
|
+
* Provide `formatted` (your CSL/citeproc output) for a verbatim bibliography
|
|
37
|
+
* entry; otherwise the built-in assembler uses `author` / `year` / `title` /
|
|
38
|
+
* `container`. The library does NOT format CSL.
|
|
39
|
+
*/
|
|
40
|
+
export interface CitationData {
|
|
41
|
+
/** The citation key (echoed back; optional). */
|
|
42
|
+
key?: string;
|
|
43
|
+
/** Inline author label for author-year + bibliography lead (e.g. `"Smith et al."`). */
|
|
44
|
+
author?: string;
|
|
45
|
+
/** Publication year. */
|
|
46
|
+
year?: string | number;
|
|
47
|
+
/** Work title. */
|
|
48
|
+
title?: string;
|
|
49
|
+
/** Container — journal, book, publisher, or site. */
|
|
50
|
+
container?: string;
|
|
51
|
+
/** Canonical URL (the bibliography link). */
|
|
52
|
+
url?: string;
|
|
53
|
+
/** DOI — linked as `https://doi.org/<doi>` when no `url` is given. */
|
|
54
|
+
doi?: string;
|
|
55
|
+
/** Fully-formatted reference (app's CSL output) — rendered verbatim when set. */
|
|
56
|
+
formatted?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Consumer hook: citation key → data, or `null` when unknown (renders `[?]`). */
|
|
60
|
+
export type ResolveCitation = (key: string) => CitationData | null;
|
|
61
|
+
|
|
62
|
+
/** Inline citation rendering style. */
|
|
63
|
+
export type CitationStyle = "numeric" | "author-year";
|
|
64
|
+
|
|
65
|
+
/** One resolved citation with its assigned number (numbers skip unresolved keys). */
|
|
66
|
+
export interface ResolvedCitation {
|
|
67
|
+
key: string;
|
|
68
|
+
/** 1-based number (numeric style + bibliography); `undefined` when unresolved. */
|
|
69
|
+
n?: number;
|
|
70
|
+
data: CitationData | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A single `@key` reference inside a citation bracket. */
|
|
74
|
+
export interface CiteItem {
|
|
75
|
+
key: string;
|
|
76
|
+
/** Locator text after the key, e.g. `"p. 5"`. */
|
|
77
|
+
locator?: string;
|
|
78
|
+
/** `-@key` → suppress the author in author-year style. */
|
|
79
|
+
suppressAuthor?: boolean;
|
|
80
|
+
/** Prose before the key, e.g. `"see"`. */
|
|
81
|
+
prefix?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* ------------------------------------------------------------------ */
|
|
85
|
+
/* Parsing (shared by the transform AND the numbering pre-pass) */
|
|
86
|
+
/* ------------------------------------------------------------------ */
|
|
87
|
+
|
|
88
|
+
// A key char-class wide enough for Better-BibTeX / CSL keys without swallowing
|
|
89
|
+
// trailing punctuation: letters, digits, and `_:.#$%&+?<>~/-` (no whitespace).
|
|
90
|
+
const ITEM_RE = /^\s*([^@]*?)\s*(-)?@([\p{L}\d][\w:.#$%&+?<>~/-]*)\s*(.*)$/u;
|
|
91
|
+
|
|
92
|
+
/** Parse one `;`-separated cite item; `null` if it has no `@key`. */
|
|
93
|
+
function parseItem(raw: string): CiteItem | null {
|
|
94
|
+
const m = ITEM_RE.exec(raw);
|
|
95
|
+
if (!m) return null;
|
|
96
|
+
const [, prefix, suppress, key, rest] = m;
|
|
97
|
+
if (!key) return null;
|
|
98
|
+
const locator = (rest ?? "").replace(/^\s*,\s*/, "").trim();
|
|
99
|
+
const item: CiteItem = { key };
|
|
100
|
+
if (suppress) item.suppressAuthor = true;
|
|
101
|
+
if (prefix?.trim()) item.prefix = prefix.trim();
|
|
102
|
+
if (locator) item.locator = locator;
|
|
103
|
+
return item;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Parse a bracket's inner text into cite items, or `null` if it is not a citation
|
|
108
|
+
* (no `@key` token) — so ordinary `[bracketed]` prose is left untouched.
|
|
109
|
+
*/
|
|
110
|
+
export function parseCitationBracket(inner: string): CiteItem[] | null {
|
|
111
|
+
if (!inner.includes("@")) return null;
|
|
112
|
+
const items: CiteItem[] = [];
|
|
113
|
+
for (const part of inner.split(";")) {
|
|
114
|
+
const item = parseItem(part);
|
|
115
|
+
if (!item) return null; // every `;`-part must be a valid cite, else it's prose
|
|
116
|
+
items.push(item);
|
|
117
|
+
}
|
|
118
|
+
return items.length > 0 ? items : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Find candidate citation brackets in a text node. Excludes a preceding `]` or `!`
|
|
122
|
+
// (reference-link / image syntax) and a following `(`/`[` (inline / reference link).
|
|
123
|
+
const BRACKET_RE = /(?<![\]!])\[([^[\]]+)\](?![([])/g;
|
|
124
|
+
|
|
125
|
+
/* ------------------------------------------------------------------ */
|
|
126
|
+
/* collectCitations — the single numbering authority */
|
|
127
|
+
/* ------------------------------------------------------------------ */
|
|
128
|
+
|
|
129
|
+
export interface CollectedCitations {
|
|
130
|
+
/** Resolved citations in first-appearance order (the bibliography list). */
|
|
131
|
+
order: ResolvedCitation[];
|
|
132
|
+
/** Lookup by key — both resolved and unresolved keys. */
|
|
133
|
+
byKey: Map<string, ResolvedCitation>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Scan the markdown body once, in document order, resolving each unique citation
|
|
138
|
+
* key and numbering the resolved ones. The inline transform stays numbering-free
|
|
139
|
+
* and reads back from `byKey`, so inline `[1]` and bibliography entry 1 agree.
|
|
140
|
+
*/
|
|
141
|
+
export function collectCitations(markdown: string, resolve: ResolveCitation): CollectedCitations {
|
|
142
|
+
const byKey = new Map<string, ResolvedCitation>();
|
|
143
|
+
const order: ResolvedCitation[] = [];
|
|
144
|
+
let m: RegExpExecArray | null;
|
|
145
|
+
BRACKET_RE.lastIndex = 0;
|
|
146
|
+
while ((m = BRACKET_RE.exec(markdown)) !== null) {
|
|
147
|
+
const items = parseCitationBracket(m[1]!);
|
|
148
|
+
if (!items) continue;
|
|
149
|
+
for (const { key } of items) {
|
|
150
|
+
if (byKey.has(key)) continue;
|
|
151
|
+
const data = resolve(key);
|
|
152
|
+
const entry: ResolvedCitation = { key, data };
|
|
153
|
+
if (data) {
|
|
154
|
+
entry.n = order.length + 1;
|
|
155
|
+
order.push(entry);
|
|
156
|
+
}
|
|
157
|
+
byKey.set(key, entry);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { order, byKey };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* ------------------------------------------------------------------ */
|
|
164
|
+
/* remark transform: `[@key]` → <brand-cite> */
|
|
165
|
+
/* ------------------------------------------------------------------ */
|
|
166
|
+
|
|
167
|
+
export const CITE_TAG = "brand-cite";
|
|
168
|
+
export const CITE_PROP = "dataCite";
|
|
169
|
+
const CITE_ATTR = "data-cite";
|
|
170
|
+
|
|
171
|
+
interface CitePayload {
|
|
172
|
+
items: CiteItem[];
|
|
173
|
+
/** Original bracket text, for the graceful all-unresolved fallback. */
|
|
174
|
+
original: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
interface MdTextNode {
|
|
178
|
+
type: string;
|
|
179
|
+
value?: string;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Rewrite `[@key]` citation spans in text nodes into `<brand-cite>` elements. */
|
|
183
|
+
export function remarkBrandCitations() {
|
|
184
|
+
return (tree: unknown) => {
|
|
185
|
+
visit(tree as never, "text", (node: MdTextNode, index: number | undefined, parent) => {
|
|
186
|
+
const p = parent as { children?: unknown[] } | undefined;
|
|
187
|
+
const text = node.value;
|
|
188
|
+
if (!p?.children || index == null || typeof text !== "string" || !text.includes("@")) return;
|
|
189
|
+
|
|
190
|
+
const next: unknown[] = [];
|
|
191
|
+
let last = 0;
|
|
192
|
+
BRACKET_RE.lastIndex = 0;
|
|
193
|
+
let m: RegExpExecArray | null;
|
|
194
|
+
while ((m = BRACKET_RE.exec(text)) !== null) {
|
|
195
|
+
const items = parseCitationBracket(m[1]!);
|
|
196
|
+
if (!items) continue;
|
|
197
|
+
if (m.index > last) next.push({ type: "text", value: text.slice(last, m.index) });
|
|
198
|
+
const payload: CitePayload = { items, original: m[0] };
|
|
199
|
+
next.push({
|
|
200
|
+
type: "brandCite",
|
|
201
|
+
data: { hName: CITE_TAG, hProperties: { [CITE_PROP]: JSON.stringify(payload) } },
|
|
202
|
+
});
|
|
203
|
+
last = m.index + m[0].length;
|
|
204
|
+
}
|
|
205
|
+
if (next.length === 0) return;
|
|
206
|
+
if (last < text.length) next.push({ type: "text", value: text.slice(last) });
|
|
207
|
+
p.children.splice(index, 1, ...next);
|
|
208
|
+
return index + next.length;
|
|
209
|
+
});
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/* ------------------------------------------------------------------ */
|
|
214
|
+
/* Context (numbering shared by inline cites + bibliography) */
|
|
215
|
+
/* ------------------------------------------------------------------ */
|
|
216
|
+
|
|
217
|
+
interface CitationState {
|
|
218
|
+
byKey: Map<string, ResolvedCitation>;
|
|
219
|
+
order: ResolvedCitation[];
|
|
220
|
+
style: CitationStyle;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const CitationContext = createContext<CitationState | null>(null);
|
|
224
|
+
|
|
225
|
+
export interface CitationProviderProps extends CollectedCitations {
|
|
226
|
+
style: CitationStyle;
|
|
227
|
+
children: ReactNode;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function CitationProvider({ byKey, order, style, children }: CitationProviderProps) {
|
|
231
|
+
return (
|
|
232
|
+
<CitationContext.Provider value={{ byKey, order, style }}>{children}</CitationContext.Provider>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/* ------------------------------------------------------------------ */
|
|
237
|
+
/* Rendering helpers */
|
|
238
|
+
/* ------------------------------------------------------------------ */
|
|
239
|
+
|
|
240
|
+
function hoverTitle(data: CitationData): string {
|
|
241
|
+
if (data.formatted) return data.formatted;
|
|
242
|
+
const parts = [
|
|
243
|
+
data.author,
|
|
244
|
+
data.year != null ? `(${data.year})` : undefined,
|
|
245
|
+
data.title,
|
|
246
|
+
data.container,
|
|
247
|
+
].filter(Boolean);
|
|
248
|
+
return parts.join(". ");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function CiteLink({ entry, label }: { entry: ResolvedCitation; label: string }) {
|
|
252
|
+
// For numeric style the visible label is a bare "[1]" — give AT a real name
|
|
253
|
+
// (the `title` tooltip is for mouse users and is not reliably announced).
|
|
254
|
+
const name = entry.data ? hoverTitle(entry.data) : entry.key;
|
|
255
|
+
return (
|
|
256
|
+
<a
|
|
257
|
+
href={`#ref-${cssId(entry.key)}`}
|
|
258
|
+
title={entry.data ? hoverTitle(entry.data) : undefined}
|
|
259
|
+
aria-label={`Citation: ${name}`}
|
|
260
|
+
// #317/#399 — the on-surface `-text` rung, NOT the `--primary` FILL: an
|
|
261
|
+
// inline cite is body text inside a paragraph and owes WCAG 1.4.3 AA
|
|
262
|
+
// (4.5:1), which `--primary` missed at 4.29-4.31:1 in light. The
|
|
263
|
+
// resting `underline` is the separate 1.4.1 non-colour cue (#317's
|
|
264
|
+
// link-in-text-block half) — keep both.
|
|
265
|
+
className="text-primary-text underline hover:underline focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
266
|
+
>
|
|
267
|
+
{label}
|
|
268
|
+
</a>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Make a citation key safe for use in an element id / fragment. */
|
|
273
|
+
function cssId(key: string): string {
|
|
274
|
+
return key.replace(/[^\w-]/g, "-");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/* ------------------------------------------------------------------ */
|
|
278
|
+
/* InlineCite — the <brand-cite> renderer */
|
|
279
|
+
/* ------------------------------------------------------------------ */
|
|
280
|
+
|
|
281
|
+
type TagProps = { node?: unknown; children?: ReactNode } & Record<string, unknown>;
|
|
282
|
+
|
|
283
|
+
function readCite(rest: TagProps): CitePayload | null {
|
|
284
|
+
const raw = (rest[CITE_ATTR] as string | undefined) ?? (rest[CITE_PROP] as string | undefined);
|
|
285
|
+
if (!raw) return null;
|
|
286
|
+
try {
|
|
287
|
+
return JSON.parse(raw) as CitePayload;
|
|
288
|
+
} catch {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Renderer for the inline `<brand-cite>` element produced by the transform. */
|
|
294
|
+
export function InlineCite({ node: _n, children: _c, ...rest }: TagProps) {
|
|
295
|
+
const ctx = useContext(CitationContext);
|
|
296
|
+
const payload = readCite(rest);
|
|
297
|
+
if (!payload) return null;
|
|
298
|
+
if (!ctx) return <span>{payload.original}</span>;
|
|
299
|
+
|
|
300
|
+
const resolved = payload.items.map((it) => ({ it, entry: ctx.byKey.get(it.key) }));
|
|
301
|
+
const anyResolved = resolved.some((r) => r.entry?.data);
|
|
302
|
+
if (!anyResolved) {
|
|
303
|
+
// Graceful: nothing resolved → keep the literal, marked for sighted + AT.
|
|
304
|
+
return (
|
|
305
|
+
<span className="text-muted-foreground" title="Unresolved citation">
|
|
306
|
+
{payload.original}
|
|
307
|
+
</span>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const numeric = ctx.style === "numeric";
|
|
312
|
+
const open = numeric ? "[" : "(";
|
|
313
|
+
const close = numeric ? "]" : ")";
|
|
314
|
+
const sep = numeric ? ", " : "; ";
|
|
315
|
+
|
|
316
|
+
return (
|
|
317
|
+
<span className="whitespace-nowrap text-meta tabular-nums">
|
|
318
|
+
{open}
|
|
319
|
+
{resolved.map(({ it, entry }, i) => {
|
|
320
|
+
const label = numeric ? numericLabel(it, entry) : authorYearLabel(it, entry);
|
|
321
|
+
return (
|
|
322
|
+
<span key={`${it.key}-${i}`}>
|
|
323
|
+
{i > 0 ? sep : null}
|
|
324
|
+
{entry?.data ? (
|
|
325
|
+
<CiteLink entry={entry} label={label} />
|
|
326
|
+
) : (
|
|
327
|
+
<span className="text-muted-foreground" title={`Unresolved: @${it.key}`}>
|
|
328
|
+
{label}
|
|
329
|
+
</span>
|
|
330
|
+
)}
|
|
331
|
+
</span>
|
|
332
|
+
);
|
|
333
|
+
})}
|
|
334
|
+
{close}
|
|
335
|
+
</span>
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function numericLabel(it: CiteItem, entry?: ResolvedCitation): string {
|
|
340
|
+
if (!entry?.data || entry.n == null) return "?";
|
|
341
|
+
return it.locator ? `${entry.n}, ${it.locator}` : String(entry.n);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function authorYearLabel(it: CiteItem, entry?: ResolvedCitation): string {
|
|
345
|
+
if (!entry?.data) return `@${it.key}?`;
|
|
346
|
+
const d = entry.data;
|
|
347
|
+
const head = it.suppressAuthor ? "" : d.author ? `${d.author} ` : "";
|
|
348
|
+
const year = d.year != null ? String(d.year) : "";
|
|
349
|
+
const core = `${head}${year}`.trim() || d.title || it.key;
|
|
350
|
+
const prefixed = it.prefix ? `${it.prefix} ${core}` : core;
|
|
351
|
+
return it.locator ? `${prefixed}, ${it.locator}` : prefixed;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/* ------------------------------------------------------------------ */
|
|
355
|
+
/* Bibliography */
|
|
356
|
+
/* ------------------------------------------------------------------ */
|
|
357
|
+
|
|
358
|
+
function assembledReference(data: CitationData): string {
|
|
359
|
+
if (data.formatted) return data.formatted;
|
|
360
|
+
const parts = [
|
|
361
|
+
data.author,
|
|
362
|
+
data.year != null ? `(${data.year}).` : undefined,
|
|
363
|
+
data.title ? `${data.title}.` : undefined,
|
|
364
|
+
data.container ? `${data.container}.` : undefined,
|
|
365
|
+
].filter(Boolean);
|
|
366
|
+
return parts.join(" ");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function referenceHref(data: CitationData): string | undefined {
|
|
370
|
+
if (data.url) return data.url;
|
|
371
|
+
if (data.doi) return `https://doi.org/${data.doi}`;
|
|
372
|
+
return undefined;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export interface BibliographyProps extends Omit<HTMLAttributes<HTMLElement>, "children" | "style"> {
|
|
376
|
+
/** Resolved citations; defaults to the citations collected by `MarkdownPreview`. */
|
|
377
|
+
entries?: ResolvedCitation[];
|
|
378
|
+
/** Numbering style; defaults to the preview's `citationStyle`. */
|
|
379
|
+
style?: CitationStyle;
|
|
380
|
+
/** Section heading label. Default `"References"`. */
|
|
381
|
+
title?: string;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* The generated reference list. Reads the preview's collected citations by default
|
|
386
|
+
* (the `::bibliography` block), or accepts an explicit `entries` array standalone.
|
|
387
|
+
* One focal separation gesture — a top `Separator` — over a quiet label + list.
|
|
388
|
+
*/
|
|
389
|
+
export const Bibliography = forwardRef<HTMLElement, BibliographyProps>(function Bibliography(
|
|
390
|
+
{ entries, style, title = "References", className, ...props },
|
|
391
|
+
ref,
|
|
392
|
+
) {
|
|
393
|
+
const ctx = useContext(CitationContext);
|
|
394
|
+
const labelId = useId();
|
|
395
|
+
const list = entries ?? ctx?.order ?? [];
|
|
396
|
+
const resolvedStyle = style ?? ctx?.style ?? "numeric";
|
|
397
|
+
const numeric = resolvedStyle === "numeric";
|
|
398
|
+
|
|
399
|
+
if (list.length === 0) return null;
|
|
400
|
+
|
|
401
|
+
return (
|
|
402
|
+
<section ref={ref} aria-labelledby={labelId} className={cn("mt-8", className)} {...props}>
|
|
403
|
+
<Separator className="mb-3" />
|
|
404
|
+
<p id={labelId} className="mb-2 text-meta font-medium text-muted-foreground">
|
|
405
|
+
{title}
|
|
406
|
+
</p>
|
|
407
|
+
<ol className="space-y-2">
|
|
408
|
+
{list.map((entry) => {
|
|
409
|
+
const data = entry.data;
|
|
410
|
+
if (!data) return null;
|
|
411
|
+
const href = referenceHref(data);
|
|
412
|
+
return (
|
|
413
|
+
<li
|
|
414
|
+
key={entry.key}
|
|
415
|
+
id={`ref-${cssId(entry.key)}`}
|
|
416
|
+
className="flex gap-2 text-caption text-foreground scroll-mt-4"
|
|
417
|
+
>
|
|
418
|
+
{numeric && entry.n != null ? (
|
|
419
|
+
<span className="shrink-0 tabular-nums text-muted-foreground">[{entry.n}]</span>
|
|
420
|
+
) : null}
|
|
421
|
+
<span className="min-w-0">
|
|
422
|
+
{assembledReference(data)}{" "}
|
|
423
|
+
{href ? (
|
|
424
|
+
<a
|
|
425
|
+
href={href}
|
|
426
|
+
target="_blank"
|
|
427
|
+
rel="noopener noreferrer"
|
|
428
|
+
// #317/#399 — bibliography DOI/URL is body text: `-text` rung
|
|
429
|
+
// + resting underline (the non-colour cue).
|
|
430
|
+
className="break-words text-primary-text underline underline-offset-2 hover:underline focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
431
|
+
>
|
|
432
|
+
{data.url ?? `doi:${data.doi}`}
|
|
433
|
+
</a>
|
|
434
|
+
) : null}
|
|
435
|
+
</span>
|
|
436
|
+
</li>
|
|
437
|
+
);
|
|
438
|
+
})}
|
|
439
|
+
</ol>
|
|
440
|
+
</section>
|
|
441
|
+
);
|
|
442
|
+
});
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Branded GFM footnotes (`[^1]` … `[^1]: definition`).
|
|
5
|
+
*
|
|
6
|
+
* GFM footnotes already PARSE (remark-gfm is always on), but Streamdown's default
|
|
7
|
+
* hast handlers render them with broken in-page anchors (a doubled `user-content-`
|
|
8
|
+
* id prefix so ref/backref hrefs don't resolve) and `target="_blank"` on what are
|
|
9
|
+
* same-page jumps. So we OWN the render: `remarkBrandFootnotes` rewrites the
|
|
10
|
+
* `footnoteReference` / `footnoteDefinition` mdast nodes into our own `brand-*`
|
|
11
|
+
* elements BEFORE mdast→hast runs, giving consistent ids, real same-page links,
|
|
12
|
+
* and quiet branded chrome.
|
|
13
|
+
*
|
|
14
|
+
* Why replace the node TYPE (not just set `data.hName`): mdast-util-to-hast has
|
|
15
|
+
* built-in handlers for `footnoteReference` / `footnoteDefinition` that ignore
|
|
16
|
+
* `data.hName`. Only a node type with NO handler falls through to the unknown
|
|
17
|
+
* handler, which honors `hName` / `hProperties`. So we swap in fresh custom-typed
|
|
18
|
+
* nodes — and we keep every `id`/`href` on our React output (post-sanitization),
|
|
19
|
+
* never on raw hast the sanitizer could strip.
|
|
20
|
+
*/
|
|
21
|
+
import { Separator } from "@elabs-ai/components-ui";
|
|
22
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
23
|
+
import { forwardRef, useId, type HTMLAttributes, type ReactNode } from "react";
|
|
24
|
+
import { visit } from "unist-util-visit";
|
|
25
|
+
|
|
26
|
+
/** Inline footnote marker (`<sup><a>…</a></sup>`). */
|
|
27
|
+
export const FOOTNOTE_REF_TAG = "brand-footnote-ref";
|
|
28
|
+
/** The branded footnote-definition section appended at document end. */
|
|
29
|
+
export const FOOTNOTE_LIST_TAG = "brand-footnote-list";
|
|
30
|
+
/** A single footnote definition row (`<li>` + backref). */
|
|
31
|
+
export const FOOTNOTE_ITEM_TAG = "brand-footnote-item";
|
|
32
|
+
/** hast property carrying the JSON payload (rendered as `data-fn`). */
|
|
33
|
+
export const FOOTNOTE_PROP = "dataFn";
|
|
34
|
+
const FOOTNOTE_ATTR = "data-fn";
|
|
35
|
+
|
|
36
|
+
interface FootnotePayload {
|
|
37
|
+
/** Footnote identifier (the `1` / `longname` in `[^1]`). */
|
|
38
|
+
id: string;
|
|
39
|
+
/** Display number, assigned in first-reference order. */
|
|
40
|
+
n: number;
|
|
41
|
+
/** Unique element id for this reference occurrence (the backref target). */
|
|
42
|
+
refId?: string;
|
|
43
|
+
/** All reference element ids for this footnote — one back-ref per occurrence. */
|
|
44
|
+
refs?: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* ------------------------------------------------------------------ */
|
|
48
|
+
/* remark transform */
|
|
49
|
+
/* ------------------------------------------------------------------ */
|
|
50
|
+
|
|
51
|
+
interface MdNode {
|
|
52
|
+
type: string;
|
|
53
|
+
identifier?: string;
|
|
54
|
+
value?: string;
|
|
55
|
+
children?: MdNode[];
|
|
56
|
+
data?: { hName?: string; hProperties?: Record<string, unknown> };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function payloadProps(payload: FootnotePayload) {
|
|
60
|
+
return { [FOOTNOTE_PROP]: JSON.stringify(payload) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Rewrite footnote refs + definitions into branded elements. Numbering follows
|
|
65
|
+
* first-reference order (GFM behavior); repeated references reuse the number but
|
|
66
|
+
* get a unique element id. Unreferenced definitions are dropped (also GFM).
|
|
67
|
+
*/
|
|
68
|
+
export function remarkBrandFootnotes() {
|
|
69
|
+
return (tree: unknown) => {
|
|
70
|
+
const root = tree as MdNode;
|
|
71
|
+
|
|
72
|
+
// Pass 1 — collect definitions (and remove them; re-emitted at the end).
|
|
73
|
+
const defs = new Map<string, MdNode>();
|
|
74
|
+
visit(
|
|
75
|
+
root as never,
|
|
76
|
+
"footnoteDefinition",
|
|
77
|
+
(node: MdNode, index, parent: MdNode | undefined) => {
|
|
78
|
+
if (!node.identifier || !parent?.children || index == null) return;
|
|
79
|
+
defs.set(node.identifier, node);
|
|
80
|
+
parent.children.splice(index, 1);
|
|
81
|
+
return index; // re-visit the now-shifted index
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// Pass 2 — number references in document order + replace with branded refs.
|
|
86
|
+
const numberOf = new Map<string, number>();
|
|
87
|
+
const refsOf = new Map<string, string[]>();
|
|
88
|
+
const order: string[] = [];
|
|
89
|
+
visit(root as never, "footnoteReference", (node: MdNode, index, parent: MdNode | undefined) => {
|
|
90
|
+
if (!node.identifier || !parent?.children || index == null) return;
|
|
91
|
+
const id = node.identifier;
|
|
92
|
+
let n = numberOf.get(id);
|
|
93
|
+
if (n == null) {
|
|
94
|
+
n = order.length + 1;
|
|
95
|
+
numberOf.set(id, n);
|
|
96
|
+
order.push(id);
|
|
97
|
+
}
|
|
98
|
+
const refs = refsOf.get(id) ?? [];
|
|
99
|
+
const refId = refs.length === 0 ? `fnref-${id}` : `fnref-${id}-${refs.length + 1}`;
|
|
100
|
+
refs.push(refId);
|
|
101
|
+
refsOf.set(id, refs);
|
|
102
|
+
parent.children[index] = {
|
|
103
|
+
type: "brandFootnoteRef",
|
|
104
|
+
data: { hName: FOOTNOTE_REF_TAG, hProperties: payloadProps({ id, n, refId }) },
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
if (order.length === 0) return;
|
|
109
|
+
|
|
110
|
+
// Pass 3 — emit the branded definition section at the document end. Each item
|
|
111
|
+
// is a custom element; the definition BODY stays real mdast (rendered + safely
|
|
112
|
+
// sanitized as normal prose), while every id/anchor lives on our React output.
|
|
113
|
+
// Carry every occurrence's ref id so the item renders one back-ref per mention.
|
|
114
|
+
const items: MdNode[] = order.map((id, i) => {
|
|
115
|
+
const def = defs.get(id);
|
|
116
|
+
const body: MdNode[] = def?.children
|
|
117
|
+
? def.children.map((c) => structuredClone(c))
|
|
118
|
+
: [{ type: "paragraph", children: [{ type: "text", value: "Missing footnote." }] }];
|
|
119
|
+
return {
|
|
120
|
+
type: "brandFootnoteItem",
|
|
121
|
+
data: {
|
|
122
|
+
hName: FOOTNOTE_ITEM_TAG,
|
|
123
|
+
hProperties: payloadProps({ id, n: i + 1, refs: refsOf.get(id) ?? [`fnref-${id}`] }),
|
|
124
|
+
},
|
|
125
|
+
children: body,
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
root.children = root.children ?? [];
|
|
130
|
+
root.children.push({
|
|
131
|
+
type: "brandFootnoteList",
|
|
132
|
+
data: { hName: FOOTNOTE_LIST_TAG },
|
|
133
|
+
children: items,
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* ------------------------------------------------------------------ */
|
|
139
|
+
/* React renderers (registered on MarkdownPreview's components map) */
|
|
140
|
+
/* ------------------------------------------------------------------ */
|
|
141
|
+
|
|
142
|
+
type TagProps = { node?: unknown; children?: ReactNode } & Record<string, unknown>;
|
|
143
|
+
|
|
144
|
+
function readPayload(rest: TagProps): FootnotePayload | null {
|
|
145
|
+
const raw =
|
|
146
|
+
(rest[FOOTNOTE_ATTR] as string | undefined) ?? (rest[FOOTNOTE_PROP] as string | undefined);
|
|
147
|
+
if (!raw) return null;
|
|
148
|
+
try {
|
|
149
|
+
return JSON.parse(raw) as FootnotePayload;
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Inline footnote marker — a quiet superscript same-page link. */
|
|
156
|
+
export function FootnoteRef({ node: _n, children: _c, ...rest }: TagProps) {
|
|
157
|
+
const payload = readPayload(rest);
|
|
158
|
+
if (!payload) return null;
|
|
159
|
+
const { id, n, refId } = payload;
|
|
160
|
+
return (
|
|
161
|
+
<sup className="leading-none">
|
|
162
|
+
<a
|
|
163
|
+
id={refId ?? `fnref-${id}`}
|
|
164
|
+
href={`#fn-${id}`}
|
|
165
|
+
data-footnote-ref=""
|
|
166
|
+
aria-label={`Footnote ${n}`}
|
|
167
|
+
// #399 — a footnote marker is superscript body text: `-text` rung.
|
|
168
|
+
className="px-0.5 font-medium text-primary-text underline tabular-nums hover:underline focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
169
|
+
>
|
|
170
|
+
{n}
|
|
171
|
+
</a>
|
|
172
|
+
</sup>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A single footnote definition row — the `<li>` + one "↩" back-ref per mention. */
|
|
177
|
+
export function FootnoteItem({ node: _n, children, ...rest }: TagProps) {
|
|
178
|
+
const payload = readPayload(rest);
|
|
179
|
+
if (!payload) return null;
|
|
180
|
+
const { id, n, refs } = payload;
|
|
181
|
+
// One back-ref per occurrence (GFM behavior); a single mention → a lone "↩".
|
|
182
|
+
const refList = refs && refs.length > 0 ? refs : [`fnref-${id}`];
|
|
183
|
+
return (
|
|
184
|
+
<li
|
|
185
|
+
id={`fn-${id}`}
|
|
186
|
+
className="scroll-mt-4 ps-1 [&>p]:m-0 [&>p]:inline [&>p]:text-caption [&>p]:text-muted-foreground"
|
|
187
|
+
>
|
|
188
|
+
{children}{" "}
|
|
189
|
+
{refList.map((refId, i) => (
|
|
190
|
+
<a
|
|
191
|
+
key={refId}
|
|
192
|
+
href={`#${refId}`}
|
|
193
|
+
data-footnote-backref=""
|
|
194
|
+
aria-label={
|
|
195
|
+
refList.length > 1
|
|
196
|
+
? `Back to reference ${n}, mention ${i + 1}`
|
|
197
|
+
: `Back to reference ${n}`
|
|
198
|
+
}
|
|
199
|
+
className="ms-0.5 inline-flex items-center text-muted-foreground no-underline hover:text-foreground focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
200
|
+
>
|
|
201
|
+
<span aria-hidden="true">↩</span>
|
|
202
|
+
{refList.length > 1 ? (
|
|
203
|
+
<sub className="ms-0.5 leading-none tabular-nums">{i + 1}</sub>
|
|
204
|
+
) : null}
|
|
205
|
+
</a>
|
|
206
|
+
))}
|
|
207
|
+
</li>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export type FootnoteListProps = HTMLAttributes<HTMLElement>;
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The branded footnote-definition section. One focal separation gesture — a top
|
|
215
|
+
* `Separator` (the classic footnote rule) — over a quiet label + the definition
|
|
216
|
+
* list; no fill, no border box. The label id is per-instance (`useId`) so two
|
|
217
|
+
* previews on one page don't collide.
|
|
218
|
+
*/
|
|
219
|
+
export const FootnoteList = forwardRef<HTMLElement, FootnoteListProps>(function FootnoteList(
|
|
220
|
+
{ className, children, ...props },
|
|
221
|
+
ref,
|
|
222
|
+
) {
|
|
223
|
+
const labelId = useId();
|
|
224
|
+
return (
|
|
225
|
+
<section ref={ref} aria-labelledby={labelId} className={cn("mt-8", className)} {...props}>
|
|
226
|
+
<Separator className="mb-3" />
|
|
227
|
+
<p id={labelId} className="mb-2 text-meta font-medium text-muted-foreground">
|
|
228
|
+
Footnotes
|
|
229
|
+
</p>
|
|
230
|
+
<ol className="list-decimal space-y-1.5 ps-6 text-caption text-muted-foreground marker:text-muted-foreground">
|
|
231
|
+
{children}
|
|
232
|
+
</ol>
|
|
233
|
+
</section>
|
|
234
|
+
);
|
|
235
|
+
});
|