valaxy-theme-press 0.28.11 → 1.0.0-beta.2

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/README.md CHANGED
@@ -1,15 +1,35 @@
1
1
  # valaxy-theme-press
2
2
 
3
- This is a theme for [valaxy docs](https://valaxy.site/).
3
+ Documentation theme for [Valaxy](https://valaxy.site).
4
4
 
5
- Inspired by [vitepress](https://vitepress.dev).
5
+ It is inspired by [VitePress](https://vitepress.dev), and it powers the Valaxy documentation site.
6
6
 
7
- 我开发此主题以编写 Valaxy 的初期文档,同时以便发现 valaxy 编写主题中的痛点。
8
- 它增加了 VitePress 此前默认所没有的一些功能,KaTeX、文章分类、单页 CSS i18n + Vue i18n、Vue Router、Pinia 状态管理,组件自动注册等。
7
+ ## Install
9
8
 
10
- - [预览](https://press.valaxy.site)
9
+ ```bash
10
+ pnpm add valaxy-theme-press
11
+ ```
12
+
13
+ ```ts
14
+ import type { PressTheme } from 'valaxy-theme-press'
15
+ import { defineValaxyConfig } from 'valaxy'
16
+
17
+ export default defineValaxyConfig<PressTheme.Config>({
18
+ theme: 'press',
19
+ themeConfig: {
20
+ logo: '/favicon.svg',
21
+ },
22
+ })
23
+ ```
24
+
25
+ ## Links
26
+
27
+ - [Documentation](https://valaxy.site/themes/press)
28
+ - [中文文档](https://valaxy.site/zh/themes/press)
29
+ - [Preview](https://press.valaxy.site)
30
+ - [Source](https://github.com/YunYouJun/valaxy/tree/main/packages/valaxy-theme-press)
11
31
 
12
32
  ## Reference
13
33
 
14
- - [vitepress](https://vitepress.vuejs.org/)
34
+ - [vitepress](https://vitepress.dev/)
15
35
  - [nextjs](https://nextjs.org/)
@@ -1,9 +1,11 @@
1
1
  <script lang="ts" setup>
2
- import type { Category } from 'valaxy'
2
+ import type { Category, CategoryList } from 'valaxy'
3
3
  import { isCategoryList, useValaxyI18n } from 'valaxy'
4
- import { ref } from 'vue'
4
+ import { computed, ref } from 'vue'
5
5
  import { useI18n } from 'vue-i18n'
6
6
 
7
+ type CategoryChild = CategoryList['children'] extends Map<string, infer T> ? T : never
8
+
7
9
  const props = withDefaults(defineProps<{
8
10
  // to eliminate the warning
9
11
  category: Category
@@ -16,54 +18,102 @@ const props = withDefaults(defineProps<{
16
18
  collapsable?: boolean
17
19
  }>(), {
18
20
  collapsable: true,
21
+ level: 0,
19
22
  })
20
23
 
21
- const collapsable = ref(props.collapsable)
24
+ const collapsed = ref(props.collapsable)
25
+ const hasChildren = computed(() => props.category.children.size > 0)
22
26
  const { t } = useI18n()
23
27
  const { $tO } = useValaxyI18n()
28
+
29
+ function toggle() {
30
+ collapsed.value = !collapsed.value
31
+ }
32
+
33
+ function getCategoryItemKey(categoryItem: CategoryChild, index: number): string | number {
34
+ return isCategoryList(categoryItem)
35
+ ? categoryItem.name
36
+ : categoryItem.path || index
37
+ }
24
38
  </script>
25
39
 
26
40
  <template>
27
41
  <li
28
42
  v-if="category.total"
29
- p="t-2"
30
- w="full" border="t t-$pr-c-divider-light"
31
- class="press-sidebar-item category-list-item inline-flex items-center justify-between"
32
- text-14px
33
- tabindex="0"
43
+ class="press-category-item"
44
+ :class="[`level-${props.level}`, { collapsed }]"
34
45
  >
35
- <span class="category-name" font="bold" m="l-1" @click="displayCategory ? displayCategory(category.name) : null">
36
- {{ category.name === 'Uncategorized' ? t('category.uncategorized') : t(`category.${category.name}`) }}
37
- <!-- <sup font="normal">[{{ category.total }}]</sup> -->
38
- </span>
39
- <button
40
- tabindex="0" role="button" aria-label="toggle section"
41
- class="caret folder-action inline-flex cursor-pointer"
42
- text-base
43
- @click="collapsable = !collapsable"
46
+ <div
47
+ p="t-2"
48
+ w="full" border="t t-$pr-c-divider-light"
49
+ class="press-sidebar-item category-list-item inline-flex items-center justify-between"
50
+ text-14px
44
51
  >
45
- <div v-if="collapsable" i-ri-folder-add-line />
46
- <div v-else i-ri-folder-reduce-line />
47
- </button>
48
- </li>
52
+ <span class="category-name" font="bold" m="l-1" @click="displayCategory ? displayCategory(category.name) : null">
53
+ {{ category.name === 'Uncategorized' ? t('category.uncategorized') : t(`category.${category.name}`) }}
54
+ <!-- <sup font="normal">[{{ category.total }}]</sup> -->
55
+ </span>
56
+ <button
57
+ v-if="hasChildren"
58
+ type="button"
59
+ aria-label="toggle section"
60
+ :aria-expanded="!collapsed"
61
+ class="caret"
62
+ @click.stop="toggle"
63
+ >
64
+ <span class="caret-icon" :class="{ open: !collapsed }" i-ri-arrow-right-s-line aria-hidden="true" />
65
+ </button>
66
+ </div>
49
67
 
50
- <ul v-if="!collapsable">
51
- <li v-for="categoryItem, i in category.children.values()" :key="i" class="post-list-item">
52
- <template v-if="!isCategoryList(categoryItem)">
53
- <RouterLink
54
- v-if="categoryItem.title" :to="categoryItem.path || ''"
55
- class="inline-flex items-center"
56
- >
57
- <span class="text ml-1" text="sm">{{ $tO(categoryItem.title) }}</span>
58
- </RouterLink>
59
- </template>
68
+ <ul v-if="hasChildren && !collapsed" class="items press-category-list press-sidebar-category-list">
69
+ <template v-for="(categoryItem, i) in category.children.values()" :key="getCategoryItemKey(categoryItem, i)">
70
+ <li v-if="!isCategoryList(categoryItem)" class="post-list-item">
71
+ <RouterLink
72
+ v-if="categoryItem.title" :to="categoryItem.path || ''"
73
+ class="inline-flex items-center"
74
+ >
75
+ <span class="text ml-1" text="sm">{{ $tO(categoryItem.title) }}</span>
76
+ </RouterLink>
77
+ </li>
60
78
 
61
- <PressCategory v-else :category="categoryItem" :display-category="displayCategory" :collapsable="collapsable" />
62
- </li>
63
- </ul>
79
+ <PressCategory
80
+ v-else
81
+ :category="categoryItem"
82
+ :display-category="displayCategory"
83
+ :collapsable="collapsed"
84
+ :level="props.level + 1"
85
+ />
86
+ </template>
87
+ </ul>
88
+ </li>
64
89
  </template>
65
90
 
66
91
  <style lang="scss">
92
+ .press-category-item,
93
+ .press-category-list,
94
+ .post-list-item {
95
+ list-style: none;
96
+ margin: 0;
97
+ padding: 0;
98
+ }
99
+
100
+ .press-category-item.level-0 {
101
+ padding-bottom: 24px;
102
+ }
103
+
104
+ .press-category-item.collapsed.level-0 {
105
+ padding-bottom: 10px;
106
+ }
107
+
108
+ .press-category-item.level-1 > .press-category-list,
109
+ .press-category-item.level-2 > .press-category-list,
110
+ .press-category-item.level-3 > .press-category-list,
111
+ .press-category-item.level-4 > .press-category-list,
112
+ .press-category-item.level-5 > .press-category-list {
113
+ border-left: 1px solid var(--vp-c-divider);
114
+ padding-left: 16px;
115
+ }
116
+
67
117
  .post-list-item {
68
118
  // align with vitepress
69
119
  a {
@@ -0,0 +1,177 @@
1
+ <script setup lang="ts">
2
+ withDefaults(defineProps<{
3
+ code?: string
4
+ label: string
5
+ }>(), {
6
+ code: '404',
7
+ })
8
+ </script>
9
+
10
+ <template>
11
+ <div class="not-found-visual" aria-hidden="true">
12
+ <div class="not-found-orbit">
13
+ <span class="not-found-satellite not-found-satellite-a" />
14
+ <span class="not-found-satellite not-found-satellite-b" />
15
+ <span class="not-found-satellite not-found-satellite-c" />
16
+ <span class="not-found-signal">{{ label }}</span>
17
+ </div>
18
+
19
+ <div class="not-found-code" :title="code" font="mono">
20
+ {{ code }}
21
+ </div>
22
+ </div>
23
+ </template>
24
+
25
+ <style lang="scss" scoped>
26
+ .not-found-visual {
27
+ position: relative;
28
+ display: grid;
29
+ place-items: center;
30
+ min-height: clamp(280px, 42vw, 420px);
31
+ isolation: isolate;
32
+ }
33
+
34
+ .not-found-orbit {
35
+ position: absolute;
36
+ width: min(100%, 360px);
37
+ aspect-ratio: 1;
38
+ border: 1px solid color-mix(in srgb, var(--vp-c-brand-1) 26%, transparent);
39
+ border-radius: 50%;
40
+ background:
41
+ radial-gradient(circle at 50% 50%, color-mix(in srgb, var(--vp-c-brand-1) 18%, transparent) 0 2px, transparent 3px),
42
+ radial-gradient(circle at 18% 24%, color-mix(in srgb, #f59e0b 32%, transparent) 0 3px, transparent 4px),
43
+ radial-gradient(circle at 78% 30%, color-mix(in srgb, #14b8a6 30%, transparent) 0 2px, transparent 3px),
44
+ radial-gradient(circle at 72% 76%, color-mix(in srgb, #ef4444 28%, transparent) 0 3px, transparent 4px);
45
+ box-shadow:
46
+ inset 0 0 56px color-mix(in srgb, var(--vp-c-brand-1) 9%, transparent),
47
+ 0 28px 80px color-mix(in srgb, var(--vp-c-brand-1) 12%, transparent);
48
+ }
49
+
50
+ .not-found-orbit::before,
51
+ .not-found-orbit::after {
52
+ content: "";
53
+ position: absolute;
54
+ inset: 12%;
55
+ border: 1px dashed color-mix(in srgb, var(--vp-c-text-2) 22%, transparent);
56
+ border-radius: 50%;
57
+ transform: rotate(-22deg) scaleX(1.18);
58
+ }
59
+
60
+ .not-found-orbit::after {
61
+ inset: 25%;
62
+ transform: rotate(34deg) scaleX(1.35);
63
+ border-style: solid;
64
+ opacity: .55;
65
+ }
66
+
67
+ .not-found-satellite {
68
+ position: absolute;
69
+ width: 14px;
70
+ height: 14px;
71
+ border-radius: 50%;
72
+ box-shadow: 0 0 0 6px color-mix(in srgb, currentcolor 12%, transparent);
73
+ }
74
+
75
+ .not-found-satellite-a {
76
+ top: 18%;
77
+ left: 67%;
78
+ color: #f59e0b;
79
+ }
80
+
81
+ .not-found-satellite-b {
82
+ right: 15%;
83
+ bottom: 24%;
84
+ color: #14b8a6;
85
+ }
86
+
87
+ .not-found-satellite-c {
88
+ bottom: 18%;
89
+ left: 24%;
90
+ color: #ef4444;
91
+ }
92
+
93
+ .not-found-signal {
94
+ position: absolute;
95
+ right: 2%;
96
+ bottom: 10%;
97
+ max-width: 9rem;
98
+ padding: 6px 10px;
99
+ border: 1px solid var(--vp-c-divider);
100
+ border-radius: 8px;
101
+ background: var(--vp-c-bg);
102
+ color: var(--vp-c-text-2);
103
+ font-size: 12px;
104
+ line-height: 1.4;
105
+ box-shadow: 0 12px 32px rgb(0 0 0 / .08);
106
+ }
107
+
108
+ .not-found-code {
109
+ position: relative;
110
+ z-index: 1;
111
+ color: var(--vp-c-text-1);
112
+ font-size: clamp(6.5rem, 18vw, 13rem);
113
+ font-weight: 900;
114
+ line-height: .82;
115
+ letter-spacing: 0;
116
+ text-shadow:
117
+ 0 1px 0 var(--vp-c-bg),
118
+ 0 14px 34px rgb(0 0 0 / .18);
119
+ }
120
+
121
+ @media (max-width: 767px) {
122
+ .not-found-visual {
123
+ min-height: clamp(132px, 24svh, 205px);
124
+ }
125
+
126
+ .not-found-orbit {
127
+ width: min(56vw, 200px, 28svh);
128
+ }
129
+
130
+ .not-found-code {
131
+ font-size: clamp(4.5rem, 22vw, 6.25rem);
132
+ }
133
+
134
+ .not-found-signal {
135
+ right: 0;
136
+ }
137
+ }
138
+
139
+ @media (max-width: 767px) and (max-height: 700px) {
140
+ .not-found-visual {
141
+ min-height: clamp(104px, 21svh, 150px);
142
+ }
143
+
144
+ .not-found-orbit {
145
+ width: min(46vw, 160px, 24svh);
146
+ }
147
+
148
+ .not-found-signal {
149
+ display: none;
150
+ }
151
+ }
152
+
153
+ @media (prefers-reduced-motion: no-preference) {
154
+ .not-found-satellite-a {
155
+ animation: not-found-float 5s ease-in-out infinite;
156
+ }
157
+
158
+ .not-found-satellite-b {
159
+ animation: not-found-float 6s ease-in-out .8s infinite;
160
+ }
161
+
162
+ .not-found-satellite-c {
163
+ animation: not-found-float 5.5s ease-in-out .35s infinite;
164
+ }
165
+ }
166
+
167
+ @keyframes not-found-float {
168
+ 0%,
169
+ 100% {
170
+ transform: translate3d(0, 0, 0);
171
+ }
172
+
173
+ 50% {
174
+ transform: translate3d(0, -10px, 0);
175
+ }
176
+ }
177
+ </style>
@@ -1,14 +1,18 @@
1
1
  <script lang="ts" setup>
2
2
  import type { CategoryList, Post } from 'valaxy'
3
+ import type { PressTheme } from '../types'
3
4
  import { removeItemFromCategory, usePageList, useSidebar } from 'valaxy'
4
5
  import { computed } from 'vue'
6
+ import { useRoute } from 'vue-router'
5
7
  import { useLocaleConfig } from '../composables'
8
+ import { getSidebar, getSidebarGroups, isSidebarItem } from '../utils/sidebar'
6
9
 
7
10
  defineProps<{
8
11
  open: boolean
9
12
  }>()
10
13
 
11
14
  const pages = usePageList()
15
+ const route = useRoute()
12
16
  const { localeConfig, currentLocaleKey, hasLocales, currentLocale } = useLocaleConfig()
13
17
 
14
18
  /**
@@ -34,6 +38,29 @@ const localePages = computed(() => {
34
38
 
35
39
  const sidebar = computed(() => localeConfig.value.sidebar)
36
40
 
41
+ const sidebarItems = computed(() => getSidebar(sidebar.value, route.path))
42
+
43
+ const sidebarGroups = computed(() => {
44
+ return getSidebarGroups(sidebarItems.value)
45
+ })
46
+
47
+ const renderGroups = computed(() => {
48
+ return sidebarGroups.value.map((group, index) => {
49
+ const groupItem = group.text || group.link
50
+ ? { ...group, items: group.items.filter(isSidebarItem) }
51
+ : undefined
52
+
53
+ return {
54
+ key: group.text || group.link || index,
55
+ groupItem,
56
+ items: groupItem ? [] : group.items,
57
+ }
58
+ })
59
+ })
60
+
61
+ // Explicit groups and generated categories share the same root visual depth.
62
+ const sidebarGroupDepth = 0
63
+
37
64
  /**
38
65
  * Build categories from locale-filtered pages.
39
66
  * Fully reactive: recomputes when route/locale changes.
@@ -103,9 +130,8 @@ const categories = computed(() => {
103
130
 
104
131
  // Remove categories not listed in sidebar config
105
132
  removeItemFromCategory(categoryList, 'Uncategorized')
106
- const sidebarVal = localeConfig.value.sidebar
107
- if (Array.isArray(sidebarVal)) {
108
- const sidebarNames = sidebarVal.filter((item): item is string => typeof item === 'string')
133
+ if (sidebarItems.value.length) {
134
+ const sidebarNames = sidebarItems.value.filter((item): item is string => typeof item === 'string')
109
135
  categoryList.children.forEach((_val, key) => {
110
136
  if (!sidebarNames.includes(key))
111
137
  removeItemFromCategory(categoryList, key)
@@ -116,31 +142,63 @@ const categories = computed(() => {
116
142
  })
117
143
 
118
144
  const { hasSidebar } = useSidebar()
145
+
146
+ const shouldShowSidebar = computed(() => {
147
+ return hasSidebar.value && sidebarGroups.value.length > 0
148
+ })
149
+
150
+ function getSidebarItemKey(item: PressTheme.SidebarEntry, index: number): string | number {
151
+ if (typeof item === 'string')
152
+ return item
153
+ return item.text || item.link || index
154
+ }
155
+
156
+ function getSidebarRootItemClasses(groupIndex: number, itemIndex = 0) {
157
+ return [
158
+ 'press-sidebar-root-item',
159
+ { 'is-separated': groupIndex > 0 || itemIndex > 0 },
160
+ ]
161
+ }
119
162
  </script>
120
163
 
121
164
  <template>
122
165
  <aside
123
- v-if="hasSidebar"
166
+ v-if="shouldShowSidebar"
124
167
  class="press-sidebar shadow-lg" :class="{ open }"
125
168
  @click.stop
126
169
  >
127
- <div text="left" m="2">
128
- <ul v-for="item in sidebar" :key="item" class="category-list">
129
- <template v-if="typeof item === 'string'">
130
- <PressCategoryByName
131
- :categories="categories"
132
- :item="item"
133
- />
134
- </template>
170
+ <nav
171
+ id="pr-sidebar-nav"
172
+ class="press-sidebar-nav"
173
+ aria-label="Sidebar Navigation"
174
+ text="left"
175
+ m="2"
176
+ >
177
+ <ul v-for="(group, groupIndex) in renderGroups" :key="group.key" class="press-sidebar-list category-list">
135
178
  <PressSidebarItem
136
- v-else
137
- p="t-2"
138
- border="t t-$pr-c-divider-light"
139
- :item="item"
140
- :depth="0"
179
+ v-if="group.groupItem"
180
+ :class="getSidebarRootItemClasses(groupIndex)"
181
+ :item="group.groupItem"
182
+ :depth="sidebarGroupDepth"
141
183
  />
184
+ <template v-else>
185
+ <template v-for="(item, index) in group.items" :key="getSidebarItemKey(item, index)">
186
+ <PressCategoryByName
187
+ v-if="typeof item === 'string'"
188
+ :class="getSidebarRootItemClasses(groupIndex, index)"
189
+ :categories="categories"
190
+ :item="item"
191
+ />
192
+ <PressSidebarItem
193
+ v-else
194
+ :class="getSidebarRootItemClasses(groupIndex, index)"
195
+ :item="item"
196
+ :depth="0"
197
+ />
198
+ </template>
199
+ </template>
142
200
  </ul>
143
- </div>
201
+ </nav>
144
202
  </aside>
145
203
  </template>
146
204
 
@@ -189,6 +247,24 @@ const { hasSidebar } = useSidebar()
189
247
  }
190
248
  }
191
249
 
250
+ .press-sidebar-list {
251
+ list-style: none;
252
+ margin: 0;
253
+ padding: 0;
254
+ }
255
+
256
+ .press-sidebar-root-item.is-separated {
257
+ border-top: 1px solid var(--pr-c-divider-light);
258
+ }
259
+
260
+ .press-sidebar-root-item > .press-sidebar-item {
261
+ padding-top: 0.5rem;
262
+ }
263
+
264
+ .press-sidebar-root-item > .category-list-item {
265
+ border-top: 0;
266
+ }
267
+
192
268
  .category-list {
193
269
  &:first-child {
194
270
  .category-list-item {
@@ -211,6 +287,16 @@ const { hasSidebar } = useSidebar()
211
287
  flex-shrink: 0;
212
288
  }
213
289
 
290
+ .caret-icon {
291
+ width: 18px;
292
+ height: 18px;
293
+ transition: transform var(--va-transition-duration);
294
+
295
+ &.open {
296
+ transform: rotate(90deg);
297
+ }
298
+ }
299
+
214
300
  &:hover .caret {
215
301
  color: var(--vp-c-text-2);
216
302
  }
@@ -219,8 +305,4 @@ const { hasSidebar } = useSidebar()
219
305
  color: var(--vp-c-text-1);
220
306
  }
221
307
  }
222
-
223
- .category-list+.category-list {
224
- margin-top: 1rem;
225
- }
226
308
  </style>
@@ -19,8 +19,6 @@ const {
19
19
  toggle,
20
20
  } = useSidebarControl(computed(() => props.item))
21
21
 
22
- const sectionTag = computed(() => (hasChildren.value ? 'section' : `div`))
23
-
24
22
  const linkTag = computed(() => (isLink.value ? 'a' : 'div'))
25
23
 
26
24
  const textTag = computed(() => {
@@ -31,7 +29,13 @@ const textTag = computed(() => {
31
29
  : `h${props.depth + 2}`
32
30
  })
33
31
 
34
- const itemRole = computed(() => (isLink.value ? undefined : 'button'))
32
+ const rawText = computed(() => props.item.text || '')
33
+
34
+ const childItems = computed(() => props.item.items || [])
35
+
36
+ const hasChildItems = computed(() => childItems.value.length > 0)
37
+
38
+ const hasCaret = computed(() => collapsible.value && hasChildItems.value)
35
39
 
36
40
  const classes = computed(() => [
37
41
  [`level-${props.depth}`],
@@ -42,6 +46,8 @@ const classes = computed(() => [
42
46
  { 'has-active': hasActiveLink.value },
43
47
  ])
44
48
 
49
+ const itemRole = computed(() => (hasCaret.value && !isLink.value ? 'button' : undefined))
50
+
45
51
  function onItemInteraction(e: MouseEvent | Event) {
46
52
  if ('key' in e && e.key !== 'Enter')
47
53
  return
@@ -49,27 +55,28 @@ function onItemInteraction(e: MouseEvent | Event) {
49
55
  !props.item.link && toggle()
50
56
  }
51
57
 
52
- function onCaretClick() {
53
- props.item.link && toggle()
54
- }
55
-
56
58
  const { t } = useI18n()
57
59
 
58
- const htmlText = computed(() => t(props.item.text || ''))
60
+ const htmlText = computed(() => {
61
+ return t(rawText.value) || rawText.value
62
+ })
63
+
64
+ function getChildItemKey(item: DefaultTheme.SidebarItem, index: number): string | number {
65
+ return item.text || item.link || index
66
+ }
59
67
  </script>
60
68
 
61
69
  <template>
62
- <component
63
- :is="sectionTag"
64
- class="VPSidebarItem" :class="classes"
70
+ <li
71
+ class="VPSidebarItem press-sidebar-item-node" :class="classes"
65
72
  >
66
73
  <div
67
- v-if="item.text"
74
+ v-if="rawText"
68
75
  class="press-sidebar-item item"
69
76
  :role="itemRole"
70
- :tabindex="item.items && 0"
77
+ :tabindex="hasCaret && !props.item.link ? 0 : undefined"
71
78
  v-on="
72
- item.items
79
+ hasCaret && !props.item.link
73
80
  ? { click: onItemInteraction, keydown: onItemInteraction }
74
81
  : {}
75
82
  "
@@ -77,47 +84,57 @@ const htmlText = computed(() => t(props.item.text || ''))
77
84
  <div class="indicator" />
78
85
 
79
86
  <AppLink
80
- v-if="item.link"
87
+ v-if="props.item.link"
81
88
  :tag="linkTag"
82
89
  class="link"
83
- :href="item.link"
84
- :rel="item.rel"
85
- :target="item.target"
90
+ :href="props.item.link"
91
+ :rel="props.item.rel"
92
+ :target="props.item.target"
86
93
  >
87
- <!-- eslint-disable-next-line vue/no-v-text-v-html-on-component -->
88
- <component :is="textTag" class="text ml-1" v-html="htmlText" />
94
+ <component :is="textTag" class="text ml-1">
95
+ <span v-html="htmlText" />
96
+ </component>
89
97
  </AppLink>
90
- <!-- eslint-disable-next-line vue/no-v-text-v-html-on-component -->
91
- <component :is="textTag" v-else class="text ml-1" v-html="htmlText" />
98
+ <component :is="textTag" v-else class="text ml-1">
99
+ <span v-html="htmlText" />
100
+ </component>
92
101
 
93
102
  <button
94
- v-if="item.collapsed != null && item.items"
95
- tabindex="0" role="button" aria-label="toggle section"
96
- class="caret folder-action inline-flex cursor-pointer"
97
- @click="onCaretClick" @keydown.enter="onCaretClick"
103
+ v-if="hasCaret"
104
+ type="button"
105
+ aria-label="toggle section"
106
+ :aria-expanded="!collapsed"
107
+ class="caret"
108
+ @click.stop="toggle"
98
109
  >
99
- <div v-if="collapsed" i-ri-folder-add-line />
100
- <div v-else i-ri-folder-reduce-line />
110
+ <span class="caret-icon" :class="{ open: !collapsed }" i-ri-arrow-right-s-line aria-hidden="true" />
101
111
  </button>
102
112
  </div>
103
113
 
104
- <div v-if="item.items && item.items.length" class="items">
114
+ <ul v-if="hasChildItems" class="items press-sidebar-item-list">
105
115
  <template v-if="depth < 5">
106
116
  <PressSidebarItem
107
- v-for="i in item.items"
108
- :key="i.text"
117
+ v-for="(i, index) in childItems"
118
+ :key="getChildItemKey(i, index)"
109
119
  :item="i"
110
120
  :depth="depth + 1"
111
121
  />
112
122
  </template>
113
- </div>
114
- </component>
123
+ </ul>
124
+ </li>
115
125
  </template>
116
126
 
117
127
  <style scoped>
118
- /* .VPSidebarItem.level-0 {
128
+ .press-sidebar-item-node,
129
+ .press-sidebar-item-list {
130
+ list-style: none;
131
+ margin: 0;
132
+ padding: 0;
133
+ }
134
+
135
+ .VPSidebarItem.level-0 {
119
136
  padding-bottom: 24px;
120
- } */
137
+ }
121
138
 
122
139
  .VPSidebarItem.collapsed.level-0 {
123
140
  padding-bottom: 10px;
@@ -4,6 +4,7 @@ import { isCategoryList, removeItemFromCategory, useFrontmatter, usePageList, us
4
4
  import { computed } from 'vue'
5
5
  import { useI18n } from 'vue-i18n'
6
6
  import { useRoute } from 'vue-router'
7
+ import { getSidebar } from '../utils/sidebar'
7
8
  import { useLocaleConfig } from './locale'
8
9
  import { normalize } from './sidebar'
9
10
 
@@ -109,70 +110,6 @@ function getCategoryLinks(categoryTree: CategoryList, categoryName: string, reso
109
110
  return links
110
111
  }
111
112
 
112
- /**
113
- * Resolve sidebar items for the current route path from the theme config.
114
- */
115
- function getSidebarItems(
116
- sidebar: PressTheme.Sidebar | undefined,
117
- path: string,
118
- ): PressTheme.SidebarItem[] {
119
- if (!sidebar)
120
- return []
121
-
122
- if (Array.isArray(sidebar)) {
123
- // Sidebar arrays may contain strings (category names) — filter to objects only
124
- const items = sidebar.filter((item): item is PressTheme.SidebarItem => typeof item !== 'string')
125
- return addBase(items)
126
- }
127
-
128
- // SidebarMulti: find the longest matching prefix
129
- path = ensureStartingSlash(path)
130
- const dir = Object.keys(sidebar)
131
- .sort((a, b) => b.split('/').length - a.split('/').length)
132
- .find((dir) => {
133
- const normalizedDir = ensureStartingSlash(dir)
134
- // Ensure boundary match: dir must end with '/' or path must have '/' after dir
135
- const dirWithSlash = normalizedDir.endsWith('/') ? normalizedDir : `${normalizedDir}/`
136
- return path.startsWith(dirWithSlash) || path === normalizedDir
137
- })
138
-
139
- if (!dir)
140
- return []
141
-
142
- const matched = sidebar[dir]
143
- if (Array.isArray(matched)) {
144
- const items = matched.filter((item): item is PressTheme.SidebarItem => typeof item !== 'string')
145
- return addBase(items)
146
- }
147
-
148
- return addBase(matched.items, matched.base)
149
- }
150
-
151
- function ensureStartingSlash(path: string): string {
152
- return path.startsWith('/') ? path : `/${path}`
153
- }
154
-
155
- /**
156
- * Join base and link with exactly one `/` separator.
157
- */
158
- function joinPath(base: string, link: string): string {
159
- const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base
160
- const normalizedLink = link.startsWith('/') ? link : `/${link}`
161
- return normalizedBase + normalizedLink
162
- }
163
-
164
- function addBase(items: PressTheme.SidebarItem[], base?: string): PressTheme.SidebarItem[] {
165
- return items.map((item) => {
166
- const result = { ...item }
167
- const itemBase = result.base || base
168
- if (itemBase && result.link)
169
- result.link = joinPath(itemBase, result.link)
170
- if (result.items)
171
- result.items = addBase(result.items, itemBase)
172
- return result
173
- })
174
- }
175
-
176
113
  interface FlatLink {
177
114
  text: string
178
115
  link: string
@@ -251,29 +188,17 @@ export function usePrevNext() {
251
188
  })
252
189
 
253
190
  return computed(() => {
254
- const sidebar = localeConfig.value.sidebar
255
- let candidates: FlatLink[]
256
-
257
- if (Array.isArray(sidebar)) {
258
- // Build category tree from locale-filtered pages
259
- const categoryTree = buildCategoryTree(localePages.value)
260
-
261
- // Walk sidebar items in order, expanding string categories into page links
262
- candidates = []
263
- for (const item of sidebar) {
264
- if (typeof item === 'string') {
265
- // Expand category name into its page links
266
- candidates.push(...getCategoryLinks(categoryTree, item, resolveTitle))
267
- }
268
- else {
269
- // Object sidebar item — flatten as before
270
- candidates.push(...getFlatLinks(addBase([item]), t))
271
- }
191
+ const categoryTree = buildCategoryTree(localePages.value)
192
+ const sidebarItems = getSidebar(localeConfig.value.sidebar, route.path)
193
+ const candidates: FlatLink[] = []
194
+
195
+ for (const item of sidebarItems) {
196
+ if (typeof item === 'string') {
197
+ candidates.push(...getCategoryLinks(categoryTree, item, resolveTitle))
198
+ continue
272
199
  }
273
- }
274
- else {
275
- // SidebarMulti — only object items, use original logic
276
- candidates = getFlatLinks(getSidebarItems(sidebar, route.path), t)
200
+
201
+ candidates.push(...getFlatLinks([item], t))
277
202
  }
278
203
 
279
204
  const index = candidates.findIndex(link =>
@@ -38,7 +38,7 @@ export function isActive(
38
38
  if (matchPath === undefined)
39
39
  return false
40
40
 
41
- currentPath = normalize(`/${currentPath}`)
41
+ currentPath = normalize(currentPath.startsWith('/') ? currentPath : `/${currentPath}`)
42
42
 
43
43
  if (asRegex)
44
44
  return new RegExp(matchPath).test(currentPath)
package/layouts/404.vue CHANGED
@@ -1,34 +1,280 @@
1
1
  <script lang="ts" setup>
2
+ import { useBack } from 'valaxy'
3
+ import { computed } from 'vue'
2
4
  import { useI18n } from 'vue-i18n'
3
- import { useRouter } from 'vue-router'
5
+ import { useRoute } from 'vue-router'
6
+ import PressNotFoundVisual from '../components/PressNotFoundVisual.vue'
4
7
 
5
- const router = useRouter()
6
- const { t } = useI18n()
8
+ type NotFoundLocale = 'en' | 'zh'
9
+
10
+ const route = useRoute()
11
+ const { locale } = useI18n()
12
+ const { back } = useBack()
13
+
14
+ const messages = {
15
+ en: {
16
+ eyebrow: 'Lost route',
17
+ title: 'This page drifted out of orbit.',
18
+ description: 'The address you opened does not match a page in this site. Check the path, return to the last page, or restart from the home page.',
19
+ currentPath: 'Current path',
20
+ back: 'Go back',
21
+ home: 'Home',
22
+ orbit: 'Route signal',
23
+ },
24
+ zh: {
25
+ eyebrow: '路线迷航',
26
+ title: '这个页面暂时偏离了轨道。',
27
+ description: '当前地址没有匹配到站点页面。你可以检查路径,返回上一页,或从首页重新出发。',
28
+ currentPath: '当前路径',
29
+ back: '返回上一页',
30
+ home: '回到首页',
31
+ orbit: '路由信号',
32
+ },
33
+ } as const
34
+
35
+ const currentLocale = computed<NotFoundLocale>(
36
+ () => locale.value.startsWith('zh') || route.path.startsWith('/zh') ? 'zh' : 'en',
37
+ )
38
+
39
+ const content = computed(() => messages[currentLocale.value])
40
+ const currentPath = computed(() => route.fullPath || route.path)
41
+ const homeLink = computed(() => currentLocale.value === 'zh' ? '/zh/' : '/')
7
42
  </script>
8
43
 
9
44
  <template>
10
- <Layout>
11
- <template #sidebar>
12
- <div />
13
- </template>
14
- <div text="center" m="t-20">
15
- <div class="not-found" title="404" font="mono">
16
- 404
17
- </div>
18
-
19
- <RouterView />
20
- <div>
21
- <button btn text-sm m="3 t8" @click="router.back()">
22
- {{ t('button.back') }}
23
- </button>
24
- </div>
25
- </div>
26
- </Layout>
45
+ <div class="layout antialiased not-found-layout">
46
+ <PressNav />
47
+ <main class="not-found-shell">
48
+ <section class="not-found-panel" aria-labelledby="not-found-title">
49
+ <PressNotFoundVisual :label="content.orbit" />
50
+
51
+ <div class="not-found-copy">
52
+ <div class="not-found-toolbar">
53
+ <p class="not-found-eyebrow">
54
+ <span i-ri-compass-3-line aria-hidden="true" />
55
+ {{ content.eyebrow }}
56
+ </p>
57
+ </div>
58
+
59
+ <h1 id="not-found-title" class="not-found-title">
60
+ {{ content.title }}
61
+ </h1>
62
+
63
+ <p class="not-found-description">
64
+ {{ content.description }}
65
+ </p>
66
+
67
+ <p class="not-found-path">
68
+ <span>{{ content.currentPath }}</span>
69
+ <code>{{ currentPath }}</code>
70
+ </p>
71
+
72
+ <div class="not-found-actions">
73
+ <button type="button" class="not-found-action primary" @click="back">
74
+ <span i-ri-arrow-left-line aria-hidden="true" />
75
+ {{ content.back }}
76
+ </button>
77
+
78
+ <AppLink class="not-found-action secondary" :href="homeLink">
79
+ <span i-ri-home-4-line aria-hidden="true" />
80
+ {{ content.home }}
81
+ </AppLink>
82
+ </div>
83
+ </div>
84
+ </section>
85
+ </main>
86
+ </div>
27
87
  </template>
28
88
 
29
89
  <style lang="scss" scoped>
30
- .not-found {
31
- font-size: 10rem;
32
- text-shadow: 0 5px 10px rgb(0 0 0 / .25), 0 20px 20px rgb(0 0 0 / .15);
90
+ .not-found-layout {
91
+ min-height: 100svh;
92
+ overflow: hidden;
93
+ }
94
+
95
+ .not-found-shell {
96
+ min-height: 100svh;
97
+ box-sizing: border-box;
98
+ display: grid;
99
+ place-items: center;
100
+ overflow: hidden;
101
+ padding: calc(var(--pr-nav-height) + clamp(16px, 3vw, 28px)) clamp(18px, 5vw, 72px) clamp(20px, 4vw, 40px);
102
+ }
103
+
104
+ .not-found-panel {
105
+ width: min(100%, 1040px);
106
+ max-height: calc(100svh - var(--pr-nav-height) - clamp(36px, 7vw, 68px));
107
+ display: grid;
108
+ grid-template-columns: minmax(280px, .9fr) minmax(0, 1fr);
109
+ align-items: center;
110
+ gap: clamp(28px, 6vw, 72px);
111
+ }
112
+
113
+ .not-found-copy {
114
+ min-width: 0;
115
+ }
116
+
117
+ .not-found-toolbar {
118
+ display: flex;
119
+ align-items: center;
120
+ margin-bottom: 18px;
121
+ }
122
+
123
+ .not-found-eyebrow {
124
+ display: inline-flex;
125
+ align-items: center;
126
+ gap: 8px;
127
+ margin: 0;
128
+ color: var(--vp-c-brand-1);
129
+ font-size: 14px;
130
+ font-weight: 700;
131
+ }
132
+
133
+ .not-found-title {
134
+ max-width: 12ch;
135
+ margin: 0;
136
+ color: var(--vp-c-text-1);
137
+ font-size: clamp(2.25rem, 6vw, 4.75rem);
138
+ line-height: .98;
139
+ letter-spacing: 0;
140
+ }
141
+
142
+ .not-found-description {
143
+ max-width: 42rem;
144
+ margin: 18px 0 0;
145
+ color: var(--vp-c-text-2);
146
+ font-size: clamp(1rem, 2vw, 1.125rem);
147
+ line-height: 1.7;
148
+ }
149
+
150
+ .not-found-path {
151
+ display: grid;
152
+ gap: 8px;
153
+ max-width: 100%;
154
+ margin: 20px 0 0;
155
+ padding: 12px 16px;
156
+ border: 1px solid var(--vp-c-divider);
157
+ border-radius: 8px;
158
+ background: color-mix(in srgb, var(--vp-c-bg-soft) 72%, transparent);
159
+ color: var(--vp-c-text-2);
160
+ font-size: 13px;
161
+ }
162
+
163
+ .not-found-path code {
164
+ display: block;
165
+ max-width: 100%;
166
+ overflow-wrap: anywhere;
167
+ color: var(--vp-c-text-1);
168
+ font-size: 14px;
169
+ }
170
+
171
+ .not-found-actions {
172
+ display: flex;
173
+ flex-wrap: wrap;
174
+ gap: 12px;
175
+ margin-top: 22px;
176
+ }
177
+
178
+ .not-found-action {
179
+ display: inline-flex;
180
+ align-items: center;
181
+ justify-content: center;
182
+ gap: 8px;
183
+ min-height: 42px;
184
+ padding: 0 18px;
185
+ border: 1px solid transparent;
186
+ border-radius: 999px;
187
+ font-size: 14px;
188
+ font-weight: 700;
189
+ text-decoration: none;
190
+ cursor: pointer;
191
+ transition: transform var(--va-transition-duration), border-color var(--va-transition-duration), background-color var(--va-transition-duration), color var(--va-transition-duration), box-shadow var(--va-transition-duration);
192
+ }
193
+
194
+ .not-found-action:hover {
195
+ transform: translateY(-1px);
196
+ }
197
+
198
+ .not-found-action.primary {
199
+ background: var(--vp-c-brand-1);
200
+ color: var(--vp-c-white);
201
+ box-shadow: 0 12px 28px color-mix(in srgb, var(--vp-c-brand-1) 24%, transparent);
202
+ }
203
+
204
+ .not-found-action.secondary {
205
+ border-color: var(--vp-c-divider);
206
+ background: var(--vp-c-bg);
207
+ color: var(--vp-c-text-1);
208
+ }
209
+
210
+ @media (max-width: 767px) {
211
+ .not-found-layout {
212
+ height: 100svh;
213
+ display: flex;
214
+ flex-direction: column;
215
+ }
216
+
217
+ .not-found-shell {
218
+ flex: 1;
219
+ min-height: 0;
220
+ padding: clamp(8px, 2.4svh, 16px) clamp(18px, 5vw, 24px) clamp(12px, 3svh, 20px);
221
+ }
222
+
223
+ .not-found-panel {
224
+ max-height: 100%;
225
+ grid-template-columns: 1fr;
226
+ align-content: center;
227
+ gap: clamp(8px, 1.6svh, 14px);
228
+ }
229
+
230
+ .not-found-toolbar {
231
+ margin-bottom: clamp(8px, 1.8svh, 14px);
232
+ }
233
+
234
+ .not-found-title {
235
+ max-width: 13ch;
236
+ font-size: clamp(1.75rem, 8.5vw, 2.75rem);
237
+ line-height: 1;
238
+ }
239
+
240
+ .not-found-description {
241
+ margin-top: clamp(8px, 1.6svh, 12px);
242
+ font-size: clamp(.88rem, 3.8vw, 1rem);
243
+ line-height: 1.55;
244
+ }
245
+
246
+ .not-found-path {
247
+ gap: 6px;
248
+ margin-top: clamp(10px, 1.8svh, 14px);
249
+ padding: 10px 14px;
250
+ }
251
+
252
+ .not-found-path code {
253
+ font-size: 13px;
254
+ }
255
+
256
+ .not-found-actions,
257
+ .not-found-action {
258
+ width: 100%;
259
+ }
260
+
261
+ .not-found-actions {
262
+ gap: 8px;
263
+ margin-top: clamp(10px, 2svh, 16px);
264
+ }
265
+
266
+ .not-found-action {
267
+ min-height: 38px;
268
+ }
269
+ }
270
+
271
+ @media (max-width: 767px) and (max-height: 700px) {
272
+ .not-found-path {
273
+ display: none;
274
+ }
275
+
276
+ .not-found-description {
277
+ max-width: 34rem;
278
+ }
33
279
  }
34
280
  </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "valaxy-theme-press",
3
- "version": "0.28.11",
3
+ "version": "1.0.0-beta.2",
4
4
  "description": "Docs (VitePress-like) theme for Valaxy",
5
5
  "author": {
6
6
  "email": "me@yunyoujun.cn",
@@ -33,10 +33,10 @@
33
33
  "@docsearch/css": "^4.6.3",
34
34
  "@docsearch/js": "^4.6.3",
35
35
  "@docsearch/sidepanel-js": "^4.6.3",
36
- "reka-ui": "^2.9.7",
36
+ "reka-ui": "^2.10.1",
37
37
  "vitepress": "^2.0.0-alpha.17"
38
38
  },
39
39
  "devDependencies": {
40
- "valaxy": "0.28.11"
40
+ "valaxy": "1.0.0-beta.2"
41
41
  }
42
42
  }
package/types/index.d.ts CHANGED
@@ -4,8 +4,6 @@ export * from '../composables'
4
4
  export * from './home.d'
5
5
 
6
6
  export namespace PressTheme {
7
- export type Sidebar = any
8
-
9
7
  export interface Footer {
10
8
  message?: string
11
9
  copyright?: string
@@ -32,9 +30,10 @@ export namespace PressTheme {
32
30
  text?: string
33
31
  }
34
32
 
35
- export type Sidebar = SidebarItem[] | SidebarMulti
33
+ export type SidebarEntry = string | SidebarItem
34
+ export type Sidebar = SidebarEntry[] | SidebarMulti
36
35
  export interface SidebarMulti {
37
- [path: string]: SidebarItem[] | { items: SidebarItem[], base: string }
36
+ [path: string]: SidebarEntry[] | { items: SidebarEntry[], base?: string }
38
37
  }
39
38
  export interface SidebarItem {
40
39
  /**
@@ -0,0 +1,89 @@
1
+ import type { PressTheme } from '../types'
2
+
3
+ export interface SidebarGroup extends Omit<PressTheme.SidebarItem, 'items'> {
4
+ items: PressTheme.SidebarEntry[]
5
+ }
6
+
7
+ /**
8
+ * Resolve the sidebar for the current path.
9
+ *
10
+ * This follows VitePress' default theme behavior: arrays are used as-is,
11
+ * multi-sidebars use the longest matching path prefix, and object entries can
12
+ * provide a shared `base` for child links.
13
+ */
14
+ export function getSidebar(sidebar: PressTheme.Sidebar | undefined, path: string): PressTheme.SidebarEntry[] {
15
+ if (Array.isArray(sidebar))
16
+ return addBase(sidebar)
17
+
18
+ if (!sidebar)
19
+ return []
20
+
21
+ const normalizedPath = ensureStartingSlash(path)
22
+ const dir = Object.keys(sidebar)
23
+ .sort((a, b) => b.split('/').length - a.split('/').length)
24
+ .find(dir => normalizedPath.startsWith(ensureStartingSlash(dir)))
25
+
26
+ const matched = dir ? sidebar[dir] : []
27
+
28
+ return Array.isArray(matched)
29
+ ? addBase(matched)
30
+ : addBase(matched.items, matched.base)
31
+ }
32
+
33
+ /**
34
+ * Convert resolved sidebar entries into renderable groups.
35
+ *
36
+ * Explicit groups with `items` are kept as groups. Consecutive top-level links
37
+ * or category names are grouped into an anonymous group, matching VitePress'
38
+ * sidebar rendering model.
39
+ */
40
+ export function getSidebarGroups(sidebar: PressTheme.SidebarEntry[]): SidebarGroup[] {
41
+ const groups: SidebarGroup[] = []
42
+ let lastGroupIndex = 0
43
+
44
+ for (const item of sidebar) {
45
+ if (isSidebarItem(item) && item.items) {
46
+ groups.push({
47
+ ...item,
48
+ items: item.items,
49
+ })
50
+ lastGroupIndex = groups.length
51
+ continue
52
+ }
53
+
54
+ if (!groups[lastGroupIndex])
55
+ groups.push({ items: [] })
56
+
57
+ groups[lastGroupIndex].items.push(item)
58
+ }
59
+
60
+ return groups
61
+ }
62
+
63
+ export function isSidebarItem(item: PressTheme.SidebarEntry): item is PressTheme.SidebarItem {
64
+ return typeof item !== 'string'
65
+ }
66
+
67
+ function ensureStartingSlash(path: string): string {
68
+ return path.startsWith('/') ? path : `/${path}`
69
+ }
70
+
71
+ function addBase(items: PressTheme.SidebarEntry[], base?: string): PressTheme.SidebarEntry[] {
72
+ return items.map(item => addBaseToItem(item, base))
73
+ }
74
+
75
+ function addBaseToItem(item: PressTheme.SidebarEntry, base?: string): PressTheme.SidebarEntry {
76
+ if (!isSidebarItem(item))
77
+ return item
78
+
79
+ const result = { ...item }
80
+ const itemBase = result.base || base
81
+
82
+ if (itemBase && result.link)
83
+ result.link = itemBase + result.link.replace(/^\//, itemBase.endsWith('/') ? '' : '/')
84
+
85
+ if (result.items)
86
+ result.items = addBase(result.items, itemBase).filter(isSidebarItem)
87
+
88
+ return result
89
+ }