@brumaombra/ui-vintage 0.7.11 → 0.7.12
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/package.json +3 -2
- package/src/components/blog/BlogContentRenderer.vue +22 -0
- package/src/components/blog/SocialShareSidebar.vue +1 -1
- package/src/components/blog/TableOfContents.vue +351 -19
- package/src/components/content/BlogList.vue +1 -1
- package/src/components/content/BlogTable.vue +1 -1
- package/src/components/content/CodeBlock.vue +122 -0
- package/src/components/content/Flow.vue +99 -0
- package/src/components/content/ProsePre.vue +29 -0
- package/src/components/content/Terminal.vue +1 -1
- package/src/components/content/index.ts +5 -2
- package/src/i18n/de.json +8 -0
- package/src/i18n/en.json +8 -0
- package/src/i18n/es.json +8 -0
- package/src/i18n/fr.json +8 -0
- package/src/i18n/it.json +8 -0
- package/src/i18n/ja.json +8 -0
- package/src/i18n/pt.json +8 -0
- package/src/i18n/ru.json +8 -0
- package/src/i18n/zh.json +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brumaombra/ui-vintage",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.12",
|
|
4
4
|
"description": "Source-published Nuxt UI runtime with shared theme tokens and built-in Nuxt integrations.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"author": "Mauro Brambilla",
|
|
@@ -113,14 +113,15 @@
|
|
|
113
113
|
"vue-i18n": "^9.0.0 || ^10.0.0"
|
|
114
114
|
},
|
|
115
115
|
"dependencies": {
|
|
116
|
-
"@nuxt/kit": "^4.4.4",
|
|
117
116
|
"@hugeicons/core-free-icons": "4.1.1",
|
|
118
117
|
"@hugeicons/vue": "1.0.5",
|
|
119
118
|
"@internationalized/date": "3.10.0",
|
|
119
|
+
"@nuxt/kit": "4.4.4",
|
|
120
120
|
"@vueuse/core": "14.2.1",
|
|
121
121
|
"class-variance-authority": "0.7.1",
|
|
122
122
|
"clsx": "2.1.1",
|
|
123
123
|
"reka-ui": "2.9.2",
|
|
124
|
+
"shiki": "4.4.3",
|
|
124
125
|
"tailwind-merge": "3.5.0",
|
|
125
126
|
"tailwindcss": "4.2.4",
|
|
126
127
|
"tw-animate-css": "1.4.0"
|
|
@@ -15,18 +15,23 @@ const props = defineProps<{
|
|
|
15
15
|
.blog-content {
|
|
16
16
|
--blog-content-text: var(--text-primary-light);
|
|
17
17
|
--blog-content-code-bg: var(--bg-selected-light);
|
|
18
|
+
--blog-content-inline-code-bg: #f9fafb;
|
|
19
|
+
--blog-content-inline-code-border: var(--border-light);
|
|
18
20
|
color: var(--blog-content-text);
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
.dark .blog-content {
|
|
22
24
|
--blog-content-text: var(--text-primary-dark);
|
|
23
25
|
--blog-content-code-bg: var(--bg-selected-dark);
|
|
26
|
+
--blog-content-inline-code-bg: var(--bg-card-dark);
|
|
27
|
+
--blog-content-inline-code-border: var(--border-dark);
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
.blog-content :deep(h1:not(.not-prose):not(.not-prose *)),
|
|
27
31
|
.blog-content :deep(h2:not(.not-prose):not(.not-prose *)),
|
|
28
32
|
.blog-content :deep(h3:not(.not-prose):not(.not-prose *)) {
|
|
29
33
|
color: var(--blog-content-text);
|
|
34
|
+
scroll-margin-top: 6rem;
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
.blog-content :deep(h1:not(.not-prose):not(.not-prose *)) {
|
|
@@ -92,6 +97,23 @@ const props = defineProps<{
|
|
|
92
97
|
}
|
|
93
98
|
|
|
94
99
|
.blog-content :deep(p:not(.not-prose):not(.not-prose *) code:not(.not-prose):not(.not-prose *)),
|
|
100
|
+
.blog-content :deep(li:not(.not-prose):not(.not-prose *) code:not(.not-prose):not(.not-prose *)),
|
|
101
|
+
.blog-content :deep(blockquote:not(.not-prose):not(.not-prose *) code:not(.not-prose):not(.not-prose *)),
|
|
102
|
+
.blog-content :deep(td:not(.not-prose):not(.not-prose *) code:not(.not-prose):not(.not-prose *)),
|
|
103
|
+
.blog-content :deep(th:not(.not-prose):not(.not-prose *) code:not(.not-prose):not(.not-prose *)) {
|
|
104
|
+
color: var(--blog-content-text);
|
|
105
|
+
background-color: var(--blog-content-inline-code-bg);
|
|
106
|
+
border: 1px solid var(--blog-content-inline-code-border);
|
|
107
|
+
border-radius: 0.25rem;
|
|
108
|
+
display: inline-block;
|
|
109
|
+
font-size: 0.75rem;
|
|
110
|
+
font-weight: 600;
|
|
111
|
+
line-height: 1rem;
|
|
112
|
+
padding: 0.125rem 0.5rem;
|
|
113
|
+
transition: color 0.3s ease-in-out, background-color 0.3s ease-in-out, border-color 0.3s ease-in-out;
|
|
114
|
+
white-space: nowrap;
|
|
115
|
+
}
|
|
116
|
+
|
|
95
117
|
.blog-content :deep(pre:not(.not-prose):not(.not-prose *) code:not(.not-prose):not(.not-prose *)) {
|
|
96
118
|
color: var(--blog-content-text);
|
|
97
119
|
background-color: var(--blog-content-code-bg);
|
|
@@ -55,7 +55,7 @@ const socialPlatforms = computed(() => {
|
|
|
55
55
|
<template>
|
|
56
56
|
<Teleport to="body">
|
|
57
57
|
<!-- Desktop sidebar on the right -->
|
|
58
|
-
<div class="fixed right-[calc(50%-
|
|
58
|
+
<div class="fixed right-[max(6.875rem,calc(50%-38.125rem))] top-35 z-20 hidden xl:block 2xl:right-[max(0rem,calc(50%-35.125rem))]">
|
|
59
59
|
<Card class="p-5! flex flex-col space-y-4 items-center">
|
|
60
60
|
<CardContent class="p-0! gap-4 items-center">
|
|
61
61
|
<!-- Share icon -->
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed } from 'vue';
|
|
2
|
+
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
|
3
3
|
import { useI18n } from 'vue-i18n';
|
|
4
|
-
import { Bookmark01Icon } from '@hugeicons/core-free-icons';
|
|
4
|
+
import { ArrowRight01Icon, Bookmark01Icon, Cancel01Icon } from '@hugeicons/core-free-icons';
|
|
5
|
+
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
5
6
|
import { Accordion } from '../ui/accordion';
|
|
7
|
+
import { Button } from '../ui/button';
|
|
8
|
+
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '../ui/sheet';
|
|
6
9
|
|
|
7
10
|
interface BlogTocLink {
|
|
8
11
|
id?: string;
|
|
@@ -11,6 +14,12 @@ interface BlogTocLink {
|
|
|
11
14
|
children?: BlogTocLink[];
|
|
12
15
|
}
|
|
13
16
|
|
|
17
|
+
interface TocHeading {
|
|
18
|
+
id: string;
|
|
19
|
+
text: string;
|
|
20
|
+
level: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
// Props
|
|
15
24
|
const props = defineProps<{
|
|
16
25
|
content: {
|
|
@@ -27,12 +36,33 @@ const props = defineProps<{
|
|
|
27
36
|
|
|
28
37
|
const { t } = useI18n();
|
|
29
38
|
|
|
39
|
+
const DESKTOP_MEDIA_QUERY = '(min-width: 1280px)';
|
|
40
|
+
const HEADING_OBSERVER_RETRY_MS = 100;
|
|
41
|
+
const MAX_HEADING_OBSERVER_RETRIES = 20;
|
|
42
|
+
const MOBILE_CLOSE_SCROLL_DELAY_MS = 320;
|
|
43
|
+
const ACTIVE_HEADING_OFFSET_PX = 120;
|
|
44
|
+
|
|
45
|
+
const inlineRef = ref<HTMLElement | null>(null);
|
|
46
|
+
const mobileTriggerRef = ref<HTMLElement | null>(null);
|
|
47
|
+
const isDesktop = ref(false);
|
|
48
|
+
const isDocked = ref(false);
|
|
49
|
+
const isMobileOpen = ref(false);
|
|
50
|
+
const activeHeadingId = ref('');
|
|
51
|
+
const visibleHeadingIds = ref<Set<string>>(new Set());
|
|
52
|
+
|
|
53
|
+
let desktopMediaQuery: MediaQueryList | null = null;
|
|
54
|
+
let sentinelObserver: IntersectionObserver | null = null;
|
|
55
|
+
let headingObserver: IntersectionObserver | null = null;
|
|
56
|
+
let headingRetryTimeout = 0;
|
|
57
|
+
let mobileScrollTimeout = 0;
|
|
58
|
+
let restoreMobileTriggerFocus = true;
|
|
59
|
+
|
|
30
60
|
// Create the headings list
|
|
31
61
|
const headings = computed(() => {
|
|
32
62
|
const tocLinks = props.content?.body?.toc?.links || props.content?.toc?.links || [];
|
|
33
63
|
|
|
34
64
|
// Recursive function to flatten the nested links
|
|
35
|
-
const flattenLinks = (links: BlogTocLink[]):
|
|
65
|
+
const flattenLinks = (links: BlogTocLink[]): TocHeading[] => {
|
|
36
66
|
return links.flatMap(link => {
|
|
37
67
|
// Extract the current heading
|
|
38
68
|
const currentHeading = {
|
|
@@ -51,22 +81,212 @@ const headings = computed(() => {
|
|
|
51
81
|
return flattenLinks(tocLinks).filter(heading => heading.id && heading.text && heading.level >= 2 && heading.level <= 3);
|
|
52
82
|
});
|
|
53
83
|
|
|
54
|
-
//
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
84
|
+
// Title and description for the table of contents
|
|
85
|
+
const tocTitle = computed(() => t('uiVintage.blog.tableOfContents'));
|
|
86
|
+
const tocDescription = computed(() => t('uiVintage.blog.tableOfContentsDescription'));
|
|
87
|
+
|
|
88
|
+
// Prefer reduced motion for heading scroll
|
|
89
|
+
const prefersReducedMotion = () => {
|
|
90
|
+
return Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// Resolve the heading that should be marked as the current chapter
|
|
94
|
+
const resolveActiveHeadingId = () => {
|
|
95
|
+
if (!headings.value.length) return '';
|
|
96
|
+
|
|
97
|
+
// Filter the headings to only those currently visible in the viewport
|
|
98
|
+
const visibleHeadings = headings.value.filter(heading => visibleHeadingIds.value.has(heading.id));
|
|
99
|
+
if (visibleHeadings.length > 0) {
|
|
100
|
+
return visibleHeadings[0].id;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// If no headings are currently visible, fall back to the first heading in the list
|
|
104
|
+
let currentId = headings.value[0].id;
|
|
105
|
+
for (const heading of headings.value) {
|
|
106
|
+
const element = document.getElementById(heading.id);
|
|
107
|
+
if (element && element.getBoundingClientRect().top <= ACTIVE_HEADING_OFFSET_PX) {
|
|
108
|
+
currentId = heading.id;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Return the resolved current heading ID
|
|
113
|
+
return currentId;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Update the active heading based on the current scroll position
|
|
117
|
+
const updateActiveHeading = () => {
|
|
118
|
+
activeHeadingId.value = resolveActiveHeadingId();
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Track which headings currently intersect the reading window
|
|
122
|
+
const handleHeadingIntersect = (entries: IntersectionObserverEntry[]) => {
|
|
123
|
+
const nextVisibleIds = new Set(visibleHeadingIds.value);
|
|
124
|
+
|
|
125
|
+
// Update the set of visible heading IDs
|
|
126
|
+
for (const entry of entries) {
|
|
127
|
+
if (entry.isIntersecting) {
|
|
128
|
+
nextVisibleIds.add(entry.target.id);
|
|
129
|
+
} else {
|
|
130
|
+
nextVisibleIds.delete(entry.target.id);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Apply the updated set
|
|
135
|
+
visibleHeadingIds.value = nextVisibleIds;
|
|
136
|
+
updateActiveHeading();
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Disconnect the heading observer and clear any pending retries
|
|
140
|
+
const disconnectHeadingObserver = () => {
|
|
141
|
+
headingObserver?.disconnect();
|
|
142
|
+
headingObserver = null;
|
|
143
|
+
window.clearTimeout(headingRetryTimeout);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Bind the heading observer to track which headings are visible in the viewport
|
|
147
|
+
const bindHeadingObserver = () => {
|
|
148
|
+
disconnectHeadingObserver();
|
|
149
|
+
|
|
150
|
+
// If there are no headings, clear the active heading and visible heading IDs
|
|
151
|
+
if (!headings.value.length) {
|
|
152
|
+
activeHeadingId.value = '';
|
|
153
|
+
visibleHeadingIds.value = new Set();
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Get the DOM elements corresponding to the headings
|
|
158
|
+
const elements = headings.value
|
|
159
|
+
.map(heading => document.getElementById(heading.id))
|
|
160
|
+
.filter((element): element is HTMLElement => Boolean(element));
|
|
161
|
+
|
|
162
|
+
// Content renderer mounts after this component, so headings may not exist yet
|
|
163
|
+
if (!elements.length) return false;
|
|
164
|
+
|
|
165
|
+
headingObserver = new IntersectionObserver(handleHeadingIntersect, {
|
|
166
|
+
root: null,
|
|
167
|
+
rootMargin: '-20% 0px -65% 0px',
|
|
168
|
+
threshold: [0, 1]
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
for (const element of elements) {
|
|
172
|
+
headingObserver.observe(element);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!activeHeadingId.value) {
|
|
176
|
+
activeHeadingId.value = headings.value[0].id;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
updateActiveHeading();
|
|
180
|
+
return true;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// Retry binding until the content renderer has mounted its headings
|
|
184
|
+
const scheduleHeadingObserver = (attempt = 0) => {
|
|
185
|
+
nextTick(() => {
|
|
186
|
+
if (bindHeadingObserver()) return;
|
|
187
|
+
if (attempt >= MAX_HEADING_OBSERVER_RETRIES) return;
|
|
188
|
+
|
|
189
|
+
headingRetryTimeout = window.setTimeout(() => {
|
|
190
|
+
scheduleHeadingObserver(attempt + 1);
|
|
191
|
+
}, HEADING_OBSERVER_RETRY_MS);
|
|
192
|
+
});
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// Disconnect the observer that controls the mobile trigger visibility
|
|
196
|
+
const disconnectSentinelObserver = () => {
|
|
197
|
+
sentinelObserver?.disconnect();
|
|
198
|
+
sentinelObserver = null;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// Show the mobile trigger after the in-flow accordion leaves the viewport
|
|
202
|
+
const bindSentinelObserver = () => {
|
|
203
|
+
disconnectSentinelObserver();
|
|
204
|
+
|
|
205
|
+
if (!inlineRef.value) {
|
|
206
|
+
isDocked.value = false;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Show the mobile control once the in-flow accordion has scrolled completely out of view
|
|
211
|
+
sentinelObserver = new IntersectionObserver(([entry]) => {
|
|
212
|
+
const hasScrolledPast = !entry.isIntersecting && entry.boundingClientRect.top < 0;
|
|
213
|
+
isDocked.value = hasScrolledPast;
|
|
214
|
+
}, {
|
|
215
|
+
threshold: 0
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
sentinelObserver.observe(inlineRef.value);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// Keep the mobile trigger state in sync with the viewport breakpoint
|
|
222
|
+
const handleDesktopMediaChange = (event: MediaQueryList | MediaQueryListEvent) => {
|
|
223
|
+
isDesktop.value = event.matches;
|
|
224
|
+
|
|
225
|
+
if (event.matches) {
|
|
226
|
+
isMobileOpen.value = false;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
nextTick(() => {
|
|
230
|
+
bindSentinelObserver();
|
|
231
|
+
});
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// Synchronize the mobile sheet state and restore focus after dismissal
|
|
235
|
+
const handleMobileOpenChange = (open: boolean) => {
|
|
236
|
+
isMobileOpen.value = open;
|
|
237
|
+
|
|
238
|
+
// Return focus to the persistent control when the overlay is dismissed
|
|
239
|
+
if (!open && restoreMobileTriggerFocus) {
|
|
240
|
+
mobileTriggerRef.value?.focus();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
restoreMobileTriggerFocus = true;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// Scroll to a heading and optionally close the mobile sheet first
|
|
247
|
+
const scrollToHeading = (id: string, options: { closeMobile?: boolean } = {}) => {
|
|
248
|
+
const runScroll = () => {
|
|
249
|
+
const element = document.getElementById(id);
|
|
250
|
+
if (!element) return;
|
|
251
|
+
|
|
252
|
+
if (!element.hasAttribute('tabindex')) {
|
|
253
|
+
element.setAttribute('tabindex', '-1');
|
|
254
|
+
}
|
|
255
|
+
|
|
58
256
|
element.scrollIntoView({
|
|
59
|
-
behavior: 'smooth',
|
|
257
|
+
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
|
|
60
258
|
block: 'start',
|
|
61
259
|
inline: 'nearest'
|
|
62
260
|
});
|
|
261
|
+
element.focus({ preventScroll: true });
|
|
262
|
+
activeHeadingId.value = id;
|
|
263
|
+
|
|
264
|
+
if (window.location.hash !== `#${id}`) {
|
|
265
|
+
history.replaceState(null, '', `#${id}`);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
restoreMobileTriggerFocus = false;
|
|
269
|
+
|
|
270
|
+
if (options.closeMobile && isMobileOpen.value) {
|
|
271
|
+
mobileTriggerRef.value?.focus();
|
|
272
|
+
isMobileOpen.value = false;
|
|
273
|
+
window.clearTimeout(mobileScrollTimeout);
|
|
274
|
+
mobileScrollTimeout = window.setTimeout(runScroll, MOBILE_CLOSE_SCROLL_DELAY_MS);
|
|
275
|
+
return;
|
|
63
276
|
}
|
|
277
|
+
|
|
278
|
+
runScroll();
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// Open the mobile table of contents sheet
|
|
282
|
+
const openMobileToc = () => {
|
|
283
|
+
isMobileOpen.value = true;
|
|
64
284
|
};
|
|
65
285
|
|
|
66
286
|
// Get classes for heading button
|
|
67
|
-
const getHeadingButtonClasses = (level: number) => {
|
|
287
|
+
const getHeadingButtonClasses = (level: number, isActive: boolean) => {
|
|
68
288
|
// Base classes for all titles
|
|
69
|
-
const baseClasses = 'block w-full text-left px-
|
|
289
|
+
const baseClasses = 'block w-full text-left px-2.5 py-2 md:px-3 md:py-2 rounded border-l-2 border-transparent transition-all duration-200 hover:bg-(--bg-selected-light) dark:hover:bg-(--bg-selected-dark) cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 motion-reduce:transition-none';
|
|
70
290
|
|
|
71
291
|
// Add classes based on heading level
|
|
72
292
|
let levelClasses = '';
|
|
@@ -76,17 +296,129 @@ const getHeadingButtonClasses = (level: number) => {
|
|
|
76
296
|
levelClasses = 'text-(--text-secondary-light) dark:text-(--text-secondary-dark) pl-6! text-xs md:text-sm';
|
|
77
297
|
}
|
|
78
298
|
|
|
299
|
+
// Add classes if the heading is active
|
|
300
|
+
const activeClasses = isActive ? 'bg-(--bg-selected-light) dark:bg-(--bg-selected-dark) border-l-primary text-(--text-primary-light) dark:text-(--text-primary-dark)' : '';
|
|
301
|
+
|
|
79
302
|
// Return combined classes
|
|
80
|
-
return [baseClasses, levelClasses];
|
|
303
|
+
return [baseClasses, levelClasses, activeClasses];
|
|
81
304
|
};
|
|
305
|
+
|
|
306
|
+
// Watch for changes in the headings and update the visible heading IDs and active heading ID accordingly
|
|
307
|
+
watch(headings, () => {
|
|
308
|
+
visibleHeadingIds.value = new Set();
|
|
309
|
+
activeHeadingId.value = headings.value[0]?.id ?? '';
|
|
310
|
+
scheduleHeadingObserver();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Watch for changes in the inline table of contents reference and bind the sentinel observer accordingly
|
|
314
|
+
watch(inlineRef, () => {
|
|
315
|
+
bindSentinelObserver();
|
|
316
|
+
}, { flush: 'post' });
|
|
317
|
+
|
|
318
|
+
// On component mounted
|
|
319
|
+
onMounted(() => {
|
|
320
|
+
desktopMediaQuery = window.matchMedia(DESKTOP_MEDIA_QUERY);
|
|
321
|
+
handleDesktopMediaChange(desktopMediaQuery);
|
|
322
|
+
desktopMediaQuery.addEventListener('change', handleDesktopMediaChange);
|
|
323
|
+
|
|
324
|
+
const hashId = window.location.hash.replace(/^#/, '');
|
|
325
|
+
if (hashId && headings.value.some(heading => heading.id === hashId)) {
|
|
326
|
+
activeHeadingId.value = hashId;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
scheduleHeadingObserver();
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// On component unmounted
|
|
333
|
+
onUnmounted(() => {
|
|
334
|
+
desktopMediaQuery?.removeEventListener('change', handleDesktopMediaChange);
|
|
335
|
+
disconnectSentinelObserver();
|
|
336
|
+
disconnectHeadingObserver();
|
|
337
|
+
window.clearTimeout(mobileScrollTimeout);
|
|
338
|
+
});
|
|
82
339
|
</script>
|
|
83
340
|
|
|
84
341
|
<template>
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
</
|
|
342
|
+
<div v-if="headings.length > 0">
|
|
343
|
+
<!-- In-flow accordion; replaced by the mobile control after it scrolls away -->
|
|
344
|
+
<div ref="inlineRef" class="mb-6" :aria-hidden="isDocked" :inert="isDocked">
|
|
345
|
+
<Accordion :title="tocTitle" :icon="Bookmark01Icon">
|
|
346
|
+
<nav :aria-label="tocTitle" class="space-y-1">
|
|
347
|
+
<button v-for="heading in headings" :key="`inline-${heading.id}`" type="button" :class="getHeadingButtonClasses(heading.level, heading.id === activeHeadingId)" :aria-current="heading.id === activeHeadingId ? 'location' : undefined" @click="scrollToHeading(heading.id)">
|
|
348
|
+
{{ heading.text }}
|
|
349
|
+
</button>
|
|
350
|
+
</nav>
|
|
351
|
+
</Accordion>
|
|
352
|
+
</div>
|
|
353
|
+
|
|
354
|
+
<Teleport to="body">
|
|
355
|
+
<!-- Mobile persistent control -->
|
|
356
|
+
<Transition name="toc-tab">
|
|
357
|
+
<button v-if="!isDesktop && isDocked" ref="mobileTriggerRef" type="button" class="fixed top-1/2 left-0 z-20 flex h-16 w-8 -translate-y-1/2 items-center justify-center rounded-r border border-l-0 border-(--border-light) bg-(--bg-card-light) text-(--text-secondary-light) transition-[background-color,color,transform] duration-200 hover:bg-(--bg-selected-light) hover:text-(--text-primary-light) focus-visible:z-30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 dark:border-(--border-dark) dark:bg-(--bg-card-dark) dark:text-(--text-secondary-dark) dark:hover:bg-(--bg-selected-dark) dark:hover:text-(--text-primary-dark) motion-reduce:transition-none" :aria-label="t('uiVintage.blog.openTableOfContents')" :aria-expanded="isMobileOpen" aria-haspopup="dialog" aria-controls="toc-mobile-overlay" @click="openMobileToc">
|
|
358
|
+
<HugeiconsIcon :icon="ArrowRight01Icon" class="size-4" aria-hidden="true" />
|
|
359
|
+
</button>
|
|
360
|
+
</Transition>
|
|
361
|
+
|
|
362
|
+
<!-- Mobile full-screen overlay -->
|
|
363
|
+
<Sheet :open="isMobileOpen" @update:open="handleMobileOpenChange">
|
|
364
|
+
<SheetContent id="toc-mobile-overlay" side="left" class="flex h-full w-full max-w-none flex-col gap-0 border-0 bg-(--bg-card-light) p-0 dark:bg-(--bg-card-dark) sm:max-w-none">
|
|
365
|
+
<SheetHeader class="flex-row items-center justify-between gap-4 border-b border-(--border-light) p-5 dark:border-(--border-dark)">
|
|
366
|
+
<!-- Mobile table of contents header -->
|
|
367
|
+
<div class="flex min-w-0 items-center gap-3">
|
|
368
|
+
<!-- Mobile table of contents icon -->
|
|
369
|
+
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded border border-(--border-light) bg-(--bg-card-light) dark:border-(--border-dark) dark:bg-(--bg-card-dark)">
|
|
370
|
+
<HugeiconsIcon :icon="Bookmark01Icon" class="size-4 text-(--text-secondary-light) dark:text-(--text-secondary-dark)" />
|
|
371
|
+
</div>
|
|
372
|
+
|
|
373
|
+
<!-- Mobile table of contents title and description -->
|
|
374
|
+
<div class="min-w-0">
|
|
375
|
+
<SheetTitle class="text-sm md:text-base! font-semibold text-(--text-primary-light) dark:text-(--text-primary-dark)">
|
|
376
|
+
{{ tocTitle }}
|
|
377
|
+
</SheetTitle>
|
|
378
|
+
<SheetDescription class="sr-only">
|
|
379
|
+
{{ tocDescription }}
|
|
380
|
+
</SheetDescription>
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
|
|
384
|
+
<!-- Close button for the mobile table of contents -->
|
|
385
|
+
<SheetClose as-child>
|
|
386
|
+
<Button type="button" variant="ghost" size="icon-sm" :aria-label="t('uiVintage.blog.closeTableOfContents')">
|
|
387
|
+
<HugeiconsIcon :icon="Cancel01Icon" class="size-4" />
|
|
388
|
+
</Button>
|
|
389
|
+
</SheetClose>
|
|
390
|
+
</SheetHeader>
|
|
391
|
+
|
|
392
|
+
<!-- Mobile table of contents navigation -->
|
|
393
|
+
<nav :aria-label="tocTitle" class="flex-1 overflow-y-auto p-5">
|
|
394
|
+
<div class="space-y-1">
|
|
395
|
+
<button v-for="heading in headings" :key="`mobile-${heading.id}`" type="button" :class="getHeadingButtonClasses(heading.level, heading.id === activeHeadingId)" :aria-current="heading.id === activeHeadingId ? 'location' : undefined" @click="scrollToHeading(heading.id, { closeMobile: true })">
|
|
396
|
+
{{ heading.text }}
|
|
397
|
+
</button>
|
|
398
|
+
</div>
|
|
399
|
+
</nav>
|
|
400
|
+
</SheetContent>
|
|
401
|
+
</Sheet>
|
|
402
|
+
</Teleport>
|
|
403
|
+
</div>
|
|
404
|
+
</template>
|
|
405
|
+
|
|
406
|
+
<style scoped>
|
|
407
|
+
.toc-tab-enter-active,
|
|
408
|
+
.toc-tab-leave-active {
|
|
409
|
+
transition: opacity 0.25s ease;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
.toc-tab-enter-from,
|
|
413
|
+
.toc-tab-leave-to {
|
|
414
|
+
opacity: 0;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
@media (prefers-reduced-motion: reduce) {
|
|
418
|
+
|
|
419
|
+
.toc-tab-enter-active,
|
|
420
|
+
.toc-tab-leave-active {
|
|
421
|
+
transition: none;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
</style>
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { CheckmarkCircle02Icon, Copy01Icon, CodeIcon } from '@hugeicons/core-free-icons';
|
|
3
|
+
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
4
|
+
import { codeToHtml } from 'shiki';
|
|
5
|
+
import { computed, ref, watch } from 'vue';
|
|
6
|
+
import { useI18n } from 'vue-i18n';
|
|
7
|
+
import { Button } from '../ui/button';
|
|
8
|
+
import { Card } from '../ui/card';
|
|
9
|
+
|
|
10
|
+
type CodeLanguage = string;
|
|
11
|
+
|
|
12
|
+
// Props
|
|
13
|
+
const props = withDefaults(defineProps<{
|
|
14
|
+
code?: string;
|
|
15
|
+
language?: CodeLanguage;
|
|
16
|
+
title?: string;
|
|
17
|
+
copyable?: boolean;
|
|
18
|
+
}>(), {
|
|
19
|
+
code: '',
|
|
20
|
+
language: 'text',
|
|
21
|
+
title: '',
|
|
22
|
+
copyable: true
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const { t } = useI18n();
|
|
26
|
+
const copied = ref(false);
|
|
27
|
+
const highlightedCode = ref('');
|
|
28
|
+
let highlightRequest = 0;
|
|
29
|
+
const resolvedTitle = computed(() => props.title || t('uiVintage.code.title'));
|
|
30
|
+
const displayedCode = computed(() => {
|
|
31
|
+
if (props.language !== 'json') return props.code;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
return JSON.stringify(JSON.parse(props.code), null, 2);
|
|
35
|
+
} catch {
|
|
36
|
+
return props.code;
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Highlight code in the library so all consumers share the same rendering behavior.
|
|
41
|
+
const highlightCode = async () => {
|
|
42
|
+
const requestId = ++highlightRequest;
|
|
43
|
+
highlightedCode.value = '';
|
|
44
|
+
|
|
45
|
+
if (!displayedCode.value || props.language === 'text') return;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const html = await codeToHtml(displayedCode.value, {
|
|
49
|
+
lang: props.language,
|
|
50
|
+
theme: 'github-dark'
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
if (requestId === highlightRequest) {
|
|
54
|
+
highlightedCode.value = html;
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// Keep the escaped plain-text fallback for unsupported languages.
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
watch([displayedCode, () => props.language], highlightCode, { immediate: true });
|
|
62
|
+
|
|
63
|
+
// Copy the formatted code to the clipboard
|
|
64
|
+
const copyCode = async () => {
|
|
65
|
+
if (!props.copyable || !displayedCode.value || !navigator.clipboard) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
await navigator.clipboard.writeText(displayedCode.value);
|
|
70
|
+
copied.value = true;
|
|
71
|
+
window.setTimeout(() => {
|
|
72
|
+
copied.value = false;
|
|
73
|
+
}, 1800);
|
|
74
|
+
};
|
|
75
|
+
</script>
|
|
76
|
+
|
|
77
|
+
<template>
|
|
78
|
+
<div class="not-prose my-6 sm:my-8">
|
|
79
|
+
<Card class="gap-0! sm:gap-0! overflow-hidden p-0! text-(--text-primary-light) dark:text-(--text-primary-dark)">
|
|
80
|
+
<!-- Code header -->
|
|
81
|
+
<div class="flex items-center justify-between gap-3 border-b border-(--border-light) bg-(--bg-card-light) px-3 py-2 sm:px-4 dark:border-(--border-dark) dark:bg-(--bg-card-dark)">
|
|
82
|
+
<div class="flex min-w-0 items-center gap-2">
|
|
83
|
+
<HugeiconsIcon :icon="CodeIcon" class="size-4 shrink-0 text-(--text-secondary-light) dark:text-(--text-secondary-dark)" />
|
|
84
|
+
<span class="truncate text-xs font-semibold text-(--text-secondary-light) dark:text-(--text-secondary-dark)">{{ resolvedTitle }}</span>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<!-- Copy code button -->
|
|
88
|
+
<Button v-if="props.copyable"
|
|
89
|
+
variant="ghost"
|
|
90
|
+
size="icon"
|
|
91
|
+
class="size-7 text-(--text-secondary-light) hover:bg-(--bg-selected-light) hover:text-(--text-primary-light) dark:text-(--text-secondary-dark) dark:hover:bg-(--bg-selected-dark) dark:hover:text-(--text-primary-dark)"
|
|
92
|
+
:aria-label="t('uiVintage.code.copy')"
|
|
93
|
+
:title="t('uiVintage.code.copy')"
|
|
94
|
+
:disabled="!displayedCode"
|
|
95
|
+
@click="copyCode">
|
|
96
|
+
<HugeiconsIcon :icon="copied ? CheckmarkCircle02Icon : Copy01Icon" class="size-4" />
|
|
97
|
+
</Button>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<!-- Code content -->
|
|
101
|
+
<div v-if="highlightedCode" class="code-block-content overflow-x-auto bg-(--bg-surface-light) dark:bg-(--bg-surface-dark)" v-html="highlightedCode" />
|
|
102
|
+
<pre v-else class="code-block-content overflow-x-auto bg-(--bg-surface-light) px-4 py-3 text-xs leading-6 dark:bg-(--bg-surface-dark) sm:px-5 sm:py-4 sm:text-sm"><code>{{ displayedCode }}</code></pre>
|
|
103
|
+
</Card>
|
|
104
|
+
</div>
|
|
105
|
+
</template>
|
|
106
|
+
|
|
107
|
+
<style scoped>
|
|
108
|
+
.code-block-content :deep(pre) {
|
|
109
|
+
margin: 0;
|
|
110
|
+
min-width: max-content;
|
|
111
|
+
padding: 0.75rem 1rem;
|
|
112
|
+
font-size: 0.75rem;
|
|
113
|
+
line-height: 1.5rem;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
@media (min-width: 640px) {
|
|
117
|
+
.code-block-content :deep(pre) {
|
|
118
|
+
padding: 1rem 1.25rem;
|
|
119
|
+
font-size: 0.875rem;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
</style>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ArrowDown01Icon, ArrowRight01Icon } from '@hugeicons/core-free-icons';
|
|
3
|
+
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
4
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
|
|
5
|
+
|
|
6
|
+
type FlowItem = string | {
|
|
7
|
+
label?: string;
|
|
8
|
+
title?: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// Props
|
|
13
|
+
const props = withDefaults(defineProps<{
|
|
14
|
+
title?: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
items?: FlowItem[];
|
|
17
|
+
orientation?: 'horizontal' | 'vertical';
|
|
18
|
+
}>(), {
|
|
19
|
+
title: '',
|
|
20
|
+
description: '',
|
|
21
|
+
items: () => [],
|
|
22
|
+
orientation: 'horizontal'
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Get item label
|
|
26
|
+
const getItemLabel = (item: FlowItem) => {
|
|
27
|
+
if (typeof item === 'string') return item;
|
|
28
|
+
return item.label || item.title || '';
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Get item description
|
|
32
|
+
const getItemDescription = (item: FlowItem) => {
|
|
33
|
+
if (typeof item === 'string') return '';
|
|
34
|
+
return item.description || '';
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Check if orientation is vertical
|
|
38
|
+
const isVertical = () => {
|
|
39
|
+
return props.orientation === 'vertical';
|
|
40
|
+
};
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<template>
|
|
44
|
+
<div class="not-prose my-6 sm:my-8">
|
|
45
|
+
<Card>
|
|
46
|
+
<!-- Card header -->
|
|
47
|
+
<CardHeader v-if="props.title || props.description">
|
|
48
|
+
<!-- Card title -->
|
|
49
|
+
<CardTitle v-if="props.title">
|
|
50
|
+
{{ props.title }}
|
|
51
|
+
</CardTitle>
|
|
52
|
+
|
|
53
|
+
<!-- Card description -->
|
|
54
|
+
<CardDescription v-if="props.description">
|
|
55
|
+
{{ props.description }}
|
|
56
|
+
</CardDescription>
|
|
57
|
+
</CardHeader>
|
|
58
|
+
|
|
59
|
+
<!-- Card content -->
|
|
60
|
+
<CardContent>
|
|
61
|
+
<div :class="[isVertical() ? 'flex flex-col gap-3' : 'flex flex-col gap-3 md:flex-row md:items-stretch']">
|
|
62
|
+
<!-- Flow items -->
|
|
63
|
+
<template v-for="(item, index) in props.items" :key="`${getItemLabel(item)}-${index}`">
|
|
64
|
+
<!-- Flow item -->
|
|
65
|
+
<Card class="flex-1 overflow-hidden md:flex md:items-center">
|
|
66
|
+
<CardContent class="w-full gap-3 md:justify-center">
|
|
67
|
+
<!-- Flow item header -->
|
|
68
|
+
<div class="inline-flex items-center gap-3 text-xs font-semibold text-(--text-primary-light) dark:text-(--text-primary-dark)">
|
|
69
|
+
<!-- Flow item number -->
|
|
70
|
+
<span class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded border border-(--border-light) bg-(--bg-card-light) text-xs font-semibold text-(--text-secondary-light) dark:border-(--border-dark) dark:bg-(--bg-card-dark) dark:text-(--text-secondary-dark)">
|
|
71
|
+
{{ index + 1 }}
|
|
72
|
+
</span>
|
|
73
|
+
|
|
74
|
+
<!-- Flow item label -->
|
|
75
|
+
<span>{{ getItemLabel(item) }}</span>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<!-- Flow item description -->
|
|
79
|
+
<p v-if="getItemDescription(item)" class="text-xs leading-6 text-(--text-secondary-light) dark:text-(--text-secondary-dark) md:leading-5">
|
|
80
|
+
{{ getItemDescription(item) }}
|
|
81
|
+
</p>
|
|
82
|
+
</CardContent>
|
|
83
|
+
</Card>
|
|
84
|
+
|
|
85
|
+
<!-- Flow item separator -->
|
|
86
|
+
<div v-if="index < props.items.length - 1" class="flex items-center justify-center text-(--text-secondary-light) dark:text-(--text-secondary-dark)">
|
|
87
|
+
<!-- Flow item separator for vertical orientation -->
|
|
88
|
+
<HugeiconsIcon v-if="isVertical()" :icon="ArrowDown01Icon" class="size-5 shrink-0" />
|
|
89
|
+
|
|
90
|
+
<!-- Flow item separator for horizontal orientation -->
|
|
91
|
+
<HugeiconsIcon v-else :icon="ArrowRight01Icon" class="hidden size-5 shrink-0 md:block" />
|
|
92
|
+
<HugeiconsIcon v-if="!isVertical()" :icon="ArrowDown01Icon" class="size-5 shrink-0 md:hidden" />
|
|
93
|
+
</div>
|
|
94
|
+
</template>
|
|
95
|
+
</div>
|
|
96
|
+
</CardContent>
|
|
97
|
+
</Card>
|
|
98
|
+
</div>
|
|
99
|
+
</template>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
import CodeBlock from './CodeBlock.vue';
|
|
4
|
+
import Terminal from './Terminal.vue';
|
|
5
|
+
|
|
6
|
+
// Props
|
|
7
|
+
const props = withDefaults(defineProps<{
|
|
8
|
+
code?: string;
|
|
9
|
+
language?: string;
|
|
10
|
+
}>(), {
|
|
11
|
+
code: '',
|
|
12
|
+
language: ''
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const normalizedLanguage = computed(() => props.language.toLowerCase());
|
|
16
|
+
const isShellBlock = computed(() => ['bash', 'sh', 'shell'].includes(normalizedLanguage.value));
|
|
17
|
+
const commands = computed(() => props.code.replace(/\n$/, '').split(/\r?\n/));
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<!-- Terminal value -->
|
|
22
|
+
<Terminal v-if="isShellBlock" :commands="commands" />
|
|
23
|
+
|
|
24
|
+
<!-- Code block -->
|
|
25
|
+
<CodeBlock v-else-if="normalizedLanguage" :code="props.code" :language="normalizedLanguage" />
|
|
26
|
+
|
|
27
|
+
<!-- Fallback pre block -->
|
|
28
|
+
<pre v-else><slot /></pre>
|
|
29
|
+
</template>
|
|
@@ -41,7 +41,7 @@ const copyCommands = async () => {
|
|
|
41
41
|
</script>
|
|
42
42
|
|
|
43
43
|
<template>
|
|
44
|
-
<div class="not-prose my-6 sm:my-
|
|
44
|
+
<div class="not-prose my-6 sm:my-8">
|
|
45
45
|
<Card class="gap-0! sm:gap-0! overflow-hidden p-0! text-(--text-primary-light) dark:text-(--text-primary-dark)">
|
|
46
46
|
<!-- Terminal header -->
|
|
47
47
|
<div class="flex items-center justify-between gap-3 border-b border-(--border-light) bg-(--bg-card-light) px-3 py-2 sm:px-4 dark:border-(--border-dark) dark:bg-(--bg-card-dark)">
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
export { default as Terminal } from './Terminal.vue';
|
|
2
|
+
export { default as CodeBlock } from './CodeBlock.vue';
|
|
3
|
+
export { default as ProsePre } from './ProsePre.vue';
|
|
1
4
|
export { default as BlogList } from './BlogList.vue';
|
|
2
5
|
export { default as BlogTable } from './BlogTable.vue';
|
|
3
|
-
export { default as
|
|
4
|
-
export { default as
|
|
6
|
+
export { default as Flow } from './Flow.vue';
|
|
7
|
+
export { default as ProseHr } from './ProseHr.vue';
|
package/src/i18n/de.json
CHANGED
|
@@ -42,6 +42,11 @@
|
|
|
42
42
|
"copy": "Befehle kopieren",
|
|
43
43
|
"copied": "Befehle kopiert"
|
|
44
44
|
},
|
|
45
|
+
"code": {
|
|
46
|
+
"title": "Code",
|
|
47
|
+
"copy": "Code kopieren",
|
|
48
|
+
"copied": "Code kopiert"
|
|
49
|
+
},
|
|
45
50
|
"footer": {
|
|
46
51
|
"madeWith": "Gemacht mit",
|
|
47
52
|
"by": "von",
|
|
@@ -51,6 +56,9 @@
|
|
|
51
56
|
"readMore": "Weiterlesen",
|
|
52
57
|
"goToPost": "Zum Beitrag",
|
|
53
58
|
"tableOfContents": "Inhaltsverzeichnis",
|
|
59
|
+
"tableOfContentsDescription": "Zu einem Abschnitt dieses Artikels springen",
|
|
60
|
+
"openTableOfContents": "Inhaltsverzeichnis offnen",
|
|
61
|
+
"closeTableOfContents": "Inhaltsverzeichnis schliessen",
|
|
54
62
|
"loadMoreWithNumbers": "Mehr laden ({current}/{total})",
|
|
55
63
|
"author": "Autor",
|
|
56
64
|
"publishedDate": "Veroffentlicht",
|
package/src/i18n/en.json
CHANGED
|
@@ -42,6 +42,11 @@
|
|
|
42
42
|
"copy": "Copy commands",
|
|
43
43
|
"copied": "Commands copied"
|
|
44
44
|
},
|
|
45
|
+
"code": {
|
|
46
|
+
"title": "Code",
|
|
47
|
+
"copy": "Copy code",
|
|
48
|
+
"copied": "Code copied"
|
|
49
|
+
},
|
|
45
50
|
"footer": {
|
|
46
51
|
"madeWith": "Made with",
|
|
47
52
|
"by": "by",
|
|
@@ -51,6 +56,9 @@
|
|
|
51
56
|
"readMore": "Read more",
|
|
52
57
|
"goToPost": "Go to post",
|
|
53
58
|
"tableOfContents": "Table of contents",
|
|
59
|
+
"tableOfContentsDescription": "Jump to a section of this article",
|
|
60
|
+
"openTableOfContents": "Open table of contents",
|
|
61
|
+
"closeTableOfContents": "Close table of contents",
|
|
54
62
|
"loadMoreWithNumbers": "Load more ({current}/{total})",
|
|
55
63
|
"author": "Author",
|
|
56
64
|
"publishedDate": "Published",
|
package/src/i18n/es.json
CHANGED
|
@@ -42,6 +42,11 @@
|
|
|
42
42
|
"copy": "Copiar comandos",
|
|
43
43
|
"copied": "Comandos copiados"
|
|
44
44
|
},
|
|
45
|
+
"code": {
|
|
46
|
+
"title": "Codigo",
|
|
47
|
+
"copy": "Copiar codigo",
|
|
48
|
+
"copied": "Codigo copiado"
|
|
49
|
+
},
|
|
45
50
|
"footer": {
|
|
46
51
|
"madeWith": "Hecho con",
|
|
47
52
|
"by": "por",
|
|
@@ -51,6 +56,9 @@
|
|
|
51
56
|
"readMore": "Leer mas",
|
|
52
57
|
"goToPost": "Ir al articulo",
|
|
53
58
|
"tableOfContents": "Tabla de contenidos",
|
|
59
|
+
"tableOfContentsDescription": "Ir a una seccion de este articulo",
|
|
60
|
+
"openTableOfContents": "Abrir tabla de contenidos",
|
|
61
|
+
"closeTableOfContents": "Cerrar tabla de contenidos",
|
|
54
62
|
"loadMoreWithNumbers": "Cargar mas ({current}/{total})",
|
|
55
63
|
"author": "Autor",
|
|
56
64
|
"publishedDate": "Publicado",
|
package/src/i18n/fr.json
CHANGED
|
@@ -42,6 +42,11 @@
|
|
|
42
42
|
"copy": "Copier les commandes",
|
|
43
43
|
"copied": "Commandes copiees"
|
|
44
44
|
},
|
|
45
|
+
"code": {
|
|
46
|
+
"title": "Code",
|
|
47
|
+
"copy": "Copier le code",
|
|
48
|
+
"copied": "Code copie"
|
|
49
|
+
},
|
|
45
50
|
"footer": {
|
|
46
51
|
"madeWith": "Fait avec",
|
|
47
52
|
"by": "par",
|
|
@@ -51,6 +56,9 @@
|
|
|
51
56
|
"readMore": "Lire la suite",
|
|
52
57
|
"goToPost": "Aller a l'article",
|
|
53
58
|
"tableOfContents": "Table des matieres",
|
|
59
|
+
"tableOfContentsDescription": "Aller a une section de cet article",
|
|
60
|
+
"openTableOfContents": "Ouvrir la table des matieres",
|
|
61
|
+
"closeTableOfContents": "Fermer la table des matieres",
|
|
54
62
|
"loadMoreWithNumbers": "Charger plus ({current}/{total})",
|
|
55
63
|
"author": "Auteur",
|
|
56
64
|
"publishedDate": "Publie",
|
package/src/i18n/it.json
CHANGED
|
@@ -42,6 +42,11 @@
|
|
|
42
42
|
"copy": "Copia comandi",
|
|
43
43
|
"copied": "Comandi copiati"
|
|
44
44
|
},
|
|
45
|
+
"code": {
|
|
46
|
+
"title": "Codice",
|
|
47
|
+
"copy": "Copia codice",
|
|
48
|
+
"copied": "Codice copiato"
|
|
49
|
+
},
|
|
45
50
|
"footer": {
|
|
46
51
|
"madeWith": "Fatto con",
|
|
47
52
|
"by": "da",
|
|
@@ -51,6 +56,9 @@
|
|
|
51
56
|
"readMore": "Leggi di piu",
|
|
52
57
|
"goToPost": "Vai all'articolo",
|
|
53
58
|
"tableOfContents": "Indice dei contenuti",
|
|
59
|
+
"tableOfContentsDescription": "Vai a una sezione dell'articolo",
|
|
60
|
+
"openTableOfContents": "Apri l'indice dei contenuti",
|
|
61
|
+
"closeTableOfContents": "Chiudi l'indice dei contenuti",
|
|
54
62
|
"loadMoreWithNumbers": "Carica altro ({current}/{total})",
|
|
55
63
|
"author": "Autore",
|
|
56
64
|
"publishedDate": "Pubblicato",
|
package/src/i18n/ja.json
CHANGED
|
@@ -42,6 +42,11 @@
|
|
|
42
42
|
"copy": "コマンドをコピー",
|
|
43
43
|
"copied": "コマンドをコピーしました"
|
|
44
44
|
},
|
|
45
|
+
"code": {
|
|
46
|
+
"title": "コード",
|
|
47
|
+
"copy": "コードをコピー",
|
|
48
|
+
"copied": "コードをコピーしました"
|
|
49
|
+
},
|
|
45
50
|
"footer": {
|
|
46
51
|
"madeWith": "愛を込めて",
|
|
47
52
|
"by": "by",
|
|
@@ -51,6 +56,9 @@
|
|
|
51
56
|
"readMore": "続きを読む",
|
|
52
57
|
"goToPost": "記事を開く",
|
|
53
58
|
"tableOfContents": "目次",
|
|
59
|
+
"tableOfContentsDescription": "この記事のセクションに移動",
|
|
60
|
+
"openTableOfContents": "目次を開く",
|
|
61
|
+
"closeTableOfContents": "目次を閉じる",
|
|
54
62
|
"loadMoreWithNumbers": "さらに読み込む ({current}/{total})",
|
|
55
63
|
"author": "著者",
|
|
56
64
|
"publishedDate": "公開日",
|
package/src/i18n/pt.json
CHANGED
|
@@ -42,6 +42,11 @@
|
|
|
42
42
|
"copy": "Copiar comandos",
|
|
43
43
|
"copied": "Comandos copiados"
|
|
44
44
|
},
|
|
45
|
+
"code": {
|
|
46
|
+
"title": "Codigo",
|
|
47
|
+
"copy": "Copiar codigo",
|
|
48
|
+
"copied": "Codigo copiado"
|
|
49
|
+
},
|
|
45
50
|
"footer": {
|
|
46
51
|
"madeWith": "Feito com",
|
|
47
52
|
"by": "por",
|
|
@@ -51,6 +56,9 @@
|
|
|
51
56
|
"readMore": "Ler mais",
|
|
52
57
|
"goToPost": "Ir para o artigo",
|
|
53
58
|
"tableOfContents": "Indice",
|
|
59
|
+
"tableOfContentsDescription": "Ir para uma secao deste artigo",
|
|
60
|
+
"openTableOfContents": "Abrir indice",
|
|
61
|
+
"closeTableOfContents": "Fechar indice",
|
|
54
62
|
"loadMoreWithNumbers": "Carregar mais ({current}/{total})",
|
|
55
63
|
"author": "Autor",
|
|
56
64
|
"publishedDate": "Publicado",
|
package/src/i18n/ru.json
CHANGED
|
@@ -42,6 +42,11 @@
|
|
|
42
42
|
"copy": "Копировать команды",
|
|
43
43
|
"copied": "Команды скопированы"
|
|
44
44
|
},
|
|
45
|
+
"code": {
|
|
46
|
+
"title": "Код",
|
|
47
|
+
"copy": "Копировать код",
|
|
48
|
+
"copied": "Код скопирован"
|
|
49
|
+
},
|
|
45
50
|
"footer": {
|
|
46
51
|
"madeWith": "Сделано с",
|
|
47
52
|
"by": "от",
|
|
@@ -51,6 +56,9 @@
|
|
|
51
56
|
"readMore": "Читать далее",
|
|
52
57
|
"goToPost": "Перейти к статье",
|
|
53
58
|
"tableOfContents": "Содержание",
|
|
59
|
+
"tableOfContentsDescription": "Перейти к разделу статьи",
|
|
60
|
+
"openTableOfContents": "Открыть содержание",
|
|
61
|
+
"closeTableOfContents": "Закрыть содержание",
|
|
54
62
|
"loadMoreWithNumbers": "Загрузить еще ({current}/{total})",
|
|
55
63
|
"author": "Автор",
|
|
56
64
|
"publishedDate": "Опубликовано",
|
package/src/i18n/zh.json
CHANGED
|
@@ -42,6 +42,11 @@
|
|
|
42
42
|
"copy": "复制命令",
|
|
43
43
|
"copied": "命令已复制"
|
|
44
44
|
},
|
|
45
|
+
"code": {
|
|
46
|
+
"title": "代码",
|
|
47
|
+
"copy": "复制代码",
|
|
48
|
+
"copied": "代码已复制"
|
|
49
|
+
},
|
|
45
50
|
"footer": {
|
|
46
51
|
"madeWith": "用",
|
|
47
52
|
"by": "由",
|
|
@@ -51,6 +56,9 @@
|
|
|
51
56
|
"readMore": "阅读更多",
|
|
52
57
|
"goToPost": "前往文章",
|
|
53
58
|
"tableOfContents": "目录",
|
|
59
|
+
"tableOfContentsDescription": "跳转到本文的一个部分",
|
|
60
|
+
"openTableOfContents": "打开目录",
|
|
61
|
+
"closeTableOfContents": "关闭目录",
|
|
54
62
|
"loadMoreWithNumbers": "加载更多 ({current}/{total})",
|
|
55
63
|
"author": "作者",
|
|
56
64
|
"publishedDate": "发布时间",
|