@lupinum/ginko-docs 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/README.md +130 -73
  2. package/app/app.config.ts +14 -12
  3. package/app/app.vue +2 -2
  4. package/app/assets/css/prose.css +2203 -149
  5. package/app/assets/css/tailwind.css +60 -38
  6. package/app/assets/css/theme-palettes.css +309 -0
  7. package/app/components/content/DocumentPageShell.vue +1 -1
  8. package/app/components/content/Feedback.vue +12 -11
  9. package/app/components/content/ImageZoomDialog.vue +84 -0
  10. package/app/components/content/PageMarkdownCopy.vue +2 -0
  11. package/app/components/content/imageZoom.ts +25 -0
  12. package/app/components/mdc/MdcAccordion.vue +6 -3
  13. package/app/components/mdc/MdcAccordionItem.vue +7 -16
  14. package/app/components/mdc/MdcApi.vue +159 -0
  15. package/app/components/mdc/MdcAside.vue +6 -10
  16. package/app/components/mdc/MdcCallout.vue +9 -10
  17. package/app/components/mdc/MdcCard.vue +36 -47
  18. package/app/components/mdc/MdcCards.vue +30 -0
  19. package/app/components/mdc/MdcCodeGroup.vue +4 -0
  20. package/app/components/mdc/MdcCodeTree.vue +234 -0
  21. package/app/components/mdc/MdcCollapse.vue +61 -0
  22. package/app/components/mdc/MdcError.vue +8 -1
  23. package/app/components/mdc/MdcExcerpt.vue +26 -0
  24. package/app/components/mdc/MdcFigure.vue +54 -20
  25. package/app/components/mdc/MdcFiles.vue +120 -0
  26. package/app/components/mdc/MdcIdea.vue +2 -1
  27. package/app/components/mdc/MdcInfo.vue +2 -1
  28. package/app/components/mdc/MdcInlineToc.vue +51 -0
  29. package/app/components/{ui/dialog/DialogHeader.vue → mdc/MdcKbd.vue} +2 -4
  30. package/app/components/mdc/MdcNote.vue +2 -1
  31. package/app/components/mdc/MdcQuiz.vue +43 -83
  32. package/app/components/mdc/MdcQuizQuestion.vue +104 -109
  33. package/app/components/mdc/MdcReadMore.vue +57 -77
  34. package/app/components/mdc/MdcSteps.vue +45 -11
  35. package/app/components/mdc/MdcSuccess.vue +8 -1
  36. package/app/components/mdc/MdcTabs.vue +44 -26
  37. package/app/components/mdc/MdcTimeline.vue +4 -1
  38. package/app/components/mdc/MdcTimelineItem.vue +18 -27
  39. package/app/components/mdc/MdcWarning.vue +8 -1
  40. package/app/components/mdc/api.utils.ts +132 -0
  41. package/app/components/mdc/code-tree.utils.ts +22 -0
  42. package/app/components/mdc/quiz.utils.ts +38 -0
  43. package/app/components/prose/ProseBlockquote.vue +17 -1
  44. package/app/components/prose/ProseH1.vue +1 -1
  45. package/app/components/prose/ProseH2.vue +7 -2
  46. package/app/components/prose/ProseH3.vue +7 -2
  47. package/app/components/prose/ProseH4.vue +17 -2
  48. package/app/components/{ui/kbd/KbdGroup.vue → prose/ProseH5.vue} +4 -3
  49. package/app/components/{ui/card/CardTitle.vue → prose/ProseH6.vue} +4 -3
  50. package/app/components/prose/ProseImg.vue +19 -112
  51. package/app/components/prose/ProsePre.vue +63 -100
  52. package/app/components/prose/ProseTable.vue +5 -3
  53. package/app/components/site/ModeToggle.vue +12 -3
  54. package/app/components/site/SiteBanner.vue +43 -2
  55. package/app/components/site/SiteFooter.vue +3 -5
  56. package/app/components/site/SiteHeader.vue +255 -132
  57. package/app/components/site/SiteHeroCode.vue +164 -0
  58. package/app/components/site/SiteLocaleSwitcher.vue +22 -4
  59. package/app/components/ui/badge/Badge.vue +1 -1
  60. package/app/components/ui/button/Button.vue +1 -1
  61. package/app/components/ui/collapsible/CollapsibleContent.vue +1 -1
  62. package/app/components/ui/dropdown-menu/DropdownMenuContent.vue +14 -4
  63. package/app/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
  64. package/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +1 -1
  65. package/app/components/ui/dropdown-menu/DropdownMenuSeparator.vue +1 -1
  66. package/app/components/ui/dropdown-menu/index.ts +0 -9
  67. package/app/components/ui/kbd/Kbd.vue +1 -1
  68. package/app/components/ui/kbd/index.ts +0 -1
  69. package/app/components/ui/scroll-area/ScrollArea.vue +1 -1
  70. package/app/components/ui/scroll-area/ScrollBar.vue +1 -1
  71. package/app/components/ui/separator/Separator.vue +1 -1
  72. package/app/components/ui/sheet/SheetContent.vue +1 -1
  73. package/app/components/ui/sheet/SheetDescription.vue +1 -1
  74. package/app/components/ui/sheet/SheetOverlay.vue +1 -1
  75. package/app/components/ui/sheet/SheetTitle.vue +1 -1
  76. package/app/components/ui/sheet/index.ts +0 -3
  77. package/app/components/ui/tabs/Tabs.vue +1 -1
  78. package/app/components/ui/tabs/TabsList.vue +1 -1
  79. package/app/components/ui/tabs/TabsTrigger.vue +1 -1
  80. package/app/components/ui/tabs/index.ts +0 -1
  81. package/app/composables/site-navigation.utils.ts +29 -11
  82. package/app/composables/useContentRouteAlternates.ts +13 -21
  83. package/app/composables/useLocalizedRouteSwitch.ts +1 -1
  84. package/app/composables/useMetaKey.ts +18 -0
  85. package/app/composables/useProseAppearance.ts +24 -0
  86. package/app/composables/useSiteNavigation.ts +3 -18
  87. package/app/config/site.utils.ts +1 -1
  88. package/app/error.vue +1 -5
  89. package/app/features/docs/components/DocsBreadcrumb.vue +37 -0
  90. package/app/features/docs/components/DocsContributeLinks.vue +28 -18
  91. package/app/features/docs/components/DocsMobileToc.vue +12 -8
  92. package/app/features/docs/components/DocsPageContent.vue +84 -47
  93. package/app/features/docs/components/DocsPageNav.vue +1 -1
  94. package/app/features/docs/components/DocsSidebar.vue +30 -20
  95. package/app/features/docs/components/DocsSidebarItem.vue +53 -100
  96. package/app/features/docs/components/DocsSidebarRow.vue +27 -0
  97. package/app/features/docs/components/DocsSidebarTabs.vue +46 -22
  98. package/app/features/docs/components/DocsToc.vue +80 -34
  99. package/app/features/docs/composables/useDocsNavigation.ts +2 -2
  100. package/app/features/docs/composables/useDocsNavigationData.ts +4 -1
  101. package/app/features/docs/composables/useRevealActive.ts +58 -0
  102. package/app/features/docs/composables/useScrollspy.ts +87 -19
  103. package/app/features/docs/docs-navigation.ts +17 -30
  104. package/app/features/docs/toc-context.ts +6 -0
  105. package/app/features/search/command-center.ts +11 -90
  106. package/app/features/search/components/SiteCommandCenter.vue +58 -38
  107. package/app/features/search/useCommandCenter.ts +46 -46
  108. package/app/layouts/docs.vue +1 -1
  109. package/app/lib/errors.ts +2 -49
  110. package/app/pages/blog/[slug].vue +16 -8
  111. package/app/pages/blog/index.vue +26 -13
  112. package/app/pages/docs/index.vue +10 -14
  113. package/app/pages/index.vue +174 -59
  114. package/app/plugins/ginko-docs-theme.ts +12 -0
  115. package/app/utils/content.ts +11 -0
  116. package/app/utils/file-icons.ts +90 -0
  117. package/app/utils/index.ts +7 -1
  118. package/app/utils/repository.ts +20 -0
  119. package/content.js +23 -26
  120. package/content.ts +30 -28
  121. package/i18n/messages/global/blog.ts +4 -0
  122. package/i18n/messages/global/command.ts +6 -2
  123. package/i18n/messages/global/docs.ts +3 -0
  124. package/i18n/messages/global/nav.ts +6 -2
  125. package/i18n/messages/index.ts +3 -3
  126. package/icon-bundle.ts +5 -0
  127. package/index.d.ts +7 -1
  128. package/nuxt.config.ts +2 -2
  129. package/package.json +9 -6
  130. package/runtime/server/agent-markdown.ts +0 -2
  131. package/shared/theme-palettes.ts +37 -0
  132. package/shared/types/app-config.ts +63 -6
  133. package/tags.ts +47 -59
  134. package/app/components/mdc/MdcAlert.vue +0 -34
  135. package/app/components/mdc/MdcCardGroup.vue +0 -47
  136. package/app/components/mdc/MdcDocImg.vue +0 -25
  137. package/app/components/mdc/MdcField.vue +0 -62
  138. package/app/components/mdc/MdcFieldGroup.vue +0 -18
  139. package/app/components/mdc/MdcPassage.vue +0 -32
  140. package/app/components/mdc/MdcQuizOption.vue +0 -91
  141. package/app/components/mdc/MdcReadMoreGroup.vue +0 -18
  142. package/app/components/mdc/MdcShortcut.vue +0 -46
  143. package/app/components/mdc/MdcStep.vue +0 -42
  144. package/app/components/ui/accordion/Accordion.vue +0 -15
  145. package/app/components/ui/accordion/AccordionContent.vue +0 -23
  146. package/app/components/ui/accordion/AccordionItem.vue +0 -24
  147. package/app/components/ui/accordion/AccordionTrigger.vue +0 -35
  148. package/app/components/ui/accordion/index.ts +0 -4
  149. package/app/components/ui/alert/Alert.vue +0 -21
  150. package/app/components/ui/alert/AlertDescription.vue +0 -22
  151. package/app/components/ui/alert/AlertTitle.vue +0 -22
  152. package/app/components/ui/alert/index.ts +0 -34
  153. package/app/components/ui/card/Card.vue +0 -16
  154. package/app/components/ui/card/CardAction.vue +0 -17
  155. package/app/components/ui/card/CardContent.vue +0 -14
  156. package/app/components/ui/card/CardDescription.vue +0 -14
  157. package/app/components/ui/card/CardFooter.vue +0 -14
  158. package/app/components/ui/card/CardHeader.vue +0 -22
  159. package/app/components/ui/card/card-variants.ts +0 -15
  160. package/app/components/ui/card/index.ts +0 -8
  161. package/app/components/ui/checkbox/Checkbox.vue +0 -35
  162. package/app/components/ui/checkbox/index.ts +0 -1
  163. package/app/components/ui/dialog/Dialog.vue +0 -15
  164. package/app/components/ui/dialog/DialogClose.vue +0 -12
  165. package/app/components/ui/dialog/DialogContent.vue +0 -69
  166. package/app/components/ui/dialog/DialogDescription.vue +0 -21
  167. package/app/components/ui/dialog/DialogFooter.vue +0 -15
  168. package/app/components/ui/dialog/DialogOverlay.vue +0 -26
  169. package/app/components/ui/dialog/DialogPortal.vue +0 -12
  170. package/app/components/ui/dialog/DialogTitle.vue +0 -21
  171. package/app/components/ui/dialog/DialogTrigger.vue +0 -12
  172. package/app/components/ui/dialog/index.ts +0 -10
  173. package/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +0 -36
  174. package/app/components/ui/dropdown-menu/DropdownMenuGroup.vue +0 -12
  175. package/app/components/ui/dropdown-menu/DropdownMenuLabel.vue +0 -25
  176. package/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue +0 -17
  177. package/app/components/ui/dropdown-menu/DropdownMenuSub.vue +0 -15
  178. package/app/components/ui/dropdown-menu/DropdownMenuSubContent.vue +0 -29
  179. package/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +0 -31
  180. package/app/components/ui/input/Input.vue +0 -23
  181. package/app/components/ui/input/index.ts +0 -1
  182. package/app/components/ui/label/Label.vue +0 -14
  183. package/app/components/ui/label/index.ts +0 -1
  184. package/app/components/ui/sheet/SheetClose.vue +0 -12
  185. package/app/components/ui/sheet/SheetFooter.vue +0 -12
  186. package/app/components/ui/sheet/SheetHeader.vue +0 -12
  187. package/app/components/ui/switch/Switch.vue +0 -39
  188. package/app/components/ui/switch/index.ts +0 -1
  189. package/app/components/ui/tabs/TabsContent.vue +0 -21
  190. package/app/components/ui/textarea/Textarea.vue +0 -31
  191. package/app/components/ui/textarea/index.ts +0 -1
  192. package/app/features/docs/composables/useDocsEntryPath.ts +0 -13
  193. package/app/lib/utils.ts +0 -7
  194. package/i18n/messages/source.ts +0 -5
  195. package/runtime/markdown/inline-code-highlight.ts +0 -155
  196. package/runtime/utils.ts +0 -7
@@ -1,9 +1,10 @@
1
1
  <script setup lang="ts">
2
2
  import type { VNode } from "vue";
3
- import { h, provide, reactive, ref, computed, useSlots } from "vue";
3
+ import { cloneVNode, h, provide, reactive, ref, computed, useSlots } from "vue";
4
4
  import { Icon } from "#components";
5
5
  import { useI18n } from "#imports";
6
6
  import { cn } from "../../utils";
7
+ import { useProseAppearance } from "../../composables/useProseAppearance";
7
8
 
8
9
  const props = defineProps<{
9
10
  title?: string;
@@ -16,7 +17,9 @@ const props = defineProps<{
16
17
  perfectLabel?: string;
17
18
  retryPrompt?: string;
18
19
  resetLabel?: string;
20
+ appearance?: "quiet" | "tint";
19
21
  }>();
22
+ const appearance = useProseAppearance("quiz", () => props.appearance);
20
23
 
21
24
  const MDC_QUIZ = Symbol.for("mdc.quiz");
22
25
 
@@ -38,15 +41,6 @@ function registerQuestion(): number {
38
41
 
39
42
  function markAnswered(index: number, correct: boolean) {
40
43
  state.answers.set(index, correct);
41
- setTimeout(() => {
42
- if (showResults.value) return;
43
- for (let i = index + 1; i < state.totalQuestions; i++) {
44
- if (!state.answers.has(i)) {
45
- activeIndex.value = i;
46
- return;
47
- }
48
- }
49
- }, 1200);
50
44
  }
51
45
 
52
46
  function reset() {
@@ -60,6 +54,7 @@ provide(MDC_QUIZ, { registerQuestion, markAnswered, resetKey, inQuiz: true });
60
54
 
61
55
  const correctCount = computed(() => [...state.answers.values()].filter(Boolean).length);
62
56
  const currentAnswered = computed(() => state.answers.has(activeIndex.value));
57
+ const allAnswered = computed(() => state.answers.size === state.totalQuestions);
63
58
  const { locale } = useI18n();
64
59
 
65
60
  const text = computed(() => {
@@ -68,7 +63,7 @@ const text = computed(() => {
68
63
  return {
69
64
  question: props.questionLabel ?? (isGerman ? "Frage" : "Question"),
70
65
  of: isGerman ? "von" : "of",
71
- back: props.backLabel ?? (isGerman ? "Zurueck" : "Back"),
66
+ back: props.backLabel ?? (isGerman ? "Zurück" : "Back"),
72
67
  next: props.nextLabel ?? (isGerman ? "Weiter" : "Next"),
73
68
  results: props.resultsLabel ?? (isGerman ? "Ergebnis anzeigen" : "See Results"),
74
69
  correct: props.correctSummaryLabel ?? (isGerman ? "richtig" : "correct"),
@@ -97,35 +92,19 @@ function renderButton(
97
92
  {
98
93
  type: "button",
99
94
  disabled: options.disabled,
100
- class: cn(
101
- "inline-flex h-8 items-center justify-center gap-1.5 rounded-md px-3 text-sm font-medium transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4",
102
- options.variant === "ghost"
103
- ? "hover:bg-accent hover:text-accent-foreground"
104
- : "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
105
- ),
95
+ class: cn("content-quiz-button", options.variant === "ghost" && "content-quiz-button-ghost"),
106
96
  onClick: options.onClick,
107
97
  },
108
- () => children,
98
+ children,
109
99
  );
110
100
  }
111
101
 
112
102
  function renderHeader() {
113
- return h("div", { class: "flex flex-row items-center gap-4 px-6" }, () => [
114
- h(
115
- "div",
116
- {
117
- class:
118
- "flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary",
119
- },
120
- [h(Icon, { name: "lucide:circle-help", class: "size-4", ariaHidden: true })],
121
- ),
122
- h("div", { class: "min-w-0 flex-1" }, [
123
- props.title
124
- ? h("div", { class: "font-semibold leading-none tracking-tight" }, () => props.title)
125
- : null,
126
- props.description
127
- ? h("div", { class: "text-sm text-muted-foreground" }, () => props.description)
128
- : null,
103
+ return h("div", { class: "content-quiz-header" }, [
104
+ h(Icon, { name: "lucide:circle-help", class: "content-quiz-header-icon", ariaHidden: true }),
105
+ h("div", { class: "content-quiz-heading" }, [
106
+ props.title ? h("div", { class: "content-quiz-title" }, props.title) : null,
107
+ props.description ? h("div", { class: "content-quiz-description" }, props.description) : null,
129
108
  ]),
130
109
  ]);
131
110
  }
@@ -133,18 +112,15 @@ function renderHeader() {
133
112
  function renderDots() {
134
113
  return h(
135
114
  "div",
136
- { class: "flex items-center gap-1.5" },
115
+ { class: "content-quiz-dots" },
137
116
  Array.from({ length: state.totalQuestions }, (_, i) =>
138
117
  h("button", {
139
118
  type: "button",
140
- class: cn(
141
- "size-2 rounded-full transition-all",
142
- i === activeIndex.value
143
- ? "scale-125 bg-primary ring-2 ring-primary/30"
144
- : state.answers.has(i)
145
- ? "bg-primary"
146
- : "bg-muted-foreground/30",
147
- ),
119
+ class: "content-quiz-dot",
120
+ "data-state":
121
+ i === activeIndex.value ? "active" : state.answers.has(i) ? "answered" : undefined,
122
+ "aria-label": `${text.value.question} ${i + 1}`,
123
+ "aria-current": i === activeIndex.value ? "true" : undefined,
148
124
  onClick: () => {
149
125
  activeIndex.value = i;
150
126
  },
@@ -156,38 +132,29 @@ function renderDots() {
156
132
  function renderFooter() {
157
133
  const isLast = activeIndex.value === state.totalQuestions - 1;
158
134
 
159
- return h("div", { class: "flex items-center justify-between border-t px-6 py-4" }, () => [
135
+ return h("div", { class: "content-quiz-footer" }, [
160
136
  h(
161
137
  "span",
162
- { class: "text-sm text-muted-foreground" },
138
+ { class: "content-quiz-progress" },
163
139
  `${text.value.question} ${activeIndex.value + 1} ${text.value.of} ${state.totalQuestions}`,
164
140
  ),
165
141
  renderDots(),
166
- h("div", { class: "flex items-center gap-2" }, [
167
- renderButton(
168
- [
169
- h(Icon, { name: "lucide:chevron-left", class: "size-4", ariaHidden: true }),
170
- text.value.back,
171
- ],
172
- {
173
- disabled: activeIndex.value === 0,
174
- onClick: () => {
175
- activeIndex.value--;
176
- },
142
+ h("div", { class: "content-quiz-nav" }, [
143
+ renderButton([h(Icon, { name: "lucide:chevron-left", ariaHidden: true }), text.value.back], {
144
+ disabled: activeIndex.value === 0,
145
+ onClick: () => {
146
+ activeIndex.value--;
177
147
  },
178
- ),
148
+ }),
179
149
  isLast
180
150
  ? renderButton(text.value.results, {
181
- disabled: !currentAnswered.value,
151
+ disabled: !allAnswered.value,
182
152
  onClick: () => {
183
153
  showResults.value = true;
184
154
  },
185
155
  })
186
156
  : renderButton(
187
- [
188
- text.value.next,
189
- h(Icon, { name: "lucide:chevron-right", class: "size-4", ariaHidden: true }),
190
- ],
157
+ [text.value.next, h(Icon, { name: "lucide:chevron-right", ariaHidden: true })],
191
158
  {
192
159
  disabled: !currentAnswered.value,
193
160
  onClick: () => {
@@ -202,32 +169,24 @@ function renderFooter() {
202
169
  function renderResults() {
203
170
  const perfect = correctCount.value === state.totalQuestions;
204
171
 
205
- return h("div", { class: "flex flex-col items-center gap-4 px-6 py-8 text-center" }, () => [
172
+ return h("div", { class: "content-quiz-results" }, [
206
173
  h(
207
174
  "div",
208
175
  {
209
- class: cn(
210
- "flex size-12 items-center justify-center rounded-full",
211
- perfect ? "bg-success/10" : "bg-muted",
212
- ),
176
+ class: "content-quiz-results-icon",
177
+ "data-state": perfect ? "perfect" : undefined,
213
178
  },
214
- [
215
- h(Icon, {
216
- name: "lucide:trophy",
217
- class: cn("size-6", perfect ? "text-success" : "text-muted-foreground"),
218
- ariaHidden: true,
219
- }),
220
- ],
179
+ [h(Icon, { name: "lucide:trophy", ariaHidden: true })],
221
180
  ),
222
181
  h("div", {}, [
223
182
  h(
224
183
  "p",
225
- { class: "text-lg font-semibold text-foreground" },
184
+ { class: "content-quiz-results-score" },
226
185
  `${correctCount.value} ${text.value.of} ${state.totalQuestions} ${text.value.correct}`,
227
186
  ),
228
187
  h(
229
188
  "p",
230
- { class: "mt-1 text-sm text-muted-foreground" },
189
+ { class: "content-quiz-results-note" },
231
190
  perfect ? text.value.perfect : text.value.retryPrompt,
232
191
  ),
233
192
  ]),
@@ -243,10 +202,12 @@ function renderQuiz() {
243
202
  const children: VNode[] = [renderHeader()];
244
203
 
245
204
  if (showResults.value) {
246
- children.push(renderResults());
205
+ children.push(h("div", { class: "content-quiz-body" }, [renderResults()]));
247
206
  } else {
248
207
  children.push(
249
- h("div", { class: "px-6" }, () =>
208
+ h(
209
+ "div",
210
+ { class: "content-quiz-body" },
250
211
  items.map((node, i) =>
251
212
  h(
252
213
  "div",
@@ -254,7 +215,7 @@ function renderQuiz() {
254
215
  key: i,
255
216
  style: i === activeIndex.value ? undefined : { display: "none" },
256
217
  },
257
- [h(node)],
218
+ [cloneVNode(node)],
258
219
  ),
259
220
  ),
260
221
  ),
@@ -267,11 +228,10 @@ function renderQuiz() {
267
228
  return h(
268
229
  "div",
269
230
  {
270
- "data-slot": "card",
271
- class:
272
- "not-prose my-8 flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-xs",
231
+ "data-appearance": appearance.value,
232
+ class: "content-quiz not-prose",
273
233
  },
274
- () => children,
234
+ children,
275
235
  );
276
236
  }
277
237
  </script>
@@ -1,18 +1,17 @@
1
1
  <script setup lang="ts">
2
- import type { Ref, VNode } from "vue";
3
- import { h, inject, ref, computed, watch, useSlots, resolveComponent } from "vue";
2
+ import type { Ref } from "vue";
3
+ import { h, inject, ref, computed, watch, useSlots } from "vue";
4
4
  import { cn } from "../../utils";
5
5
  import { Icon } from "#components";
6
6
  import { useI18n } from "#imports";
7
-
8
- type YamlOption = { text: string; correct?: boolean } | string;
7
+ import { parseQuizOptions, type QuizOption, type QuizType } from "./quiz.utils";
9
8
 
10
9
  const props = withDefaults(
11
10
  defineProps<{
12
11
  question: string;
13
- type?: "single" | "multiple";
12
+ type?: QuizType;
14
13
  explanation?: string;
15
- options?: YamlOption[];
14
+ options: QuizOption[];
16
15
  checkLabel?: string;
17
16
  correctLabel?: string;
18
17
  incorrectLabel?: string;
@@ -56,34 +55,17 @@ const text = computed(() => {
56
55
  reset: props.resetLabel ?? (isGerman ? "Erneut versuchen" : "Try Again"),
57
56
  multipleChoice:
58
57
  props.multipleChoiceLabel ??
59
- (isGerman ? "(mehrere Antworten moeglich)" : "(select all that apply)"),
58
+ (isGerman ? "(mehrere Antworten möglich)" : "(select all that apply)"),
60
59
  };
61
60
  });
62
61
 
63
- function normalizeYamlOption(opt: YamlOption): { text: string; correct: boolean } {
64
- if (typeof opt === "string") return { text: opt, correct: false };
65
- return { text: opt.text, correct: opt.correct ?? false };
66
- }
67
-
68
- const useYaml = computed(() => Array.isArray(props.options) && props.options.length > 0);
69
-
70
- function getOptionNodes(): VNode[] {
71
- return (slots.default?.() ?? []).filter((node): node is VNode => Boolean(node?.type));
72
- }
62
+ const options = computed(() => parseQuizOptions(props.options, props.type));
63
+ const useYaml = computed(() => options.value.length > 0);
73
64
 
74
65
  const isCorrect = computed(() => {
75
- if (useYaml.value) {
76
- return props.options!.every((opt, i) => {
77
- const { correct } = normalizeYamlOption(opt);
78
- return correct === selectedOptions.value.has(i);
79
- });
80
- }
81
- const nodes = getOptionNodes();
82
- return nodes.every((node, i) => {
83
- const correct = node.props?.correct !== undefined && node.props?.correct !== false;
84
- const selected = selectedOptions.value.has(i);
85
- return correct === selected;
86
- });
66
+ return options.value.every(
67
+ (option, index) => option.correct === selectedOptions.value.has(index),
68
+ );
87
69
  });
88
70
 
89
71
  function handleSelect(index: number) {
@@ -124,100 +106,113 @@ function renderButton(
124
106
  {
125
107
  type: "button",
126
108
  disabled: options.disabled,
127
- class: cn(
128
- "inline-flex h-8 items-center justify-center gap-1.5 rounded-md px-3 text-sm font-medium transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50",
129
- options.variant === "ghost"
130
- ? "hover:bg-accent hover:text-accent-foreground"
131
- : "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
132
- ),
109
+ class: cn("content-quiz-button", options.variant === "ghost" && "content-quiz-button-ghost"),
133
110
  onClick: options.onClick,
134
111
  },
135
- () => label,
112
+ label,
136
113
  );
137
114
  }
138
115
 
139
116
  function renderOptions() {
140
- if (useYaml.value) {
141
- const QuizOption = resolveComponent("MdcQuizOption");
142
- return props.options!.map((opt, i) => {
143
- const { text, correct } = normalizeYamlOption(opt);
144
- return h(QuizOption, {
117
+ return options.value.map(({ text: label, correct }, i) => {
118
+ const selected = selectedOptions.value.has(i);
119
+ const state = isSubmitted.value
120
+ ? correct
121
+ ? "correct"
122
+ : selected
123
+ ? "incorrect"
124
+ : "idle"
125
+ : selected
126
+ ? "selected"
127
+ : "idle";
128
+ const resultIcon =
129
+ state === "correct"
130
+ ? "lucide:circle-check"
131
+ : state === "incorrect"
132
+ ? "lucide:circle-x"
133
+ : null;
134
+ return h(
135
+ "button",
136
+ {
145
137
  key: i,
146
- optionIndex: i,
147
- correct,
148
- label: text,
149
- selected: selectedOptions.value.has(i),
138
+ type: "button",
150
139
  disabled: isSubmitted.value,
151
- showResult: isSubmitted.value,
152
- onSelect: () => handleSelect(i),
153
- });
154
- });
155
- }
156
- return getOptionNodes().map((node, i) =>
157
- h(node, {
158
- key: i,
159
- optionIndex: i,
160
- selected: selectedOptions.value.has(i),
161
- disabled: isSubmitted.value,
162
- showResult: isSubmitted.value,
163
- onSelect: () => handleSelect(i),
164
- }),
165
- );
140
+ class: "content-quiz-option",
141
+ "data-state": state,
142
+ onClick: () => handleSelect(i),
143
+ },
144
+ [
145
+ h(
146
+ "span",
147
+ { class: "content-quiz-option-key", "aria-hidden": "true" },
148
+ String.fromCharCode(65 + i),
149
+ ),
150
+ h("span", { class: "content-quiz-option-label" }, label),
151
+ resultIcon
152
+ ? h(Icon, { name: resultIcon, class: "content-quiz-option-result", ariaHidden: true })
153
+ : null,
154
+ ],
155
+ );
156
+ });
166
157
  }
167
158
 
168
159
  function renderQuestion() {
169
- return h("div", { class: quizContext?.inQuiz ? "space-y-4" : "not-prose my-6 space-y-4" }, [
170
- h("p", { class: "font-semibold text-foreground" }, [
171
- props.type === "multiple"
160
+ return h(
161
+ "div",
162
+ {
163
+ class: cn(
164
+ "content-quiz-question",
165
+ !quizContext?.inQuiz && "content-quiz-question-standalone not-prose",
166
+ ),
167
+ },
168
+ [
169
+ h("p", { class: "content-quiz-question-title" }, [
170
+ props.question,
171
+ props.type === "multiple"
172
+ ? h("span", { class: "content-quiz-question-hint" }, text.value.multipleChoice)
173
+ : null,
174
+ ]),
175
+ h("div", { class: "content-quiz-options" }, renderOptions()),
176
+ h("div", { class: "content-quiz-actions" }, [
177
+ !isSubmitted.value
178
+ ? renderButton(text.value.check, {
179
+ disabled: selectedOptions.value.size === 0,
180
+ onClick: handleSubmit,
181
+ })
182
+ : h("div", { class: "content-quiz-actions" }, [
183
+ h(
184
+ "span",
185
+ {
186
+ class: "content-quiz-result",
187
+ "data-state": isCorrect.value ? "correct" : "incorrect",
188
+ },
189
+ isCorrect.value ? text.value.correct : text.value.incorrect,
190
+ ),
191
+ !quizContext?.inQuiz
192
+ ? renderButton(text.value.reset, { variant: "ghost", onClick: handleReset })
193
+ : null,
194
+ ]),
195
+ ]),
196
+ isSubmitted.value && (props.explanation || useYaml.value)
172
197
  ? h(
173
- "span",
174
- { class: "text-xs font-normal text-muted-foreground mr-2" },
175
- text.value.multipleChoice,
198
+ "div",
199
+ {
200
+ class: "content-quiz-explanation",
201
+ },
202
+ [
203
+ h(Icon, {
204
+ name: "lucide:info",
205
+ class: "content-quiz-explanation-icon",
206
+ ariaHidden: true,
207
+ }),
208
+ props.explanation
209
+ ? h("span", props.explanation)
210
+ : h("div", { class: "content-prose content-prose-trim" }, slots.default?.()),
211
+ ],
176
212
  )
177
213
  : null,
178
- props.question,
179
- ]),
180
- h("div", { class: "space-y-2" }, renderOptions()),
181
- h("div", { class: "flex items-center gap-3" }, [
182
- !isSubmitted.value
183
- ? renderButton(text.value.check, {
184
- disabled: selectedOptions.value.size === 0,
185
- onClick: handleSubmit,
186
- })
187
- : h("div", { class: "flex items-center gap-3" }, [
188
- h(
189
- "span",
190
- {
191
- class: cn(
192
- "text-sm font-medium",
193
- isCorrect.value ? "text-success" : "text-destructive",
194
- ),
195
- },
196
- isCorrect.value ? text.value.correct : text.value.incorrect,
197
- ),
198
- !quizContext?.inQuiz
199
- ? renderButton(text.value.reset, { variant: "ghost", onClick: handleReset })
200
- : null,
201
- ]),
202
- ]),
203
- isSubmitted.value && props.explanation
204
- ? h(
205
- "div",
206
- {
207
- class:
208
- "flex gap-2 rounded-lg border border-info/30 bg-info/10 px-4 py-3 text-sm text-foreground",
209
- },
210
- [
211
- h(Icon, {
212
- name: "lucide:info",
213
- class: "mt-0.5 size-4 shrink-0 text-info",
214
- ariaHidden: true,
215
- }),
216
- h("span", props.explanation),
217
- ],
218
- )
219
- : null,
220
- ]);
214
+ ],
215
+ );
221
216
  }
222
217
  </script>
223
218
 
@@ -1,90 +1,70 @@
1
1
  <script setup lang="ts">
2
2
  import { NuxtLink } from "#components";
3
- import { computed, inject } from "vue";
4
- import { cn } from "../../utils";
3
+ import { computed } from "vue";
4
+ import { useProseAppearance } from "../../composables/useProseAppearance";
5
5
  import { isExternalLink, resolveIconifyIcon } from "./icons";
6
6
 
7
- const MDC_READ_MORE_GROUP = Symbol.for("mdc.readMoreGroup");
8
- const inReadMoreGroup = inject(MDC_READ_MORE_GROUP, false);
7
+ type ReadMoreLink = {
8
+ title: string;
9
+ to: string;
10
+ description?: string;
11
+ target?: string;
12
+ icon?: string;
13
+ };
9
14
 
10
- const props = withDefaults(
11
- defineProps<{
12
- title?: string;
13
- to: string;
14
- target?: string;
15
- icon?: string;
16
- }>(),
17
- {
18
- icon: "lucide:bookmark",
19
- },
20
- );
21
-
22
- const label = computed(() => {
23
- if (props.title) return props.title;
24
- return props.to;
25
- });
26
-
27
- const iconName = computed(() => resolveIconifyIcon(props.icon));
28
-
29
- const linkProps = computed(() => {
30
- if (isExternalLink(props.to)) {
31
- return {
32
- href: props.to,
33
- target: props.target,
34
- rel: "noreferrer noopener" as const,
35
- };
36
- }
37
-
38
- return {
39
- to: props.to,
40
- target: props.target,
41
- };
42
- });
15
+ const props = defineProps<{
16
+ title?: string;
17
+ links?: unknown;
18
+ appearance?: "quiet" | "tint";
19
+ }>();
43
20
 
44
- const wrapperTag = computed(() => (isExternalLink(props.to) ? "a" : NuxtLink));
21
+ const appearance = useProseAppearance("readMore", () => props.appearance);
22
+ const items = computed<ReadMoreLink[]>(() =>
23
+ Array.isArray(props.links)
24
+ ? props.links.filter(
25
+ (link): link is ReadMoreLink =>
26
+ Boolean(link) &&
27
+ typeof link === "object" &&
28
+ typeof link.title === "string" &&
29
+ typeof link.to === "string",
30
+ )
31
+ : [],
32
+ );
45
33
 
46
- const shell = computed(() => ({
47
- is: "div",
48
- class: inReadMoreGroup
49
- ? "contents"
50
- : cn(
51
- "relative gap-0 rounded-xl border bg-card py-4 text-card-foreground shadow-xs transition-colors",
52
- "group-hover:border-foreground/20 group-hover:bg-accent/50",
53
- ),
54
- }));
34
+ const componentFor = (to: string) => (isExternalLink(to) ? "a" : NuxtLink);
35
+ const linkProps = (item: ReadMoreLink) =>
36
+ isExternalLink(item.to)
37
+ ? { href: item.to, target: item.target, rel: "noreferrer noopener" }
38
+ : { to: item.to, target: item.target };
55
39
  </script>
56
40
 
57
41
  <template>
58
- <component
59
- :is="wrapperTag"
60
- v-bind="linkProps"
61
- :class="
62
- cn(
63
- 'group not-prose relative block overflow-hidden text-sm text-card-foreground transition-colors duration-200 ease-out',
64
- 'outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
65
- inReadMoreGroup
66
- ? 'my-0 rounded-none bg-transparent px-6 py-4 shadow-none hover:bg-accent/50'
67
- : 'my-6 cursor-pointer rounded-xl',
68
- )
69
- "
42
+ <nav
43
+ v-if="items.length"
44
+ class="content-read-more not-prose"
45
+ :data-appearance="appearance"
46
+ :aria-label="title"
70
47
  >
71
- <component :is="shell.is" :class="shell.class">
72
- <div class="relative flex items-center gap-3" :class="inReadMoreGroup ? undefined : 'px-4'">
73
- <div
74
- v-if="iconName"
75
- class="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-muted text-muted-foreground"
76
- >
77
- <Icon :name="iconName" class="size-4" aria-hidden="true" />
78
- </div>
79
- <div class="min-w-0 flex-1 pr-8 leading-relaxed font-medium text-foreground">
80
- {{ label }}
81
- </div>
82
- <span
83
- class="pointer-events-none absolute top-1/2 right-4 -translate-y-1/2 text-muted-foreground/40 transition-colors group-hover:text-foreground"
84
- >
85
- <Icon name="lucide:arrow-up-right" class="size-4 shrink-0" aria-hidden="true" />
48
+ <p v-if="title" class="content-read-more-title">{{ title }}</p>
49
+ <div class="content-read-more-list">
50
+ <component
51
+ :is="componentFor(item.to)"
52
+ v-for="item in items"
53
+ :key="item.to"
54
+ v-bind="linkProps(item)"
55
+ class="content-read-more-link"
56
+ >
57
+ <span v-if="item.icon" class="content-read-more-icon" aria-hidden="true">
58
+ <Icon :name="resolveIconifyIcon(item.icon) ?? item.icon" />
59
+ </span>
60
+ <span class="content-read-more-copy">
61
+ <span class="content-read-more-label">{{ item.title }}</span>
62
+ <span v-if="item.description" class="content-read-more-description">
63
+ {{ item.description }}
64
+ </span>
86
65
  </span>
87
- </div>
88
- </component>
89
- </component>
66
+ <Icon name="lucide:arrow-up-right" class="content-read-more-arrow" aria-hidden="true" />
67
+ </component>
68
+ </div>
69
+ </nav>
90
70
  </template>