@markuxt/markuxt 0.1.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.
- package/LICENSE +201 -0
- package/README.md +168 -0
- package/app.config.d.ts +33 -0
- package/nuxt.config.ts +170 -0
- package/package.json +43 -0
- package/src/components/AppFooter.vue +225 -0
- package/src/components/AppHeader.vue +342 -0
- package/src/components/Hero.vue +438 -0
- package/src/components/Icon.vue +131 -0
- package/src/components/LanguageSwitcher.vue +71 -0
- package/src/components/MemberCard.vue +198 -0
- package/src/components/MembersGrid.vue +129 -0
- package/src/components/MermaidDiagram.vue +99 -0
- package/src/components/NewsCard.vue +119 -0
- package/src/components/PublicationCard.vue +245 -0
- package/src/components/SectionTitle.vue +75 -0
- package/src/components/content/ProseImg.vue +29 -0
- package/src/components/content/ProsePre.vue +58 -0
- package/src/components/content/ProseVideo.vue +45 -0
- package/src/composables/resolveContentImage.ts +35 -0
- package/src/content-transformers/binary-assets.ts +20 -0
- package/src/error.vue +58 -0
- package/src/layouts/default.vue +37 -0
- package/src/middleware/navigation-guard.ts +22 -0
- package/src/pages/index.vue +232 -0
- package/src/pages/members/[...slug].vue +542 -0
- package/src/pages/members/index.vue +147 -0
- package/src/pages/news/[...slug].vue +280 -0
- package/src/pages/news/index.vue +102 -0
- package/src/pages/positions/[...slug].vue +425 -0
- package/src/pages/positions/index.vue +266 -0
- package/src/pages/projects/[...slug].vue +441 -0
- package/src/pages/projects/index.vue +499 -0
- package/src/pages/publications/[...slug].vue +435 -0
- package/src/pages/publications/index.vue +145 -0
- package/src/plugins/mathml-components.ts +33 -0
- package/src/plugins/suppress-warnings.ts +40 -0
- package/src/public/_markuxt/components/AppFooter.vue +225 -0
- package/src/public/_markuxt/components/AppHeader.vue +342 -0
- package/src/public/_markuxt/components/Hero.vue +430 -0
- package/src/public/_markuxt/components/Icon.vue +131 -0
- package/src/public/_markuxt/components/LanguageSwitcher.vue +71 -0
- package/src/public/_markuxt/components/MemberCard.vue +198 -0
- package/src/public/_markuxt/components/MembersGrid.vue +129 -0
- package/src/public/_markuxt/components/MermaidDiagram.vue +99 -0
- package/src/public/_markuxt/components/NewsCard.vue +119 -0
- package/src/public/_markuxt/components/PublicationCard.vue +245 -0
- package/src/public/_markuxt/components/SectionTitle.vue +75 -0
- package/src/public/_markuxt/components/content/ProseImg.vue +29 -0
- package/src/public/_markuxt/components/content/ProsePre.vue +58 -0
- package/src/public/_markuxt/components/content/ProseVideo.vue +45 -0
- package/src/public/_markuxt/composables/resolveContentImage.ts +35 -0
- package/src/public/_markuxt/content-transformers/binary-assets.ts +20 -0
- package/src/public/_markuxt/error.vue +58 -0
- package/src/public/_markuxt/layouts/default.vue +37 -0
- package/src/public/_markuxt/middleware/navigation-guard.ts +22 -0
- package/src/public/_markuxt/pages/index.vue +232 -0
- package/src/public/_markuxt/pages/members/[...slug].vue +542 -0
- package/src/public/_markuxt/pages/members/index.vue +147 -0
- package/src/public/_markuxt/pages/news/[...slug].vue +280 -0
- package/src/public/_markuxt/pages/news/index.vue +102 -0
- package/src/public/_markuxt/pages/positions/[...slug].vue +425 -0
- package/src/public/_markuxt/pages/positions/index.vue +266 -0
- package/src/public/_markuxt/pages/projects/[...slug].vue +441 -0
- package/src/public/_markuxt/pages/projects/index.vue +499 -0
- package/src/public/_markuxt/pages/publications/[...slug].vue +435 -0
- package/src/public/_markuxt/pages/publications/index.vue +145 -0
- package/src/public/_markuxt/plugins/mathml-components.ts +33 -0
- package/src/public/_markuxt/plugins/suppress-warnings.ts +40 -0
- package/src/public/_markuxt/server/plugins/content-locale.ts +47 -0
- package/src/public/_markuxt/server/plugins/fix-content-anchors.ts +63 -0
- package/src/public/_markuxt/styles/_animations.css +99 -0
- package/src/public/_markuxt/styles/_base.css +31 -0
- package/src/public/_markuxt/styles/_code.css +109 -0
- package/src/public/_markuxt/styles/_components.css +109 -0
- package/src/public/_markuxt/styles/_layout.css +220 -0
- package/src/public/_markuxt/styles/_markdown.css +52 -0
- package/src/public/_markuxt/styles/_tokens.css +62 -0
- package/src/public/_markuxt/styles/_typography.css +144 -0
- package/src/public/_markuxt/styles/_utilities.css +110 -0
- package/src/public/_markuxt/styles/main.css +784 -0
- package/src/public/images/logo.png +0 -0
- package/src/server/plugins/content-locale.ts +47 -0
- package/src/server/plugins/fix-content-anchors.ts +63 -0
- package/src/styles/_animations.css +99 -0
- package/src/styles/_base.css +31 -0
- package/src/styles/_code.css +109 -0
- package/src/styles/_components.css +109 -0
- package/src/styles/_layout.css +220 -0
- package/src/styles/_markdown.css +52 -0
- package/src/styles/_tokens.css +62 -0
- package/src/styles/_typography.css +144 -0
- package/src/styles/_utilities.css +110 -0
- package/src/styles/main.css +784 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<article class="publication-card">
|
|
3
|
+
<div class="publication-card__meta">
|
|
4
|
+
<span class="publication-card__year">{{ publication.year }}</span>
|
|
5
|
+
<span v-if="publication.venue" class="badge badge-accent">{{ publication.venue }}</span>
|
|
6
|
+
</div>
|
|
7
|
+
<h3 class="publication-card__title">
|
|
8
|
+
<!-- If there's a detail page (_path), link to it; otherwise show plain title or DOI link -->
|
|
9
|
+
<NuxtLink v-if="publication._path" :to="publication._path" class="publication-card__title-link">
|
|
10
|
+
{{ publication.title }}
|
|
11
|
+
</NuxtLink>
|
|
12
|
+
<a v-else-if="publication.doi" :href="publication.doi" target="_blank" rel="noopener" class="publication-card__title-link">
|
|
13
|
+
{{ publication.title }}
|
|
14
|
+
</a>
|
|
15
|
+
<span v-else class="publication-card__title-text">{{ publication.title }}</span>
|
|
16
|
+
</h3>
|
|
17
|
+
<p class="publication-card__authors">{{ formattedAuthors }}</p>
|
|
18
|
+
<p class="publication-card__abstract" v-if="publication.abstract">{{ truncatedAbstract }}</p>
|
|
19
|
+
<div class="publication-card__footer">
|
|
20
|
+
<div class="publication-card__keywords">
|
|
21
|
+
<span
|
|
22
|
+
v-for="(keyword, index) in displayedKeywords"
|
|
23
|
+
:key="index"
|
|
24
|
+
class="publication-card__keyword"
|
|
25
|
+
>
|
|
26
|
+
{{ keyword }}
|
|
27
|
+
</span>
|
|
28
|
+
<span v-if="moreKeywords" class="publication-card__keyword-more">+{{ moreKeywords }}</span>
|
|
29
|
+
</div>
|
|
30
|
+
<!-- Only show DOI link if there's NO detail page -->
|
|
31
|
+
<a
|
|
32
|
+
v-if="publication.doi && !publication._path"
|
|
33
|
+
:href="publication.doi"
|
|
34
|
+
target="_blank"
|
|
35
|
+
rel="noopener"
|
|
36
|
+
class="publication-card__link"
|
|
37
|
+
:aria-label="t('publications.viewPublication')"
|
|
38
|
+
>
|
|
39
|
+
<LinkOut class="icon-inline" theme="outline" :size="16" fill="currentColor" :stroke-width="2" />
|
|
40
|
+
</a>
|
|
41
|
+
</div>
|
|
42
|
+
</article>
|
|
43
|
+
</template>
|
|
44
|
+
|
|
45
|
+
<script setup lang="ts">
|
|
46
|
+
import { computed } from 'vue'
|
|
47
|
+
import LinkOut from '@icon-park/vue-next/es/icons/LinkOut'
|
|
48
|
+
|
|
49
|
+
interface Publication {
|
|
50
|
+
title: string
|
|
51
|
+
authors: string[]
|
|
52
|
+
year: number
|
|
53
|
+
doi?: string
|
|
54
|
+
venue?: string
|
|
55
|
+
keywords?: string[]
|
|
56
|
+
abstract?: string
|
|
57
|
+
_path?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface Props {
|
|
61
|
+
publication: Publication
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const props = defineProps<Props>()
|
|
65
|
+
|
|
66
|
+
const { t } = useI18n()
|
|
67
|
+
|
|
68
|
+
const formattedAuthors = computed(() => {
|
|
69
|
+
const authors = props.publication.authors
|
|
70
|
+
if (authors.length <= 2) return authors.join(t('publications.authorSep'))
|
|
71
|
+
return authors.slice(0, authors.length - 1).join(', ') + ', ' + t('publications.authorSep') + authors[authors.length - 1]
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const truncatedAbstract = computed(() => {
|
|
75
|
+
if (!props.publication.abstract) return ''
|
|
76
|
+
const abstract = props.publication.abstract
|
|
77
|
+
return abstract.length > 200 ? abstract.slice(0, 200) + t('publications.truncation') : abstract
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const displayedKeywords = computed(() => {
|
|
81
|
+
if (!props.publication.keywords) return []
|
|
82
|
+
return props.publication.keywords.slice(0, 3)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const moreKeywords = computed(() => {
|
|
86
|
+
if (!props.publication.keywords) return 0
|
|
87
|
+
const remaining = props.publication.keywords.length - 3
|
|
88
|
+
return remaining > 0 ? t('publications.moreKeywords', { n: remaining }) : ''
|
|
89
|
+
})
|
|
90
|
+
</script>
|
|
91
|
+
|
|
92
|
+
<style scoped>
|
|
93
|
+
.publication-card {
|
|
94
|
+
background: var(--color-bg-alt);
|
|
95
|
+
border-radius: var(--radius-lg);
|
|
96
|
+
padding: var(--spacing-xl);
|
|
97
|
+
border: 1px solid var(--color-border);
|
|
98
|
+
box-shadow: var(--shadow-md);
|
|
99
|
+
transition: all var(--transition-base);
|
|
100
|
+
display: block;
|
|
101
|
+
text-decoration: none;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.publication-card:hover {
|
|
105
|
+
box-shadow: var(--shadow-lg);
|
|
106
|
+
border-color: var(--color-secondary);
|
|
107
|
+
transform: translateY(-4px);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.publication-card__meta {
|
|
111
|
+
display: flex;
|
|
112
|
+
align-items: center;
|
|
113
|
+
gap: var(--spacing-sm);
|
|
114
|
+
margin-bottom: var(--spacing-md);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.publication-card__year {
|
|
118
|
+
font-family: var(--font-display);
|
|
119
|
+
font-size: 0.875rem;
|
|
120
|
+
font-weight: 600;
|
|
121
|
+
color: var(--color-text-muted);
|
|
122
|
+
letter-spacing: 0.05em;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.badge {
|
|
126
|
+
display: inline-flex;
|
|
127
|
+
align-items: center;
|
|
128
|
+
padding: var(--spacing-xs) var(--spacing-sm);
|
|
129
|
+
font-size: 0.75rem;
|
|
130
|
+
font-weight: 600;
|
|
131
|
+
letter-spacing: 0.08em;
|
|
132
|
+
text-transform: uppercase;
|
|
133
|
+
border-radius: var(--radius-sm);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.badge-accent {
|
|
137
|
+
background: var(--color-accent);
|
|
138
|
+
color: white;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.publication-card__title {
|
|
142
|
+
font-family: var(--font-display);
|
|
143
|
+
font-size: 1.125rem;
|
|
144
|
+
font-weight: 600;
|
|
145
|
+
line-height: 1.3;
|
|
146
|
+
color: var(--color-primary);
|
|
147
|
+
margin-bottom: var(--spacing-sm);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.publication-card__title-link {
|
|
151
|
+
color: var(--color-secondary);
|
|
152
|
+
text-decoration: none;
|
|
153
|
+
transition: color var(--transition-fast);
|
|
154
|
+
display: block;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.publication-card__title-link:hover {
|
|
158
|
+
color: var(--color-accent);
|
|
159
|
+
text-decoration: underline;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.publication-card__title-text {
|
|
163
|
+
color: var(--color-primary);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.publication-card__authors {
|
|
167
|
+
font-size: 0.9375rem;
|
|
168
|
+
color: var(--color-text-muted);
|
|
169
|
+
line-height: 1.5;
|
|
170
|
+
margin-bottom: var(--spacing-md);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.publication-card__abstract {
|
|
174
|
+
font-family: var(--font-body);
|
|
175
|
+
font-size: 0.9375rem;
|
|
176
|
+
color: var(--color-text);
|
|
177
|
+
line-height: 1.6;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.publication-card__footer {
|
|
181
|
+
display: flex;
|
|
182
|
+
flex-direction: column;
|
|
183
|
+
gap: var(--spacing-sm);
|
|
184
|
+
margin-top: var(--spacing-lg);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.publication-card__keywords {
|
|
188
|
+
display: flex;
|
|
189
|
+
flex-wrap: wrap;
|
|
190
|
+
gap: var(--spacing-sm);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.publication-card__keyword {
|
|
194
|
+
display: inline-flex;
|
|
195
|
+
padding: var(--spacing-xs) var(--spacing-md);
|
|
196
|
+
font-size: 0.8125rem;
|
|
197
|
+
font-weight: 500;
|
|
198
|
+
color: var(--color-text);
|
|
199
|
+
background: var(--color-bg);
|
|
200
|
+
border: 1px solid var(--color-border);
|
|
201
|
+
border-radius: var(--radius-md);
|
|
202
|
+
transition: all var(--transition-fast);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.publication-card__keyword:hover {
|
|
206
|
+
background: var(--color-secondary);
|
|
207
|
+
color: white;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.publication-card__keyword-more {
|
|
211
|
+
display: inline-flex;
|
|
212
|
+
align-items: center;
|
|
213
|
+
padding: var(--spacing-xs) var(--spacing-md);
|
|
214
|
+
font-size: 0.8125rem;
|
|
215
|
+
font-weight: 500;
|
|
216
|
+
color: var(--color-text);
|
|
217
|
+
background: var(--color-bg);
|
|
218
|
+
border: 1px solid var(--color-border);
|
|
219
|
+
border-radius: var(--radius-md);
|
|
220
|
+
transition: all var(--transition-fast);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.publication-card__keyword-more:hover {
|
|
224
|
+
background: var(--color-secondary);
|
|
225
|
+
color: white;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.publication-card__link {
|
|
229
|
+
display: flex;
|
|
230
|
+
align-items: center;
|
|
231
|
+
justify-content: center;
|
|
232
|
+
width: 36px;
|
|
233
|
+
height: 36px;
|
|
234
|
+
background: var(--color-bg-alt);
|
|
235
|
+
border-radius: var(--radius-full);
|
|
236
|
+
color: var(--color-primary);
|
|
237
|
+
transition: all var(--transition-fast);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.publication-card__link:hover {
|
|
241
|
+
background: var(--color-secondary);
|
|
242
|
+
color: white;
|
|
243
|
+
transform: scale(1.1);
|
|
244
|
+
}
|
|
245
|
+
</style>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="section-title" :class="`section-title--${align}`">
|
|
3
|
+
<span class="section-title__overline" v-if="overline">{{ overline }}</span>
|
|
4
|
+
<h2 class="section-title__title">{{ title }}</h2>
|
|
5
|
+
<p class="section-title__description" v-if="description">
|
|
6
|
+
{{ description }}
|
|
7
|
+
</p>
|
|
8
|
+
<div class="section-title__line"></div>
|
|
9
|
+
</div>
|
|
10
|
+
</template>
|
|
11
|
+
|
|
12
|
+
<script setup lang="ts">
|
|
13
|
+
interface Props {
|
|
14
|
+
title: string
|
|
15
|
+
overline?: string
|
|
16
|
+
description?: string
|
|
17
|
+
align?: 'left' | 'center'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
withDefaults(defineProps<Props>(), {
|
|
21
|
+
align: 'left'
|
|
22
|
+
})
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<style scoped>
|
|
26
|
+
.section-title {
|
|
27
|
+
margin-bottom: var(--spacing-2xl);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.section-title--center {
|
|
31
|
+
text-align: center;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.section-title--center .section-title__line {
|
|
35
|
+
margin-left: auto;
|
|
36
|
+
margin-right: auto;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.section-title__overline {
|
|
40
|
+
display: inline-block;
|
|
41
|
+
font-size: 0.75rem;
|
|
42
|
+
font-weight: 600;
|
|
43
|
+
letter-spacing: 0.1em;
|
|
44
|
+
text-transform: uppercase;
|
|
45
|
+
color: var(--color-secondary);
|
|
46
|
+
margin-bottom: var(--spacing-sm);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.section-title__title {
|
|
50
|
+
font-family: var(--font-display);
|
|
51
|
+
font-size: clamp(2rem, 4vw, 2.5rem);
|
|
52
|
+
font-weight: 700;
|
|
53
|
+
color: var(--color-primary);
|
|
54
|
+
margin-bottom: var(--spacing-sm);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.section-title__description {
|
|
58
|
+
font-size: 1.125rem;
|
|
59
|
+
color: var(--color-text-muted);
|
|
60
|
+
max-width: 600px;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.section-title--center .section-title__description {
|
|
64
|
+
margin-left: auto;
|
|
65
|
+
margin-right: auto;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.section-title__line {
|
|
69
|
+
width: 60px;
|
|
70
|
+
height: 4px;
|
|
71
|
+
background: linear-gradient(90deg, var(--color-accent), var(--color-secondary));
|
|
72
|
+
border-radius: 2px;
|
|
73
|
+
margin-top: var(--spacing-md);
|
|
74
|
+
}
|
|
75
|
+
</style>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<img :src="refinedSrc" :alt="alt" :width="width" :height="height" />
|
|
3
|
+
</template>
|
|
4
|
+
|
|
5
|
+
<script setup lang="ts">
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
src: { type: String, default: '' },
|
|
8
|
+
alt: { type: String, default: '' },
|
|
9
|
+
width: { type: [String, Number], default: undefined },
|
|
10
|
+
height: { type: [String, Number], default: undefined }
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const contentId = inject<Ref<string>>('contentId', ref(''))
|
|
14
|
+
|
|
15
|
+
const config = useRuntimeConfig()
|
|
16
|
+
|
|
17
|
+
const refinedSrc = computed(() => {
|
|
18
|
+
try {
|
|
19
|
+
const resolved = resolveContentImage(props.src, unref(contentId))
|
|
20
|
+
if (!resolved) return ''
|
|
21
|
+
const basePath = config.app.baseURL || ''
|
|
22
|
+
if (!basePath || basePath === '/') return resolved
|
|
23
|
+
return basePath.replace(/\/$/, '') + resolved
|
|
24
|
+
} catch {
|
|
25
|
+
// Fallback: return raw src so the page doesn't break
|
|
26
|
+
return props.src || ''
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
</script>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!-- Mermaid blocks are diagrams, not code: hand off to the renderer. -->
|
|
3
|
+
<MermaidDiagram v-if="language === 'mermaid'" :code="code" />
|
|
4
|
+
|
|
5
|
+
<!-- Everything else: a highlighted code block with chrome. -->
|
|
6
|
+
<div v-else class="code-block">
|
|
7
|
+
<div class="code-block__header">
|
|
8
|
+
<span class="code-block__lang">{{ displayLanguage }}</span>
|
|
9
|
+
<button
|
|
10
|
+
type="button"
|
|
11
|
+
class="code-block__copy"
|
|
12
|
+
:class="{ 'is-copied': copied }"
|
|
13
|
+
:aria-label="copied ? t('code.copied') : t('code.copyCode')"
|
|
14
|
+
:title="copied ? t('code.copiedExclaim') : t('code.copy')"
|
|
15
|
+
@click="copyCode"
|
|
16
|
+
>
|
|
17
|
+
<Check v-if="copied" theme="outline" :size="16" :stroke-width="3" />
|
|
18
|
+
<Copy v-else theme="outline" :size="16" :stroke-width="3" />
|
|
19
|
+
</button>
|
|
20
|
+
</div>
|
|
21
|
+
<pre :class="$props.class"><slot /></pre>
|
|
22
|
+
</div>
|
|
23
|
+
</template>
|
|
24
|
+
|
|
25
|
+
<script setup lang="ts">
|
|
26
|
+
import Copy from '@icon-park/vue-next/es/icons/Copy'
|
|
27
|
+
import Check from '@icon-park/vue-next/es/icons/Check'
|
|
28
|
+
|
|
29
|
+
const { t } = useI18n()
|
|
30
|
+
|
|
31
|
+
const props = defineProps({
|
|
32
|
+
code: { type: String, default: '' },
|
|
33
|
+
language: { type: String, default: null },
|
|
34
|
+
filename: { type: String, default: null },
|
|
35
|
+
highlights: { type: Array as () => number[], default: () => [] },
|
|
36
|
+
meta: { type: String, default: null },
|
|
37
|
+
class: { type: String, default: null }
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const copied = ref(false)
|
|
41
|
+
|
|
42
|
+
// Friendly label for the header; fall back to "text" for fenced blocks
|
|
43
|
+
// that declared no language.
|
|
44
|
+
const displayLanguage = computed(() => {
|
|
45
|
+
if (props.filename) return props.filename
|
|
46
|
+
return props.language || 'text'
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
async function copyCode() {
|
|
50
|
+
try {
|
|
51
|
+
await navigator.clipboard.writeText(props.code)
|
|
52
|
+
copied.value = true
|
|
53
|
+
setTimeout(() => { copied.value = false }, 2000)
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.error('[ProsePre] copy failed:', err)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
</script>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<video
|
|
3
|
+
:src="refinedSrc"
|
|
4
|
+
:controls="controls"
|
|
5
|
+
:autoplay="autoplay"
|
|
6
|
+
:loop="loop"
|
|
7
|
+
:muted="muted"
|
|
8
|
+
:poster="refinedPoster"
|
|
9
|
+
:width="width"
|
|
10
|
+
:height="height"
|
|
11
|
+
>
|
|
12
|
+
<slot />
|
|
13
|
+
</video>
|
|
14
|
+
</template>
|
|
15
|
+
|
|
16
|
+
<script setup lang="ts">
|
|
17
|
+
import { inject, computed, type Ref } from 'vue'
|
|
18
|
+
|
|
19
|
+
const props = defineProps({
|
|
20
|
+
src: { type: String, default: '' },
|
|
21
|
+
poster: { type: String, default: undefined },
|
|
22
|
+
controls: { type: [Boolean, String], default: true },
|
|
23
|
+
autoplay: { type: [Boolean, String], default: false },
|
|
24
|
+
loop: { type: [Boolean, String], default: false },
|
|
25
|
+
muted: { type: [Boolean, String], default: false },
|
|
26
|
+
width: { type: [String, Number], default: undefined },
|
|
27
|
+
height: { type: [String, Number], default: undefined }
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// Injected by the page component via provide('contentId', ...)
|
|
31
|
+
const contentId = inject<Ref<string>>('contentId', { value: '' } as Ref<string>)
|
|
32
|
+
|
|
33
|
+
const config = useRuntimeConfig()
|
|
34
|
+
|
|
35
|
+
function resolve(src?: string): string | undefined {
|
|
36
|
+
if (!src) return src
|
|
37
|
+
const resolved = resolveContentImage(src, contentId.value)
|
|
38
|
+
const basePath = config.app.baseURL || '/'
|
|
39
|
+
if (!basePath || basePath === '/' || resolved.startsWith(basePath)) return resolved
|
|
40
|
+
return basePath.replace(/\/$/, '') + resolved
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const refinedSrc = computed(() => resolve(props.src))
|
|
44
|
+
const refinedPoster = computed(() => resolve(props.poster))
|
|
45
|
+
</script>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a relative image path from content frontmatter to an absolute /_markuxt/ URL.
|
|
3
|
+
*
|
|
4
|
+
* Authors write `image: photo.webp` in frontmatter; this function converts it
|
|
5
|
+
* to `/_markuxt/members/staff/photo.webp` based on the source file location.
|
|
6
|
+
*
|
|
7
|
+
* Supports `../` for cross-directory references (e.g. shared placeholders).
|
|
8
|
+
* Absolute paths (`/...`, `http...`) pass through unchanged.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveContentImage(
|
|
11
|
+
image: string | undefined,
|
|
12
|
+
contentId: string | undefined,
|
|
13
|
+
): string {
|
|
14
|
+
if (!image) return ''
|
|
15
|
+
if (image.startsWith('/') || image.startsWith('http') || image.startsWith('//')) {
|
|
16
|
+
return image
|
|
17
|
+
}
|
|
18
|
+
if (!contentId) return image
|
|
19
|
+
|
|
20
|
+
// _id format in Nuxt Content v2: 'content:members:staff:salman-ijaz.md'
|
|
21
|
+
// Split by ':', drop 'content' prefix and filename, rejoin as directory path
|
|
22
|
+
const parts = contentId.split(':')
|
|
23
|
+
if (parts.length < 3) return image
|
|
24
|
+
|
|
25
|
+
const dirSegments = parts.slice(1, -1) // e.g. ['members', 'staff']
|
|
26
|
+
|
|
27
|
+
// Normalize: resolve ../ and ./
|
|
28
|
+
const resolved = [...dirSegments]
|
|
29
|
+
for (const seg of image.split('/')) {
|
|
30
|
+
if (seg === '..') resolved.pop()
|
|
31
|
+
else if (seg !== '.') resolved.push(seg)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return '/_markuxt/' + resolved.join('/')
|
|
35
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nuxt Content transformer for binary asset files (images, videos, etc.).
|
|
3
|
+
*
|
|
4
|
+
* Without this, @nuxt/content warns for every binary file in content/:
|
|
5
|
+
* ".webp files are not supported … falling back to raw content"
|
|
6
|
+
*
|
|
7
|
+
* This transformer claims those extensions so the built-in parser never
|
|
8
|
+
* falls through to the warning path.
|
|
9
|
+
*/
|
|
10
|
+
export default {
|
|
11
|
+
name: 'binary-assets',
|
|
12
|
+
extensions: [
|
|
13
|
+
'\\.png', '\\.jpe?g', '\\.gif', '\\.webp', '\\.svg', '\\.ico',
|
|
14
|
+
'\\.mp4', '\\.webm', '\\.avi', '\\.mov', '\\.mkv',
|
|
15
|
+
'\\.pdf', '\\.zip', '\\.tar', '\\.gz',
|
|
16
|
+
'\\.woff2?', '\\.ttf', '\\.eot',
|
|
17
|
+
'\\.mp3', '\\.wav', '\\.ogg', '\\.flac',
|
|
18
|
+
],
|
|
19
|
+
parse: async (_id: string, content: string) => ({ _id, _type: 'binary', body: content }),
|
|
20
|
+
}
|
package/src/error.vue
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="error-page">
|
|
3
|
+
<div class="error-page__content">
|
|
4
|
+
<h1 class="error-page__code">404</h1>
|
|
5
|
+
<h2 class="error-page__title">{{ t('error.notFound') }}</h2>
|
|
6
|
+
<p class="error-page__description">{{ t('error.notFoundDesc') }}</p>
|
|
7
|
+
<NuxtLink to="/" class="btn btn-primary">{{ t('error.backHome') }}</NuxtLink>
|
|
8
|
+
</div>
|
|
9
|
+
</div>
|
|
10
|
+
</template>
|
|
11
|
+
|
|
12
|
+
<script setup lang="ts">
|
|
13
|
+
const { t } = useI18n()
|
|
14
|
+
|
|
15
|
+
useHead({
|
|
16
|
+
title: '404 - Page Not Found'
|
|
17
|
+
})
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<style scoped>
|
|
21
|
+
.error-page {
|
|
22
|
+
display: flex;
|
|
23
|
+
align-items: center;
|
|
24
|
+
justify-content: center;
|
|
25
|
+
padding: var(--spacing-3xl) var(--spacing-lg);
|
|
26
|
+
text-align: center;
|
|
27
|
+
min-height: 50vh;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.error-page__content {
|
|
31
|
+
max-width: 480px;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.error-page__code {
|
|
35
|
+
font-family: var(--font-display);
|
|
36
|
+
font-size: 8rem;
|
|
37
|
+
font-weight: 900;
|
|
38
|
+
line-height: 1;
|
|
39
|
+
color: var(--color-secondary);
|
|
40
|
+
margin-bottom: var(--spacing-md);
|
|
41
|
+
opacity: 0.3;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.error-page__title {
|
|
45
|
+
font-family: var(--font-display);
|
|
46
|
+
font-size: 1.5rem;
|
|
47
|
+
font-weight: 600;
|
|
48
|
+
color: var(--color-primary);
|
|
49
|
+
margin-bottom: var(--spacing-md);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.error-page__description {
|
|
53
|
+
font-size: 1rem;
|
|
54
|
+
color: var(--color-text-muted);
|
|
55
|
+
line-height: 1.6;
|
|
56
|
+
margin-bottom: var(--spacing-2xl);
|
|
57
|
+
}
|
|
58
|
+
</style>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="layout">
|
|
3
|
+
<AppHeader />
|
|
4
|
+
<main class="main">
|
|
5
|
+
<slot />
|
|
6
|
+
</main>
|
|
7
|
+
<AppFooter />
|
|
8
|
+
</div>
|
|
9
|
+
</template>
|
|
10
|
+
|
|
11
|
+
<script setup lang="ts">
|
|
12
|
+
const { t, locale } = useI18n()
|
|
13
|
+
|
|
14
|
+
useHead({
|
|
15
|
+
htmlAttrs: { lang: locale },
|
|
16
|
+
title: t('site.title'),
|
|
17
|
+
meta: [
|
|
18
|
+
{ name: 'description', content: t('site.description') },
|
|
19
|
+
{ name: 'keywords', content: t('site.keywords') },
|
|
20
|
+
{ property: 'og:title', content: t('site.ogTitle') },
|
|
21
|
+
{ property: 'og:description', content: t('site.ogDescription') },
|
|
22
|
+
{ property: 'og:type', content: 'website' },
|
|
23
|
+
],
|
|
24
|
+
})
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<style scoped>
|
|
28
|
+
.layout {
|
|
29
|
+
display: flex;
|
|
30
|
+
flex-direction: column;
|
|
31
|
+
min-height: 100vh;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.main {
|
|
35
|
+
flex: 1;
|
|
36
|
+
}
|
|
37
|
+
</style>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Router } from 'vue-router'
|
|
2
|
+
|
|
3
|
+
export default defineNuxtPlugin(() => {
|
|
4
|
+
const appConfig = useAppConfig()
|
|
5
|
+
const allowedPaths = new Set(
|
|
6
|
+
(appConfig.markuxt?.navigation || []).map((item: { to: string }) => item.to)
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
addRouteMiddleware('navigation-guard', (to) => {
|
|
10
|
+
// Only guard markuxt section pages (members, publications, projects, positions, news)
|
|
11
|
+
const markuxtSections = ['/members', '/publications', '/projects', '/positions', '/news']
|
|
12
|
+
const isMarkuxtPage = markuxtSections.some(section => to.path.startsWith(section))
|
|
13
|
+
|
|
14
|
+
if (isMarkuxtPage) {
|
|
15
|
+
// Check if any allowed path matches this route's section
|
|
16
|
+
const sectionPath = '/' + to.path.split('/')[1]
|
|
17
|
+
if (!allowedPaths.has(sectionPath)) {
|
|
18
|
+
return abortNavigation(createError({ statusCode: 404, statusMessage: 'Page Not Found' }))
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}, { global: true })
|
|
22
|
+
})
|