@ewanc26/svelte-standard-site 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/components/ActionBar.svelte +85 -0
  2. package/dist/components/ActionBar.svelte.d.ts +13 -0
  3. package/dist/components/Avatar.svelte +104 -0
  4. package/dist/components/Avatar.svelte.d.ts +19 -0
  5. package/dist/components/Comment.svelte +172 -0
  6. package/dist/components/Comment.svelte.d.ts +22 -0
  7. package/dist/components/CommentsSection.svelte +89 -0
  8. package/dist/components/DocumentCard.svelte +126 -56
  9. package/dist/components/DocumentCard.svelte.d.ts +51 -0
  10. package/dist/components/Footnotes.svelte +72 -0
  11. package/dist/components/Footnotes.svelte.d.ts +13 -0
  12. package/dist/components/RecommendButton.svelte +153 -0
  13. package/dist/components/RecommendButton.svelte.d.ts +17 -0
  14. package/dist/components/ThemeProvider.svelte +92 -0
  15. package/dist/components/ThemeProvider.svelte.d.ts +13 -0
  16. package/dist/components/Toast.svelte +177 -0
  17. package/dist/components/Toast.svelte.d.ts +32 -0
  18. package/dist/components/Watermark.svelte +100 -0
  19. package/dist/components/Watermark.svelte.d.ts +17 -0
  20. package/dist/components/common/ThemedCard.svelte +15 -15
  21. package/dist/components/common/ThemedCard.svelte.d.ts +5 -0
  22. package/dist/components/document/BlockRenderer.svelte +3 -0
  23. package/dist/components/document/DocumentRenderer.svelte +41 -1
  24. package/dist/components/document/RichText.svelte +87 -2
  25. package/dist/components/document/RichText.svelte.d.ts +2 -0
  26. package/dist/components/document/blocks/OrderedListBlock.svelte +152 -0
  27. package/dist/components/document/blocks/UnorderedListBlock.svelte +1 -1
  28. package/dist/components/index.d.ts +28 -0
  29. package/dist/components/index.js +30 -0
  30. package/dist/index.d.ts +5 -4
  31. package/dist/index.js +6 -4
  32. package/dist/publisher.d.ts +73 -0
  33. package/dist/publisher.js +185 -0
  34. package/dist/schemas.d.ts +1162 -2
  35. package/dist/schemas.js +316 -0
  36. package/dist/types.d.ts +393 -2
  37. package/dist/types.js +1 -1
  38. package/dist/utils/native-comments.d.ts +68 -0
  39. package/dist/utils/native-comments.js +149 -0
  40. package/dist/utils/theme-helpers.d.ts +41 -1
  41. package/dist/utils/theme-helpers.js +98 -1
  42. package/dist/utils/theme.d.ts +48 -1
  43. package/dist/utils/theme.js +158 -0
  44. package/package.json +20 -20
  45. package/src/lib/components/ActionBar.svelte +85 -0
  46. package/src/lib/components/Avatar.svelte +104 -0
  47. package/src/lib/components/Comment.svelte +172 -0
  48. package/src/lib/components/CommentsSection.svelte +89 -0
  49. package/src/lib/components/DocumentCard.svelte +126 -56
  50. package/src/lib/components/Footnotes.svelte +72 -0
  51. package/src/lib/components/RecommendButton.svelte +153 -0
  52. package/src/lib/components/ThemeProvider.svelte +92 -0
  53. package/src/lib/components/Toast.svelte +177 -0
  54. package/src/lib/components/Watermark.svelte +100 -0
  55. package/src/lib/components/common/ThemedCard.svelte +15 -15
  56. package/src/lib/components/document/BlockRenderer.svelte +3 -0
  57. package/src/lib/components/document/DocumentRenderer.svelte +41 -1
  58. package/src/lib/components/document/RichText.svelte +87 -2
  59. package/src/lib/components/document/blocks/OrderedListBlock.svelte +152 -0
  60. package/src/lib/components/document/blocks/UnorderedListBlock.svelte +1 -1
  61. package/src/lib/components/index.ts +32 -0
  62. package/src/lib/index.ts +119 -5
  63. package/src/lib/publisher.ts +251 -0
  64. package/src/lib/schemas.ts +411 -0
  65. package/src/lib/types.ts +506 -2
  66. package/src/lib/utils/native-comments.ts +197 -0
  67. package/src/lib/utils/theme-helpers.ts +136 -3
  68. package/src/lib/utils/theme.ts +189 -1
  69. package/dist/components/document/blocks/UnorderedListBlock.svelte.d.ts +0 -9
@@ -0,0 +1,177 @@
1
+ <script lang="ts">
2
+ import { createEventDispatcher } from 'svelte';
3
+
4
+ export type ToastType = 'success' | 'error' | 'info' | 'warning';
5
+
6
+ export interface ToastMessage {
7
+ id: string;
8
+ message: string;
9
+ type: ToastType;
10
+ duration?: number;
11
+ }
12
+
13
+ interface Props {
14
+ toasts: ToastMessage[];
15
+ position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
16
+ hasTheme?: boolean;
17
+ }
18
+
19
+ const { toasts, position = 'bottom-right', hasTheme = false }: Props = $props();
20
+
21
+ const dispatch = createEventDispatcher<{ dismiss: string }>();
22
+
23
+ function dismiss(id: string) {
24
+ dispatch('dismiss', id);
25
+ }
26
+ </script>
27
+
28
+ <div class="toast-container {position}" class:themed={hasTheme}>
29
+ {#each toasts as toast}
30
+ <div
31
+ class="toast {toast.type}"
32
+ class:themed={hasTheme}
33
+ role="alert"
34
+ aria-live="polite"
35
+ >
36
+ <div class="toast-icon">
37
+ {#if toast.type === 'success'}
38
+ <svg viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
39
+ <path
40
+ fill-rule="evenodd"
41
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
42
+ clip-rule="evenodd"
43
+ />
44
+ </svg>
45
+ {:else if toast.type === 'error'}
46
+ <svg viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
47
+ <path
48
+ fill-rule="evenodd"
49
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z"
50
+ clip-rule="evenodd"
51
+ />
52
+ </svg>
53
+ {:else if toast.type === 'warning'}
54
+ <svg viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
55
+ <path
56
+ fill-rule="evenodd"
57
+ d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.63-1.516 2.63H3.72c-1.347 0-2.189-1.463-1.516-2.63l6.28-10.875zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
58
+ clip-rule="evenodd"
59
+ />
60
+ </svg>
61
+ {:else}
62
+ <svg viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
63
+ <path
64
+ fill-rule="evenodd"
65
+ d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
66
+ clip-rule="evenodd"
67
+ />
68
+ </svg>
69
+ {/if}
70
+ </div>
71
+ <span class="toast-message">{toast.message}</span>
72
+ <button class="toast-dismiss" onclick={() => dismiss(toast.id)} aria-label="Dismiss">
73
+ <svg viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
74
+ <path
75
+ d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
76
+ />
77
+ </svg>
78
+ </button>
79
+ </div>
80
+ {/each}
81
+ </div>
82
+
83
+ <style>
84
+ .toast-container {
85
+ position: fixed;
86
+ z-index: 9999;
87
+ display: flex;
88
+ flex-direction: column;
89
+ gap: 0.5rem;
90
+ max-width: 24rem;
91
+ }
92
+
93
+ .top-right {
94
+ top: 1rem;
95
+ right: 1rem;
96
+ }
97
+
98
+ .top-left {
99
+ top: 1rem;
100
+ left: 1rem;
101
+ }
102
+
103
+ .bottom-right {
104
+ bottom: 1rem;
105
+ right: 1rem;
106
+ }
107
+
108
+ .bottom-left {
109
+ bottom: 1rem;
110
+ left: 1rem;
111
+ }
112
+
113
+ .toast {
114
+ display: flex;
115
+ align-items: center;
116
+ gap: 0.75rem;
117
+ padding: 0.75rem 1rem;
118
+ border-radius: 0.5rem;
119
+ box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
120
+ animation: slide-in 0.2s ease-out;
121
+ }
122
+
123
+ @keyframes slide-in {
124
+ from {
125
+ transform: translateX(100%);
126
+ opacity: 0;
127
+ }
128
+ to {
129
+ transform: translateX(0);
130
+ opacity: 1;
131
+ }
132
+ }
133
+
134
+ .toast.success {
135
+ background-color: rgb(220 252 231);
136
+ color: rgb(22 101 52);
137
+ }
138
+
139
+ .toast.error {
140
+ background-color: rgb(254 226 226);
141
+ color: rgb(185 28 28);
142
+ }
143
+
144
+ .toast.warning {
145
+ background-color: rgb(254 249 195);
146
+ color: rgb(161 98 7);
147
+ }
148
+
149
+ .toast.info {
150
+ background-color: rgb(239 246 255);
151
+ color: rgb(30 64 175);
152
+ }
153
+
154
+ .toast-icon {
155
+ flex-shrink: 0;
156
+ }
157
+
158
+ .toast-message {
159
+ flex: 1;
160
+ font-size: 0.875rem;
161
+ }
162
+
163
+ .toast-dismiss {
164
+ flex-shrink: 0;
165
+ padding: 0.25rem;
166
+ border-radius: 0.25rem;
167
+ background: transparent;
168
+ border: none;
169
+ cursor: pointer;
170
+ opacity: 0.6;
171
+ transition: opacity 0.15s;
172
+ }
173
+
174
+ .toast-dismiss:hover {
175
+ opacity: 1;
176
+ }
177
+ </style>
@@ -0,0 +1,32 @@
1
+ export type ToastType = 'success' | 'error' | 'info' | 'warning';
2
+ export interface ToastMessage {
3
+ id: string;
4
+ message: string;
5
+ type: ToastType;
6
+ duration?: number;
7
+ }
8
+ interface Props {
9
+ toasts: ToastMessage[];
10
+ position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
11
+ hasTheme?: boolean;
12
+ }
13
+ interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
14
+ new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
15
+ $$bindings?: Bindings;
16
+ } & Exports;
17
+ (internal: unknown, props: Props & {
18
+ $$events?: Events;
19
+ $$slots?: Slots;
20
+ }): Exports & {
21
+ $set?: any;
22
+ $on?: any;
23
+ };
24
+ z_$$bindings?: Bindings;
25
+ }
26
+ declare const Toast: $$__sveltets_2_IsomorphicComponent<Props, {
27
+ dismiss: CustomEvent<string>;
28
+ } & {
29
+ [evt: string]: CustomEvent<any>;
30
+ }, {}, {}, "">;
31
+ type Toast = InstanceType<typeof Toast>;
32
+ export default Toast;
@@ -0,0 +1,100 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ /** Text to display as watermark */
4
+ text?: string;
5
+ /** Publication name */
6
+ publicationName?: string;
7
+ /** Publication URL */
8
+ publicationUrl?: string;
9
+ /** Position of watermark */
10
+ position?: 'bottom-right' | 'bottom-left' | 'bottom-center';
11
+ /** Opacity (0-1) */
12
+ opacity?: number;
13
+ /** Has theme applied */
14
+ hasTheme?: boolean;
15
+ }
16
+
17
+ const {
18
+ text,
19
+ publicationName,
20
+ publicationUrl,
21
+ position = 'bottom-right',
22
+ opacity = 0.6,
23
+ hasTheme = false
24
+ }: Props = $props();
25
+
26
+ // Display text - prefer explicit text, then publication name
27
+ const displayText = $derived(text || publicationName || 'Leaflet');
28
+ </script>
29
+
30
+ <div
31
+ class="watermark {position}"
32
+ class:themed={hasTheme}
33
+ style:opacity={opacity}
34
+ >
35
+ {#if publicationUrl && !text}
36
+ <a
37
+ href={publicationUrl}
38
+ target="_blank"
39
+ rel="noopener noreferrer"
40
+ class="watermark-link"
41
+ >
42
+ {displayText}
43
+ </a>
44
+ {:else}
45
+ <span class="watermark-text">{displayText}</span>
46
+ {/if}
47
+ </div>
48
+
49
+ <style>
50
+ .watermark {
51
+ position: fixed;
52
+ padding: 0.5rem 1rem;
53
+ font-size: 0.75rem;
54
+ font-weight: 500;
55
+ letter-spacing: 0.05em;
56
+ text-transform: uppercase;
57
+ pointer-events: none;
58
+ z-index: 100;
59
+ }
60
+
61
+ .bottom-right {
62
+ bottom: 1rem;
63
+ right: 1rem;
64
+ }
65
+
66
+ .bottom-left {
67
+ bottom: 1rem;
68
+ left: 1rem;
69
+ }
70
+
71
+ .bottom-center {
72
+ bottom: 1rem;
73
+ left: 50%;
74
+ transform: translateX(-50%);
75
+ }
76
+
77
+ .watermark-text {
78
+ color: rgb(107 114 128);
79
+ }
80
+
81
+ .watermark-link {
82
+ color: rgb(107 114 128);
83
+ text-decoration: none;
84
+ pointer-events: auto;
85
+ transition: color 0.15s;
86
+ }
87
+
88
+ .watermark-link:hover {
89
+ color: rgb(0 0 225);
90
+ }
91
+
92
+ .watermark.themed .watermark-text,
93
+ .watermark.themed .watermark-link {
94
+ color: var(--theme-foreground);
95
+ }
96
+
97
+ .watermark.themed .watermark-link:hover {
98
+ color: var(--theme-accent);
99
+ }
100
+ </style>
@@ -0,0 +1,17 @@
1
+ interface Props {
2
+ /** Text to display as watermark */
3
+ text?: string;
4
+ /** Publication name */
5
+ publicationName?: string;
6
+ /** Publication URL */
7
+ publicationUrl?: string;
8
+ /** Position of watermark */
9
+ position?: 'bottom-right' | 'bottom-left' | 'bottom-center';
10
+ /** Opacity (0-1) */
11
+ opacity?: number;
12
+ /** Has theme applied */
13
+ hasTheme?: boolean;
14
+ }
15
+ declare const Watermark: import("svelte").Component<Props, {}, "">;
16
+ type Watermark = ReturnType<typeof Watermark>;
17
+ export default Watermark;
@@ -9,9 +9,14 @@
9
9
  children: Snippet;
10
10
  class?: string;
11
11
  href?: string;
12
+ /**
13
+ * If true, renders only the themed wrapper without default styles.
14
+ * Useful for custom layouts — pass your own classes via `class`.
15
+ */
16
+ headless?: boolean;
12
17
  }
13
18
 
14
- let { theme, children, class: className = '', href }: Props = $props();
19
+ let { theme, children, class: className = '', href, headless = false }: Props = $props();
15
20
 
16
21
  const themeVars = $derived(theme ? getThemeVars(theme) : {});
17
22
  const hasTheme = $derived(!!theme);
@@ -30,20 +35,19 @@
30
35
  }
31
36
  return base;
32
37
  });
38
+
39
+ // Default styles for non-headless mode
40
+ const defaultStyles = $derived(
41
+ headless
42
+ ? ''
43
+ : 'rounded-lg border p-6 transition-all bg-canvas-50 dark:bg-canvas-950 border-canvas-200 dark:border-canvas-800 hover:border-primary-300 dark:hover:border-primary-700 focus-within:border-primary-300 dark:focus-within:border-primary-700'
44
+ );
33
45
  </script>
34
46
 
35
47
  {#if href}
36
48
  <a {href} class="group block">
37
49
  <article
38
- class="rounded-lg border p-6 transition-all {className}"
39
- class:bg-canvas-50={!hasTheme}
40
- class:dark:bg-canvas-950={!hasTheme}
41
- class:border-canvas-200={!hasTheme}
42
- class:dark:border-canvas-800={!hasTheme}
43
- class:hover:border-primary-300={!hasTheme}
44
- class:dark:hover:border-primary-700={!hasTheme}
45
- class:focus-within:border-primary-300={!hasTheme}
46
- class:dark:focus-within:border-primary-700={!hasTheme}
50
+ class="{defaultStyles} {className}"
47
51
  style:background-color={hasTheme ? 'var(--theme-background)' : undefined}
48
52
  style={allStyles()}
49
53
  >
@@ -52,11 +56,7 @@
52
56
  </a>
53
57
  {:else}
54
58
  <article
55
- class="rounded-lg border p-6 transition-all {className}"
56
- class:bg-canvas-50={!hasTheme}
57
- class:dark:bg-canvas-950={!hasTheme}
58
- class:border-canvas-200={!hasTheme}
59
- class:dark:border-canvas-800={!hasTheme}
59
+ class="{defaultStyles} {className}"
60
60
  style:background-color={hasTheme ? 'var(--theme-background)' : undefined}
61
61
  style={allStyles()}
62
62
  >
@@ -5,6 +5,11 @@ interface Props {
5
5
  children: Snippet;
6
6
  class?: string;
7
7
  href?: string;
8
+ /**
9
+ * If true, renders only the themed wrapper without default styles.
10
+ * Useful for custom layouts — pass your own classes via `class`.
11
+ */
12
+ headless?: boolean;
8
13
  }
9
14
  declare const ThemedCard: import("svelte").Component<Props, {}, "">;
10
15
  type ThemedCard = ReturnType<typeof ThemedCard>;
@@ -6,6 +6,7 @@
6
6
  import CodeBlock from './blocks/CodeBlock.svelte';
7
7
  import MathBlock from './blocks/MathBlock.svelte';
8
8
  import UnorderedListBlock from './blocks/UnorderedListBlock.svelte';
9
+ import OrderedListBlock from './blocks/OrderedListBlock.svelte';
9
10
  import HorizontalRuleBlock from './blocks/HorizontalRuleBlock.svelte';
10
11
  import IframeBlock from './blocks/IframeBlock.svelte';
11
12
  import WebsiteBlock from './blocks/WebsiteBlock.svelte';
@@ -38,6 +39,8 @@
38
39
  <MathBlock {block} {hasTheme} />
39
40
  {:else if block.$type === 'pub.leaflet.blocks.unorderedList'}
40
41
  <UnorderedListBlock {block} {hasTheme} />
42
+ {:else if block.$type === 'pub.leaflet.blocks.orderedList'}
43
+ <OrderedListBlock {block} {hasTheme} {did} {pds} />
41
44
  {:else if block.$type === 'pub.leaflet.blocks.horizontalRule'}
42
45
  <HorizontalRuleBlock {hasTheme} />
43
46
  {:else if block.$type === 'pub.leaflet.blocks.iframe'}
@@ -1,9 +1,17 @@
1
1
  <script lang="ts">
2
+ import { setContext, getContext } from 'svelte';
2
3
  import type { Document } from '../../types.js';
3
4
  import LeafletContentRenderer from './LeafletContentRenderer.svelte';
4
5
  import MarkdownRenderer from './MarkdownRenderer.svelte';
6
+ import Footnotes from '../Footnotes.svelte';
5
7
  import { mixThemeColor } from '../../utils/theme-helpers.js';
6
8
 
9
+ interface FootnoteData {
10
+ footnoteId: string;
11
+ contentPlaintext: string;
12
+ contentFacets?: any[];
13
+ }
14
+
7
15
  interface Props {
8
16
  document: Document;
9
17
  /** DID of the record author — needed to resolve blob URLs */
@@ -20,6 +28,34 @@
20
28
 
21
29
  const { document, did = '', pds = '', hasTheme = false, prerenderedHtml }: Props = $props();
22
30
 
31
+ // Footnote collection state
32
+ let footnoteCounter = $state(0);
33
+ let footnotes = $state<Array<FootnoteData & { number: number }>>([]);
34
+
35
+ // Reset footnotes when document changes
36
+ $effect(() => {
37
+ footnoteCounter = 0;
38
+ footnotes = [];
39
+ });
40
+
41
+ // Set up footnote context for child components
42
+ function registerFootnote(footnote: FootnoteData): number {
43
+ // Check if footnote already registered
44
+ const existing = footnotes.find((f) => f.footnoteId === footnote.footnoteId);
45
+ if (existing) {
46
+ return existing.number;
47
+ }
48
+ footnoteCounter++;
49
+ const newFootnote = { ...footnote, number: footnoteCounter };
50
+ footnotes = [...footnotes, newFootnote];
51
+ return footnoteCounter;
52
+ }
53
+
54
+ setContext('footnotes', {
55
+ registerFootnote,
56
+ getFootnotes: () => footnotes
57
+ });
58
+
23
59
  // Content priority:
24
60
  // 1. pub.leaflet.content — rich Leaflet block tree
25
61
  // 2. textContent — stored as (possibly markdown) text; render via MarkdownRenderer
@@ -60,9 +96,13 @@
60
96
  document.content,
61
97
  null,
62
98
  2
63
- )}</pre>
99
+ )}</pre
100
+ >
64
101
  </div>
65
102
  {:else}
66
103
  <p class="italic opacity-50">No content available</p>
67
104
  {/if}
105
+
106
+ <!-- Footnotes section -->
107
+ <Footnotes {footnotes} {hasTheme} />
68
108
  </div>
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import InlineMath from './InlineMath.svelte';
3
3
  import { UnicodeString } from '@atproto/api';
4
+ import { getContext, setContext } from 'svelte';
4
5
 
5
6
  interface Facet {
6
7
  index: {
@@ -13,13 +14,32 @@
13
14
  }>;
14
15
  }
15
16
 
17
+ interface FootnoteData {
18
+ footnoteId: string;
19
+ contentPlaintext: string;
20
+ contentFacets?: Facet[];
21
+ }
22
+
16
23
  interface Props {
17
24
  plaintext: string;
18
25
  facets?: Facet[];
19
26
  hasTheme?: boolean;
27
+ /** Footnote index in the document (for numbering) */
28
+ footnoteIndex?: number;
29
+ }
30
+
31
+ const { plaintext, facets = [], hasTheme = false, footnoteIndex = 0 }: Props = $props();
32
+
33
+ // Footnote context - allows parent components to collect footnotes
34
+ interface FootnoteContext {
35
+ registerFootnote: (footnote: FootnoteData) => number;
36
+ getFootnotes: () => Array<FootnoteData & { number: number }>;
20
37
  }
21
38
 
22
- const { plaintext, facets = [], hasTheme = false }: Props = $props();
39
+ const footnoteContext = getContext<FootnoteContext | undefined>('footnotes');
40
+
41
+ // Track footnotes found in this text
42
+ const foundFootnotes: Map<string, number> = new Map();
23
43
 
24
44
  interface RichTextSegment {
25
45
  text: string;
@@ -96,10 +116,13 @@
96
116
  isMath: boolean;
97
117
  isDidMention: boolean;
98
118
  isAtMention: boolean;
119
+ isFootnote: boolean;
99
120
  link?: string;
100
121
  id?: string;
101
122
  did?: string;
102
123
  atURI?: string;
124
+ footnote?: FootnoteData;
125
+ footnoteNumber?: number;
103
126
  }
104
127
 
105
128
  function processSegments(): ProcessedSegment[] {
@@ -131,6 +154,26 @@
131
154
  );
132
155
  const isMath = segment.facet?.some((f) => f.$type === 'pub.leaflet.richtext.facet#math');
133
156
 
157
+ // Handle footnote
158
+ const footnoteFeature = segment.facet?.find(
159
+ (f) => f.$type === 'pub.leaflet.richtext.facet#footnote'
160
+ ) as FootnoteFeature | undefined;
161
+
162
+ let footnoteNumber: number | undefined;
163
+ if (footnoteFeature && footnoteContext) {
164
+ if (!foundFootnotes.has(footnoteFeature.footnoteId)) {
165
+ const num = footnoteContext.registerFootnote({
166
+ footnoteId: footnoteFeature.footnoteId,
167
+ contentPlaintext: footnoteFeature.contentPlaintext,
168
+ contentFacets: footnoteFeature.contentFacets
169
+ });
170
+ foundFootnotes.set(footnoteFeature.footnoteId, num);
171
+ footnoteNumber = num;
172
+ } else {
173
+ footnoteNumber = foundFootnotes.get(footnoteFeature.footnoteId);
174
+ }
175
+ }
176
+
134
177
  // Split text by newlines and mark br elements - handle undefined segment.text
135
178
  const segmentText = segment.text || '';
136
179
  const textParts = segmentText.split('\n');
@@ -154,10 +197,19 @@
154
197
  isMath: isMath || false,
155
198
  isDidMention: !!isDidMention,
156
199
  isAtMention: !!isAtMention,
200
+ isFootnote: !!footnoteFeature,
157
201
  link: link?.uri,
158
202
  id: id?.id,
159
203
  did: isDidMention?.did,
160
- atURI: isAtMention?.atURI
204
+ atURI: isAtMention?.atURI,
205
+ footnote: footnoteFeature
206
+ ? {
207
+ footnoteId: footnoteFeature.footnoteId,
208
+ contentPlaintext: footnoteFeature.contentPlaintext,
209
+ contentFacets: footnoteFeature.contentFacets
210
+ }
211
+ : undefined,
212
+ footnoteNumber
161
213
  });
162
214
  }
163
215
 
@@ -165,12 +217,30 @@
165
217
  }
166
218
 
167
219
  const segments = $derived(processSegments());
220
+
221
+ // Footnote feature type
222
+ interface FootnoteFeature {
223
+ $type: 'pub.leaflet.richtext.facet#footnote';
224
+ footnoteId: string;
225
+ contentPlaintext: string;
226
+ contentFacets?: Facet[];
227
+ }
168
228
  </script>
169
229
 
170
230
  {#each segments as segment, i}
171
231
  {#each segment.parts as part, j}
172
232
  {#if part.isBr}
173
233
  <br />
234
+ {:else if segment.isFootnote}
235
+ <!-- Footnote reference -->
236
+ <a
237
+ href="#{segment.footnote?.footnoteId}"
238
+ class="footnote-ref"
239
+ class:themed={hasTheme}
240
+ id="{segment.footnote?.footnoteId}-ref"
241
+ >
242
+ <sup>{segment.footnoteNumber ?? '*'}</sup>
243
+ </a>
174
244
  {:else if segment.isMath}
175
245
  <InlineMath tex={part.text} {hasTheme} />
176
246
  {:else}
@@ -269,4 +339,19 @@
269
339
  -webkit-box-decoration-break: clone;
270
340
  background-color: rgb(255, 177, 177);
271
341
  }
342
+
343
+ .footnote-ref {
344
+ text-decoration: none;
345
+ color: rgb(0 0 225);
346
+ font-size: 0.75em;
347
+ padding: 0 0.1em;
348
+ }
349
+
350
+ .footnote-ref.themed {
351
+ color: var(--theme-accent);
352
+ }
353
+
354
+ .footnote-ref:hover {
355
+ text-decoration: underline;
356
+ }
272
357
  </style>
@@ -12,6 +12,8 @@ interface Props {
12
12
  plaintext: string;
13
13
  facets?: Facet[];
14
14
  hasTheme?: boolean;
15
+ /** Footnote index in the document (for numbering) */
16
+ footnoteIndex?: number;
15
17
  }
16
18
  declare const RichText: import("svelte").Component<Props, {}, "">;
17
19
  type RichText = ReturnType<typeof RichText>;