@lupinum/ginko-docs 0.2.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.
Files changed (240) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -0
  3. package/app/app.config.ts +49 -0
  4. package/app/app.vue +71 -0
  5. package/app/assets/css/prose.css +1154 -0
  6. package/app/assets/css/tailwind.css +365 -0
  7. package/app/components/OgImage/GinkoDocs.satori.vue +117 -0
  8. package/app/components/content/DocumentPageShell.vue +55 -0
  9. package/app/components/content/Feedback.vue +97 -0
  10. package/app/components/content/PageMarkdownCopy.vue +102 -0
  11. package/app/components/mdc/MdcAccordion.vue +64 -0
  12. package/app/components/mdc/MdcAccordionItem.vue +51 -0
  13. package/app/components/mdc/MdcAlert.vue +34 -0
  14. package/app/components/mdc/MdcAside.vue +27 -0
  15. package/app/components/mdc/MdcCallout.vue +62 -0
  16. package/app/components/mdc/MdcCard.vue +186 -0
  17. package/app/components/mdc/MdcCardGroup.vue +47 -0
  18. package/app/components/mdc/MdcCenter.vue +71 -0
  19. package/app/components/mdc/MdcCodeGroup.vue +193 -0
  20. package/app/components/mdc/MdcColumn.vue +70 -0
  21. package/app/components/mdc/MdcDocImg.vue +25 -0
  22. package/app/components/mdc/MdcDropcap.vue +84 -0
  23. package/app/components/mdc/MdcError.vue +16 -0
  24. package/app/components/mdc/MdcField.vue +62 -0
  25. package/app/components/mdc/MdcFieldGroup.vue +18 -0
  26. package/app/components/mdc/MdcFigure.vue +51 -0
  27. package/app/components/mdc/MdcIdea.vue +16 -0
  28. package/app/components/mdc/MdcInfo.vue +16 -0
  29. package/app/components/mdc/MdcLayout.vue +57 -0
  30. package/app/components/mdc/MdcNote.vue +16 -0
  31. package/app/components/mdc/MdcPassage.vue +32 -0
  32. package/app/components/mdc/MdcQuiz.vue +281 -0
  33. package/app/components/mdc/MdcQuizOption.vue +91 -0
  34. package/app/components/mdc/MdcQuizQuestion.vue +226 -0
  35. package/app/components/mdc/MdcReadMore.vue +90 -0
  36. package/app/components/mdc/MdcReadMoreGroup.vue +18 -0
  37. package/app/components/mdc/MdcShortcut.vue +46 -0
  38. package/app/components/mdc/MdcStep.vue +42 -0
  39. package/app/components/mdc/MdcSteps.vue +25 -0
  40. package/app/components/mdc/MdcSuccess.vue +16 -0
  41. package/app/components/mdc/MdcTab.vue +17 -0
  42. package/app/components/mdc/MdcTabs.vue +132 -0
  43. package/app/components/mdc/MdcTimeline.vue +14 -0
  44. package/app/components/mdc/MdcTimelineItem.vue +49 -0
  45. package/app/components/mdc/MdcWarning.vue +16 -0
  46. package/app/components/mdc/icons.ts +10 -0
  47. package/app/components/prose/ProseA.vue +27 -0
  48. package/app/components/prose/ProseBlockquote.vue +14 -0
  49. package/app/components/prose/ProseCode.vue +14 -0
  50. package/app/components/prose/ProseEm.vue +14 -0
  51. package/app/components/prose/ProseH1.vue +18 -0
  52. package/app/components/prose/ProseH2.vue +24 -0
  53. package/app/components/prose/ProseH3.vue +24 -0
  54. package/app/components/prose/ProseH4.vue +14 -0
  55. package/app/components/prose/ProseHr.vue +12 -0
  56. package/app/components/prose/ProseImg.vue +120 -0
  57. package/app/components/prose/ProseLi.vue +14 -0
  58. package/app/components/prose/ProseOl.vue +14 -0
  59. package/app/components/prose/ProseP.vue +14 -0
  60. package/app/components/prose/ProsePre.vue +217 -0
  61. package/app/components/prose/ProseStrong.vue +14 -0
  62. package/app/components/prose/ProseTable.vue +16 -0
  63. package/app/components/prose/ProseTbody.vue +14 -0
  64. package/app/components/prose/ProseTd.vue +14 -0
  65. package/app/components/prose/ProseTh.vue +14 -0
  66. package/app/components/prose/ProseThead.vue +14 -0
  67. package/app/components/prose/ProseTr.vue +14 -0
  68. package/app/components/prose/ProseUl.vue +14 -0
  69. package/app/components/site/ModeToggle.vue +123 -0
  70. package/app/components/site/SiteBanner.vue +56 -0
  71. package/app/components/site/SiteFooter.vue +80 -0
  72. package/app/components/site/SiteHeader.vue +250 -0
  73. package/app/components/site/SiteInteractionLayer.vue +35 -0
  74. package/app/components/site/SiteLocaleSwitcher.vue +268 -0
  75. package/app/components/site/SiteLogoMark.vue +12 -0
  76. package/app/components/site/SiteSkipLink.vue +14 -0
  77. package/app/components/ui/accordion/Accordion.vue +15 -0
  78. package/app/components/ui/accordion/AccordionContent.vue +23 -0
  79. package/app/components/ui/accordion/AccordionItem.vue +24 -0
  80. package/app/components/ui/accordion/AccordionTrigger.vue +35 -0
  81. package/app/components/ui/accordion/index.ts +4 -0
  82. package/app/components/ui/alert/Alert.vue +21 -0
  83. package/app/components/ui/alert/AlertDescription.vue +22 -0
  84. package/app/components/ui/alert/AlertTitle.vue +22 -0
  85. package/app/components/ui/alert/index.ts +34 -0
  86. package/app/components/ui/badge/Badge.vue +28 -0
  87. package/app/components/ui/badge/index.ts +24 -0
  88. package/app/components/ui/button/Button.vue +31 -0
  89. package/app/components/ui/button/index.ts +35 -0
  90. package/app/components/ui/card/Card.vue +16 -0
  91. package/app/components/ui/card/CardAction.vue +17 -0
  92. package/app/components/ui/card/CardContent.vue +14 -0
  93. package/app/components/ui/card/CardDescription.vue +14 -0
  94. package/app/components/ui/card/CardFooter.vue +14 -0
  95. package/app/components/ui/card/CardHeader.vue +22 -0
  96. package/app/components/ui/card/CardTitle.vue +14 -0
  97. package/app/components/ui/card/card-variants.ts +15 -0
  98. package/app/components/ui/card/index.ts +8 -0
  99. package/app/components/ui/checkbox/Checkbox.vue +35 -0
  100. package/app/components/ui/checkbox/index.ts +1 -0
  101. package/app/components/ui/collapsible/Collapsible.vue +15 -0
  102. package/app/components/ui/collapsible/CollapsibleContent.vue +25 -0
  103. package/app/components/ui/collapsible/CollapsibleTrigger.vue +12 -0
  104. package/app/components/ui/collapsible/index.ts +3 -0
  105. package/app/components/ui/dialog/Dialog.vue +15 -0
  106. package/app/components/ui/dialog/DialogClose.vue +12 -0
  107. package/app/components/ui/dialog/DialogContent.vue +69 -0
  108. package/app/components/ui/dialog/DialogDescription.vue +21 -0
  109. package/app/components/ui/dialog/DialogFooter.vue +15 -0
  110. package/app/components/ui/dialog/DialogHeader.vue +12 -0
  111. package/app/components/ui/dialog/DialogOverlay.vue +26 -0
  112. package/app/components/ui/dialog/DialogPortal.vue +12 -0
  113. package/app/components/ui/dialog/DialogTitle.vue +21 -0
  114. package/app/components/ui/dialog/DialogTrigger.vue +12 -0
  115. package/app/components/ui/dialog/index.ts +10 -0
  116. package/app/components/ui/dropdown-menu/DropdownMenu.vue +15 -0
  117. package/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +36 -0
  118. package/app/components/ui/dropdown-menu/DropdownMenuContent.vue +40 -0
  119. package/app/components/ui/dropdown-menu/DropdownMenuGroup.vue +12 -0
  120. package/app/components/ui/dropdown-menu/DropdownMenuItem.vue +41 -0
  121. package/app/components/ui/dropdown-menu/DropdownMenuLabel.vue +25 -0
  122. package/app/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue +15 -0
  123. package/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +37 -0
  124. package/app/components/ui/dropdown-menu/DropdownMenuSeparator.vue +23 -0
  125. package/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue +17 -0
  126. package/app/components/ui/dropdown-menu/DropdownMenuSub.vue +15 -0
  127. package/app/components/ui/dropdown-menu/DropdownMenuSubContent.vue +29 -0
  128. package/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +31 -0
  129. package/app/components/ui/dropdown-menu/DropdownMenuTrigger.vue +14 -0
  130. package/app/components/ui/dropdown-menu/index.ts +16 -0
  131. package/app/components/ui/input/Input.vue +23 -0
  132. package/app/components/ui/input/index.ts +1 -0
  133. package/app/components/ui/kbd/Kbd.vue +23 -0
  134. package/app/components/ui/kbd/KbdGroup.vue +14 -0
  135. package/app/components/ui/kbd/index.ts +22 -0
  136. package/app/components/ui/label/Label.vue +14 -0
  137. package/app/components/ui/label/index.ts +1 -0
  138. package/app/components/ui/scroll-area/ScrollArea.vue +39 -0
  139. package/app/components/ui/scroll-area/ScrollBar.vue +33 -0
  140. package/app/components/ui/scroll-area/index.ts +2 -0
  141. package/app/components/ui/separator/Separator.vue +27 -0
  142. package/app/components/ui/separator/index.ts +1 -0
  143. package/app/components/ui/sheet/Sheet.vue +15 -0
  144. package/app/components/ui/sheet/SheetClose.vue +12 -0
  145. package/app/components/ui/sheet/SheetContent.vue +59 -0
  146. package/app/components/ui/sheet/SheetDescription.vue +21 -0
  147. package/app/components/ui/sheet/SheetFooter.vue +12 -0
  148. package/app/components/ui/sheet/SheetHeader.vue +12 -0
  149. package/app/components/ui/sheet/SheetOverlay.vue +26 -0
  150. package/app/components/ui/sheet/SheetTitle.vue +21 -0
  151. package/app/components/ui/sheet/SheetTrigger.vue +12 -0
  152. package/app/components/ui/sheet/index.ts +8 -0
  153. package/app/components/ui/switch/Switch.vue +39 -0
  154. package/app/components/ui/switch/index.ts +1 -0
  155. package/app/components/ui/tabs/Tabs.vue +24 -0
  156. package/app/components/ui/tabs/TabsContent.vue +21 -0
  157. package/app/components/ui/tabs/TabsList.vue +26 -0
  158. package/app/components/ui/tabs/TabsTrigger.vue +28 -0
  159. package/app/components/ui/tabs/index.ts +4 -0
  160. package/app/components/ui/textarea/Textarea.vue +31 -0
  161. package/app/components/ui/textarea/index.ts +1 -0
  162. package/app/composables/site-navigation.utils.ts +80 -0
  163. package/app/composables/useCanonicalUrl.ts +9 -0
  164. package/app/composables/useContentRouteAlternates.ts +43 -0
  165. package/app/composables/useGinkoAnalytics.ts +37 -0
  166. package/app/composables/useGinkoDocsConfig.ts +6 -0
  167. package/app/composables/useGinkoOgImage.ts +20 -0
  168. package/app/composables/useLocalizedPath.ts +12 -0
  169. package/app/composables/useLocalizedRouteSwitch.ts +31 -0
  170. package/app/composables/usePageMarkdownActions.ts +79 -0
  171. package/app/composables/useSchemaJsonLd.ts +25 -0
  172. package/app/composables/useSiteNavigation.ts +86 -0
  173. package/app/config/site.utils.ts +36 -0
  174. package/app/error.vue +125 -0
  175. package/app/features/docs/components/DocsContributeLinks.vue +65 -0
  176. package/app/features/docs/components/DocsMobileToc.vue +104 -0
  177. package/app/features/docs/components/DocsPageContent.vue +188 -0
  178. package/app/features/docs/components/DocsPageNav.vue +57 -0
  179. package/app/features/docs/components/DocsSidebar.vue +129 -0
  180. package/app/features/docs/components/DocsSidebarDropdown.vue +70 -0
  181. package/app/features/docs/components/DocsSidebarItem.vue +169 -0
  182. package/app/features/docs/components/DocsSidebarList.vue +35 -0
  183. package/app/features/docs/components/DocsSidebarTabs.vue +35 -0
  184. package/app/features/docs/components/DocsToc.vue +60 -0
  185. package/app/features/docs/composables/useDocsEntryPath.ts +13 -0
  186. package/app/features/docs/composables/useDocsNavigation.ts +29 -0
  187. package/app/features/docs/composables/useDocsNavigationData.ts +15 -0
  188. package/app/features/docs/composables/useScrollspy.ts +67 -0
  189. package/app/features/docs/docs-navigation.ts +170 -0
  190. package/app/features/search/command-center.ts +156 -0
  191. package/app/features/search/components/SiteCommandCenter.vue +323 -0
  192. package/app/features/search/components/SiteSearchPageHighlight.vue +124 -0
  193. package/app/features/search/useCommandCenter.ts +300 -0
  194. package/app/layouts/blog.vue +15 -0
  195. package/app/layouts/default.vue +12 -0
  196. package/app/layouts/docs.vue +20 -0
  197. package/app/lib/errors.ts +84 -0
  198. package/app/lib/schema-org.ts +38 -0
  199. package/app/lib/utils.ts +7 -0
  200. package/app/pages/blog/[slug].vue +211 -0
  201. package/app/pages/blog/index.vue +106 -0
  202. package/app/pages/docs/[...slug].vue +10 -0
  203. package/app/pages/docs/index.vue +23 -0
  204. package/app/pages/index.vue +140 -0
  205. package/app/plugins/ssr-width.ts +6 -0
  206. package/app/utils/content.ts +56 -0
  207. package/app/utils/index.ts +1 -0
  208. package/components.ts +9 -0
  209. package/content.js +175 -0
  210. package/content.ts +148 -0
  211. package/i18n/i18n.config.ts +10 -0
  212. package/i18n/locales.ts +81 -0
  213. package/i18n/messages/global/banner.ts +8 -0
  214. package/i18n/messages/global/blog.ts +14 -0
  215. package/i18n/messages/global/command.ts +44 -0
  216. package/i18n/messages/global/docs.ts +32 -0
  217. package/i18n/messages/global/errors.ts +36 -0
  218. package/i18n/messages/global/feedback.ts +11 -0
  219. package/i18n/messages/global/index.ts +21 -0
  220. package/i18n/messages/global/nav.ts +18 -0
  221. package/i18n/messages/global/site.ts +4 -0
  222. package/i18n/messages/global/theme.ts +7 -0
  223. package/i18n/messages/index.ts +20 -0
  224. package/i18n/messages/localize.ts +35 -0
  225. package/i18n/messages/source.ts +5 -0
  226. package/i18n/routes.ts +33 -0
  227. package/icon-bundle.ts +141 -0
  228. package/index.d.ts +13 -0
  229. package/modules/feature-routing.ts +32 -0
  230. package/nuxt.config.ts +194 -0
  231. package/package.json +91 -0
  232. package/runtime/markdown/inline-code-highlight.ts +155 -0
  233. package/runtime/server/agent-markdown.ts +21 -0
  234. package/runtime/utils.ts +7 -0
  235. package/server/mcp/tools/get-page.ts +14 -0
  236. package/server/mcp/tools/list-pages.ts +10 -0
  237. package/server/plugins/agent-markdown.ts +6 -0
  238. package/shared/route-slugs.ts +5 -0
  239. package/shared/types/app-config.ts +100 -0
  240. package/tags.ts +204 -0
@@ -0,0 +1,281 @@
1
+ <script setup lang="ts">
2
+ import type { VNode } from "vue";
3
+ import { h, provide, reactive, ref, computed, useSlots } from "vue";
4
+ import { Icon } from "#components";
5
+ import { useI18n } from "#imports";
6
+ import { cn } from "../../utils";
7
+
8
+ const props = defineProps<{
9
+ title?: string;
10
+ description?: string;
11
+ questionLabel?: string;
12
+ backLabel?: string;
13
+ nextLabel?: string;
14
+ resultsLabel?: string;
15
+ correctSummaryLabel?: string;
16
+ perfectLabel?: string;
17
+ retryPrompt?: string;
18
+ resetLabel?: string;
19
+ }>();
20
+
21
+ const MDC_QUIZ = Symbol.for("mdc.quiz");
22
+
23
+ const slots = useSlots();
24
+ const activeIndex = ref(0);
25
+ const showResults = ref(false);
26
+ const resetKey = ref(0);
27
+
28
+ const state = reactive({
29
+ totalQuestions: 0,
30
+ answers: new Map<number, boolean>(),
31
+ });
32
+
33
+ function registerQuestion(): number {
34
+ const index = state.totalQuestions;
35
+ state.totalQuestions++;
36
+ return index;
37
+ }
38
+
39
+ function markAnswered(index: number, correct: boolean) {
40
+ 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
+ }
51
+
52
+ function reset() {
53
+ state.answers.clear();
54
+ activeIndex.value = 0;
55
+ showResults.value = false;
56
+ resetKey.value++;
57
+ }
58
+
59
+ provide(MDC_QUIZ, { registerQuestion, markAnswered, resetKey, inQuiz: true });
60
+
61
+ const correctCount = computed(() => [...state.answers.values()].filter(Boolean).length);
62
+ const currentAnswered = computed(() => state.answers.has(activeIndex.value));
63
+ const { locale } = useI18n();
64
+
65
+ const text = computed(() => {
66
+ const isGerman = String(locale.value) === "de";
67
+
68
+ return {
69
+ question: props.questionLabel ?? (isGerman ? "Frage" : "Question"),
70
+ of: isGerman ? "von" : "of",
71
+ back: props.backLabel ?? (isGerman ? "Zurueck" : "Back"),
72
+ next: props.nextLabel ?? (isGerman ? "Weiter" : "Next"),
73
+ results: props.resultsLabel ?? (isGerman ? "Ergebnis anzeigen" : "See Results"),
74
+ correct: props.correctSummaryLabel ?? (isGerman ? "richtig" : "correct"),
75
+ perfect: props.perfectLabel ?? (isGerman ? "Alles richtig." : "Perfect score!"),
76
+ retryPrompt:
77
+ props.retryPrompt ??
78
+ (isGerman ? "Weiterlernen und erneut versuchen." : "Keep learning and try again."),
79
+ reset: props.resetLabel ?? (isGerman ? "Erneut versuchen" : "Try Again"),
80
+ };
81
+ });
82
+
83
+ function getQuestionNodes(): VNode[] {
84
+ return (slots.default?.() ?? []).filter((node): node is VNode => Boolean(node?.type));
85
+ }
86
+
87
+ function renderButton(
88
+ children: string | Array<string | VNode>,
89
+ options: {
90
+ disabled?: boolean;
91
+ onClick: () => void;
92
+ variant?: "outline" | "ghost";
93
+ },
94
+ ) {
95
+ return h(
96
+ "button",
97
+ {
98
+ type: "button",
99
+ 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
+ ),
106
+ onClick: options.onClick,
107
+ },
108
+ () => children,
109
+ );
110
+ }
111
+
112
+ 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,
129
+ ]),
130
+ ]);
131
+ }
132
+
133
+ function renderDots() {
134
+ return h(
135
+ "div",
136
+ { class: "flex items-center gap-1.5" },
137
+ Array.from({ length: state.totalQuestions }, (_, i) =>
138
+ h("button", {
139
+ 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
+ ),
148
+ onClick: () => {
149
+ activeIndex.value = i;
150
+ },
151
+ }),
152
+ ),
153
+ );
154
+ }
155
+
156
+ function renderFooter() {
157
+ const isLast = activeIndex.value === state.totalQuestions - 1;
158
+
159
+ return h("div", { class: "flex items-center justify-between border-t px-6 py-4" }, () => [
160
+ h(
161
+ "span",
162
+ { class: "text-sm text-muted-foreground" },
163
+ `${text.value.question} ${activeIndex.value + 1} ${text.value.of} ${state.totalQuestions}`,
164
+ ),
165
+ 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
+ },
177
+ },
178
+ ),
179
+ isLast
180
+ ? renderButton(text.value.results, {
181
+ disabled: !currentAnswered.value,
182
+ onClick: () => {
183
+ showResults.value = true;
184
+ },
185
+ })
186
+ : renderButton(
187
+ [
188
+ text.value.next,
189
+ h(Icon, { name: "lucide:chevron-right", class: "size-4", ariaHidden: true }),
190
+ ],
191
+ {
192
+ disabled: !currentAnswered.value,
193
+ onClick: () => {
194
+ activeIndex.value++;
195
+ },
196
+ },
197
+ ),
198
+ ]),
199
+ ]);
200
+ }
201
+
202
+ function renderResults() {
203
+ const perfect = correctCount.value === state.totalQuestions;
204
+
205
+ return h("div", { class: "flex flex-col items-center gap-4 px-6 py-8 text-center" }, () => [
206
+ h(
207
+ "div",
208
+ {
209
+ class: cn(
210
+ "flex size-12 items-center justify-center rounded-full",
211
+ perfect ? "bg-success/10" : "bg-muted",
212
+ ),
213
+ },
214
+ [
215
+ h(Icon, {
216
+ name: "lucide:trophy",
217
+ class: cn("size-6", perfect ? "text-success" : "text-muted-foreground"),
218
+ ariaHidden: true,
219
+ }),
220
+ ],
221
+ ),
222
+ h("div", {}, [
223
+ h(
224
+ "p",
225
+ { class: "text-lg font-semibold text-foreground" },
226
+ `${correctCount.value} ${text.value.of} ${state.totalQuestions} ${text.value.correct}`,
227
+ ),
228
+ h(
229
+ "p",
230
+ { class: "mt-1 text-sm text-muted-foreground" },
231
+ perfect ? text.value.perfect : text.value.retryPrompt,
232
+ ),
233
+ ]),
234
+ renderButton(text.value.reset, { onClick: reset }),
235
+ ]);
236
+ }
237
+
238
+ function renderQuiz() {
239
+ const items = getQuestionNodes();
240
+
241
+ if (!items.length) return null;
242
+
243
+ const children: VNode[] = [renderHeader()];
244
+
245
+ if (showResults.value) {
246
+ children.push(renderResults());
247
+ } else {
248
+ children.push(
249
+ h("div", { class: "px-6" }, () =>
250
+ items.map((node, i) =>
251
+ h(
252
+ "div",
253
+ {
254
+ key: i,
255
+ style: i === activeIndex.value ? undefined : { display: "none" },
256
+ },
257
+ [h(node)],
258
+ ),
259
+ ),
260
+ ),
261
+ );
262
+ if (state.totalQuestions > 1) {
263
+ children.push(renderFooter());
264
+ }
265
+ }
266
+
267
+ return h(
268
+ "div",
269
+ {
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",
273
+ },
274
+ () => children,
275
+ );
276
+ }
277
+ </script>
278
+
279
+ <template>
280
+ <component :is="renderQuiz" />
281
+ </template>
@@ -0,0 +1,91 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue";
3
+ import { cn } from "../../utils";
4
+
5
+ const props = withDefaults(
6
+ defineProps<{
7
+ correct?: boolean;
8
+ selected?: boolean;
9
+ disabled?: boolean;
10
+ showResult?: boolean;
11
+ optionIndex?: number;
12
+ label?: string;
13
+ }>(),
14
+ {
15
+ correct: false,
16
+ selected: false,
17
+ disabled: false,
18
+ showResult: false,
19
+ optionIndex: 0,
20
+ },
21
+ );
22
+
23
+ const emit = defineEmits<{
24
+ select: [];
25
+ }>();
26
+
27
+ const letter = computed(() => String.fromCharCode(65 + props.optionIndex));
28
+
29
+ const stateClass = computed(() => {
30
+ if (props.showResult && props.correct) {
31
+ return "border-success bg-success/10 text-foreground";
32
+ }
33
+ if (props.showResult && props.selected && !props.correct) {
34
+ return "border-destructive bg-destructive/10 text-foreground";
35
+ }
36
+ if (props.selected) {
37
+ return "border-primary bg-primary/5 ring-2 ring-primary/20";
38
+ }
39
+ return "";
40
+ });
41
+
42
+ const iconName = computed(() => {
43
+ if (!props.showResult) return null;
44
+ if (props.correct) return "lucide:circle-check";
45
+ if (props.selected) return "lucide:circle-x";
46
+ return null;
47
+ });
48
+ </script>
49
+
50
+ <template>
51
+ <button
52
+ type="button"
53
+ :disabled="disabled"
54
+ :class="
55
+ cn(
56
+ 'flex w-full items-center gap-3 rounded-lg border bg-background px-4 py-3 text-left text-sm transition-all',
57
+ !disabled && !showResult && 'cursor-pointer hover:bg-accent/50',
58
+ disabled && 'cursor-default',
59
+ stateClass,
60
+ )
61
+ "
62
+ @click="!disabled && emit('select')"
63
+ >
64
+ <span
65
+ :class="
66
+ cn(
67
+ 'flex size-6 shrink-0 items-center justify-center rounded-md text-xs font-semibold',
68
+ selected && !showResult && 'bg-primary text-primary-foreground',
69
+ showResult && correct && 'bg-success text-success-foreground',
70
+ showResult && selected && !correct && 'bg-destructive text-destructive-foreground',
71
+ !selected && !showResult && 'bg-muted text-muted-foreground',
72
+ showResult && !correct && !selected && 'bg-muted text-muted-foreground',
73
+ )
74
+ "
75
+ >
76
+ {{ letter }}
77
+ </span>
78
+ <span class="min-w-0 flex-1">
79
+ <template v-if="label">{{ label }}</template>
80
+ <slot v-else />
81
+ </span>
82
+ <Icon
83
+ v-if="iconName"
84
+ :name="iconName"
85
+ :class="
86
+ cn('size-4 shrink-0', correct && 'text-success', selected && !correct && 'text-destructive')
87
+ "
88
+ aria-hidden="true"
89
+ />
90
+ </button>
91
+ </template>
@@ -0,0 +1,226 @@
1
+ <script setup lang="ts">
2
+ import type { Ref, VNode } from "vue";
3
+ import { h, inject, ref, computed, watch, useSlots, resolveComponent } from "vue";
4
+ import { cn } from "../../utils";
5
+ import { Icon } from "#components";
6
+ import { useI18n } from "#imports";
7
+
8
+ type YamlOption = { text: string; correct?: boolean } | string;
9
+
10
+ const props = withDefaults(
11
+ defineProps<{
12
+ question: string;
13
+ type?: "single" | "multiple";
14
+ explanation?: string;
15
+ options?: YamlOption[];
16
+ checkLabel?: string;
17
+ correctLabel?: string;
18
+ incorrectLabel?: string;
19
+ resetLabel?: string;
20
+ multipleChoiceLabel?: string;
21
+ }>(),
22
+ {
23
+ type: "single",
24
+ },
25
+ );
26
+
27
+ const MDC_QUIZ = Symbol.for("mdc.quiz");
28
+ const quizContext = inject<{
29
+ registerQuestion: () => number;
30
+ markAnswered: (index: number, correct: boolean) => void;
31
+ resetKey: Ref<number>;
32
+ inQuiz: boolean;
33
+ } | null>(MDC_QUIZ, null);
34
+
35
+ const questionIndex = quizContext?.registerQuestion() ?? -1;
36
+
37
+ if (quizContext?.resetKey) {
38
+ watch(quizContext.resetKey, () => {
39
+ isSubmitted.value = false;
40
+ selectedOptions.value = new Set();
41
+ });
42
+ }
43
+
44
+ const slots = useSlots();
45
+ const selectedOptions = ref(new Set<number>());
46
+ const isSubmitted = ref(false);
47
+ const { locale } = useI18n();
48
+
49
+ const text = computed(() => {
50
+ const isGerman = String(locale.value) === "de";
51
+
52
+ return {
53
+ check: props.checkLabel ?? (isGerman ? "Antwort pruefen" : "Check Answer"),
54
+ correct: props.correctLabel ?? (isGerman ? "Richtig" : "Correct"),
55
+ incorrect: props.incorrectLabel ?? (isGerman ? "Nicht richtig" : "Incorrect"),
56
+ reset: props.resetLabel ?? (isGerman ? "Erneut versuchen" : "Try Again"),
57
+ multipleChoice:
58
+ props.multipleChoiceLabel ??
59
+ (isGerman ? "(mehrere Antworten moeglich)" : "(select all that apply)"),
60
+ };
61
+ });
62
+
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
+ }
73
+
74
+ 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
+ });
87
+ });
88
+
89
+ function handleSelect(index: number) {
90
+ if (isSubmitted.value) return;
91
+ if (props.type === "single") {
92
+ selectedOptions.value = new Set([index]);
93
+ } else {
94
+ const next = new Set(selectedOptions.value);
95
+ if (next.has(index)) {
96
+ next.delete(index);
97
+ } else {
98
+ next.add(index);
99
+ }
100
+ selectedOptions.value = next;
101
+ }
102
+ }
103
+
104
+ function handleSubmit() {
105
+ isSubmitted.value = true;
106
+ quizContext?.markAnswered(questionIndex, isCorrect.value);
107
+ }
108
+
109
+ function handleReset() {
110
+ isSubmitted.value = false;
111
+ selectedOptions.value = new Set();
112
+ }
113
+
114
+ function renderButton(
115
+ label: string,
116
+ options: {
117
+ disabled?: boolean;
118
+ onClick: () => void;
119
+ variant?: "outline" | "ghost";
120
+ },
121
+ ) {
122
+ return h(
123
+ "button",
124
+ {
125
+ type: "button",
126
+ 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
+ ),
133
+ onClick: options.onClick,
134
+ },
135
+ () => label,
136
+ );
137
+ }
138
+
139
+ 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, {
145
+ key: i,
146
+ optionIndex: i,
147
+ correct,
148
+ label: text,
149
+ selected: selectedOptions.value.has(i),
150
+ 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
+ );
166
+ }
167
+
168
+ 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"
172
+ ? h(
173
+ "span",
174
+ { class: "text-xs font-normal text-muted-foreground mr-2" },
175
+ text.value.multipleChoice,
176
+ )
177
+ : 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
+ ]);
221
+ }
222
+ </script>
223
+
224
+ <template>
225
+ <component :is="renderQuestion" />
226
+ </template>
@@ -0,0 +1,90 @@
1
+ <script setup lang="ts">
2
+ import { NuxtLink } from "#components";
3
+ import { computed, inject } from "vue";
4
+ import { cn } from "../../utils";
5
+ import { isExternalLink, resolveIconifyIcon } from "./icons";
6
+
7
+ const MDC_READ_MORE_GROUP = Symbol.for("mdc.readMoreGroup");
8
+ const inReadMoreGroup = inject(MDC_READ_MORE_GROUP, false);
9
+
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
+ });
43
+
44
+ const wrapperTag = computed(() => (isExternalLink(props.to) ? "a" : NuxtLink));
45
+
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
+ }));
55
+ </script>
56
+
57
+ <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
+ "
70
+ >
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" />
86
+ </span>
87
+ </div>
88
+ </component>
89
+ </component>
90
+ </template>
@@ -0,0 +1,18 @@
1
+ <script setup lang="ts">
2
+ import { provide } from "vue";
3
+
4
+ const MDC_READ_MORE_GROUP = Symbol.for("mdc.readMoreGroup");
5
+
6
+ provide(MDC_READ_MORE_GROUP, true);
7
+ </script>
8
+
9
+ <template>
10
+ <div
11
+ data-slot="card"
12
+ class="not-prose my-6 gap-0 overflow-hidden rounded-xl border bg-card py-0 text-card-foreground shadow-xs transition-colors hover:border-foreground/20"
13
+ >
14
+ <div class="flex flex-col divide-y divide-border">
15
+ <slot />
16
+ </div>
17
+ </div>
18
+ </template>