@tiramisu-docs/kit 0.1.0

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 (99) hide show
  1. package/README.md +103 -0
  2. package/components.json +14 -0
  3. package/dist/bin/mcp.d.ts +2 -0
  4. package/dist/bin/mcp.js +4 -0
  5. package/dist/config.d.ts +99 -0
  6. package/dist/config.js +36 -0
  7. package/dist/highlight.d.ts +10 -0
  8. package/dist/highlight.js +93 -0
  9. package/dist/index.d.ts +6 -0
  10. package/dist/index.js +3 -0
  11. package/dist/lib/components/index.d.ts +16 -0
  12. package/dist/lib/components/index.js +18 -0
  13. package/dist/lib/components/tiramisu/lang-icons.d.ts +4 -0
  14. package/dist/lib/components/tiramisu/lang-icons.js +77 -0
  15. package/dist/lib/components/ui/alert/index.d.ts +5 -0
  16. package/dist/lib/components/ui/alert/index.js +6 -0
  17. package/dist/lib/components/ui/badge/index.d.ts +2 -0
  18. package/dist/lib/components/ui/badge/index.js +1 -0
  19. package/dist/lib/components/ui/button/index.d.ts +4 -0
  20. package/dist/lib/components/ui/button/index.js +2 -0
  21. package/dist/lib/components/ui/card/index.d.ts +8 -0
  22. package/dist/lib/components/ui/card/index.js +10 -0
  23. package/dist/lib/components/ui/collapsible/index.d.ts +1 -0
  24. package/dist/lib/components/ui/collapsible/index.js +1 -0
  25. package/dist/lib/components/ui/dropdown-menu/index.d.ts +18 -0
  26. package/dist/lib/components/ui/dropdown-menu/index.js +18 -0
  27. package/dist/lib/components/ui/scroll-area/index.d.ts +1 -0
  28. package/dist/lib/components/ui/scroll-area/index.js +1 -0
  29. package/dist/lib/components/ui/separator/index.d.ts +1 -0
  30. package/dist/lib/components/ui/separator/index.js +1 -0
  31. package/dist/lib/components/ui/sheet/index.d.ts +3 -0
  32. package/dist/lib/components/ui/sheet/index.js +3 -0
  33. package/dist/lib/components/ui/tabs/index.d.ts +5 -0
  34. package/dist/lib/components/ui/tabs/index.js +7 -0
  35. package/dist/lib/open-links.d.ts +22 -0
  36. package/dist/lib/open-links.js +33 -0
  37. package/dist/lib/routes/docs/[...slug]/+page.d.ts +25 -0
  38. package/dist/lib/routes/docs/[...slug]/+page.js +109 -0
  39. package/dist/lib/utils.d.ts +5 -0
  40. package/dist/lib/utils.js +5 -0
  41. package/dist/mcp.d.ts +24 -0
  42. package/dist/mcp.js +155 -0
  43. package/dist/scan.d.ts +15 -0
  44. package/dist/scan.js +72 -0
  45. package/dist/seo.d.ts +63 -0
  46. package/dist/seo.js +160 -0
  47. package/dist/tiramisu-grammar.d.ts +2 -0
  48. package/dist/tiramisu-grammar.js +77 -0
  49. package/dist/types.d.ts +66 -0
  50. package/dist/types.js +1 -0
  51. package/dist/vite.d.ts +33 -0
  52. package/dist/vite.js +406 -0
  53. package/package.json +74 -0
  54. package/src/config.ts +133 -0
  55. package/src/highlight.ts +110 -0
  56. package/src/index.ts +6 -0
  57. package/src/lib/components/DocPage.svelte +430 -0
  58. package/src/lib/components/DocsLayout.svelte +145 -0
  59. package/src/lib/components/Footer.svelte +26 -0
  60. package/src/lib/components/Navbar.svelte +117 -0
  61. package/src/lib/components/PageFooter.svelte +63 -0
  62. package/src/lib/components/PrevNextNav.svelte +83 -0
  63. package/src/lib/components/SearchDialog.svelte +130 -0
  64. package/src/lib/components/Sidebar.svelte +237 -0
  65. package/src/lib/components/TableOfContents.svelte +50 -0
  66. package/src/lib/components/TopBar.svelte +407 -0
  67. package/src/lib/components/index.ts +19 -0
  68. package/src/lib/components/tiramisu/Accordion.svelte +16 -0
  69. package/src/lib/components/tiramisu/Badge.svelte +16 -0
  70. package/src/lib/components/tiramisu/Callout.svelte +26 -0
  71. package/src/lib/components/tiramisu/CodeBlock.svelte +56 -0
  72. package/src/lib/components/tiramisu/CodeTabs.svelte +123 -0
  73. package/src/lib/components/tiramisu/Demo.svelte +15 -0
  74. package/src/lib/components/tiramisu/FileTree.svelte +67 -0
  75. package/src/lib/components/tiramisu/MathBlock.svelte +26 -0
  76. package/src/lib/components/tiramisu/Mermaid.svelte +30 -0
  77. package/src/lib/components/tiramisu/NavCard.svelte +49 -0
  78. package/src/lib/components/tiramisu/Steps.svelte +60 -0
  79. package/src/lib/components/tiramisu/Tabs.svelte +87 -0
  80. package/src/lib/components/tiramisu/ZoomImage.svelte +114 -0
  81. package/src/lib/components/tiramisu/lang-icons.ts +81 -0
  82. package/src/lib/open-links.ts +50 -0
  83. package/src/lib/routes/docs/[...slug]/+page.svelte +26 -0
  84. package/src/lib/routes/docs/[...slug]/+page.ts +117 -0
  85. package/src/lib/styles/theme.css +222 -0
  86. package/src/lib/utils.ts +10 -0
  87. package/src/mcp.ts +180 -0
  88. package/src/scan.ts +92 -0
  89. package/src/seo.ts +193 -0
  90. package/src/tiramisu-grammar.ts +80 -0
  91. package/src/types.ts +71 -0
  92. package/src/virtual.d.ts +11 -0
  93. package/src/vite.ts +478 -0
  94. package/tests/config.test.ts +60 -0
  95. package/tests/mcp.test.ts +116 -0
  96. package/tests/scan.test.ts +48 -0
  97. package/tests/seo.test.ts +174 -0
  98. package/tests/vite.test.ts +283 -0
  99. package/tsconfig.json +19 -0
@@ -0,0 +1,123 @@
1
+ <script lang="ts">
2
+ import { fly } from "svelte/transition"
3
+ import { getLangIcon } from "./lang-icons.js"
4
+
5
+ interface TabMeta {
6
+ label: string
7
+ icon?: string
8
+ language?: string
9
+ }
10
+
11
+ let {
12
+ group = "",
13
+ tabs = [],
14
+ codes = [],
15
+ langMap = [],
16
+ }: { group?: string; tabs?: TabMeta[]; codes?: string[]; langMap?: string[] } = $props()
17
+
18
+ const storageKey = $derived(group ? `codetabs:${group}` : "")
19
+
20
+ function getInitial() {
21
+ const key = group ? `codetabs:${group}` : ""
22
+ if (key && typeof window !== "undefined" && window.localStorage) {
23
+ const saved = window.localStorage.getItem(key)
24
+ if (saved && tabs.some((t: TabMeta) => t.label === saved)) return saved
25
+ }
26
+ return tabs[0]?.label ?? ""
27
+ }
28
+
29
+ let active = $state(getInitial())
30
+
31
+ function select(label: string) {
32
+ if (label === active) return
33
+ active = label
34
+ if (storageKey && typeof localStorage !== "undefined") {
35
+ localStorage.setItem(storageKey, label)
36
+ window.dispatchEvent(new CustomEvent("codetabs-sync", { detail: { group, label } }))
37
+ }
38
+ }
39
+
40
+ $effect(() => {
41
+ if (!storageKey || typeof window === "undefined") return
42
+
43
+ function onSync(e: Event) {
44
+ const detail = (e as CustomEvent<{ group: string; label: string }>).detail
45
+ if (detail?.group === group && detail?.label !== active) {
46
+ select(detail.label)
47
+ }
48
+ }
49
+
50
+ function onStorage(e: StorageEvent) {
51
+ if (e.key === storageKey && e.newValue && e.newValue !== active) {
52
+ select(e.newValue)
53
+ }
54
+ }
55
+
56
+ window.addEventListener("codetabs-sync", onSync)
57
+ window.addEventListener("storage", onStorage)
58
+ return () => {
59
+ window.removeEventListener("codetabs-sync", onSync)
60
+ window.removeEventListener("storage", onStorage)
61
+ }
62
+ })
63
+
64
+ let copied = $state(false)
65
+
66
+ function copyActive() {
67
+ const idx = tabs.findIndex((t: TabMeta) => t.label === active)
68
+ if (idx === -1) return
69
+ const raw = (codes[idx] ?? "").replace(/<[^>]*>/g, "")
70
+ navigator.clipboard.writeText(raw)
71
+ copied = true
72
+ setTimeout(() => (copied = false), 2000)
73
+ }
74
+
75
+ function tabIcon(tab: TabMeta): string | undefined {
76
+ if (tab.icon) return tab.icon
77
+ return getLangIcon(tab.label) || getLangIcon(tab.language || "")
78
+ }
79
+ </script>
80
+
81
+ <div class="group relative my-4 overflow-hidden rounded-lg border border-border">
82
+ <div class="flex items-center border-b border-border bg-muted/50">
83
+ <div class="flex overflow-x-auto">
84
+ {#each tabs as tab, i}
85
+ {@const icon = tabIcon(tab)}
86
+ <button
87
+ onclick={() => select(tab.label)}
88
+ class="relative flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors whitespace-nowrap {active === tab.label ? 'text-foreground' : 'text-muted-foreground hover:text-foreground/80'}"
89
+ >
90
+ {#if icon}
91
+ <iconify-icon icon={icon} width="14" height="14" class="shrink-0"></iconify-icon>
92
+ {/if}
93
+ {tab.label}
94
+ {#if active === tab.label}
95
+ <span class="absolute bottom-0 left-2 right-2 h-0.5 rounded-full bg-primary"></span>
96
+ {/if}
97
+ </button>
98
+ {/each}
99
+ </div>
100
+ <div class="ml-auto pr-2">
101
+ <button
102
+ onclick={copyActive}
103
+ class="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100"
104
+ aria-label="Copy code"
105
+ >
106
+ {#if copied}
107
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
108
+ Copied
109
+ {:else}
110
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
111
+ Copy
112
+ {/if}
113
+ </button>
114
+ </div>
115
+ </div>
116
+ {#each tabs as tab, i}
117
+ {#if active === tab.label}
118
+ <div in:fly={{ y: 6, duration: 200 }}>
119
+ <pre class="overflow-x-auto p-4 text-sm leading-relaxed"><code>{@html codes[i] ?? ""}</code></pre>
120
+ </div>
121
+ {/if}
122
+ {/each}
123
+ </div>
@@ -0,0 +1,15 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+ import { Card, CardContent, CardHeader, CardTitle } from "$lib/components/ui/card/index.js"
4
+
5
+ let { title = "Preview", children }: { title?: string; children: Snippet } = $props()
6
+ </script>
7
+
8
+ <Card class="my-4">
9
+ <CardHeader>
10
+ <CardTitle class="text-xs font-medium tracking-wide text-muted-foreground uppercase">{title}</CardTitle>
11
+ </CardHeader>
12
+ <CardContent>
13
+ {@render children()}
14
+ </CardContent>
15
+ </Card>
@@ -0,0 +1,67 @@
1
+ <script lang="ts">
2
+ let { children }: { children?: import("svelte").Snippet } = $props()
3
+ </script>
4
+
5
+ <div class="file-tree my-4 rounded-lg border bg-card p-4 font-mono text-sm">
6
+ {@render children?.()}
7
+ </div>
8
+
9
+ <style>
10
+ .file-tree :global(.tree-file),
11
+ .file-tree :global(.tree-folder-name) {
12
+ position: relative;
13
+ display: block;
14
+ padding-left: 1.25rem;
15
+ padding-top: 0.125rem;
16
+ padding-bottom: 0.125rem;
17
+ }
18
+
19
+ .file-tree :global(.tree-file)::before {
20
+ content: "";
21
+ position: absolute;
22
+ left: 0;
23
+ top: 0.35rem;
24
+ width: 14px;
25
+ height: 14px;
26
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z'/%3E%3Cpath d='M14 2v4a2 2 0 0 0 2 2h4'/%3E%3C/svg%3E");
27
+ background-size: contain;
28
+ background-repeat: no-repeat;
29
+ }
30
+
31
+ .file-tree :global(.tree-folder-name)::before {
32
+ content: "";
33
+ position: absolute;
34
+ left: 0;
35
+ top: 0.35rem;
36
+ width: 14px;
37
+ height: 14px;
38
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z'/%3E%3C/svg%3E");
39
+ background-size: contain;
40
+ background-repeat: no-repeat;
41
+ }
42
+
43
+ .file-tree :global(.tree-folder-name) {
44
+ font-weight: 500;
45
+ color: var(--foreground);
46
+ }
47
+
48
+ /* Nesting: border aligned under parent folder icon center (7px ≈ 0.45rem) */
49
+ .file-tree :global(.tree-folder > .tree-file),
50
+ .file-tree :global(.tree-folder > .tree-folder) {
51
+ margin-left: 0.45rem;
52
+ border-left: 1px solid var(--border);
53
+ }
54
+
55
+ /* Nested files: shift icon away from border, increase padding to match */
56
+ .file-tree :global(.tree-folder > .tree-file) {
57
+ padding-left: 1.75rem;
58
+ }
59
+ .file-tree :global(.tree-folder > .tree-file)::before {
60
+ left: 0.5rem;
61
+ }
62
+
63
+ /* Nested folders: gap from border to folder-name content */
64
+ .file-tree :global(.tree-folder > .tree-folder) {
65
+ padding-left: 0.5rem;
66
+ }
67
+ </style>
@@ -0,0 +1,26 @@
1
+ <script lang="ts">
2
+ import { onMount } from "svelte"
3
+
4
+ let { formula = "" }: { formula?: string } = $props()
5
+ let container: HTMLElement
6
+ let html = $state("")
7
+
8
+ onMount(async () => {
9
+ const katex = await import("katex")
10
+ html = katex.default.renderToString(formula, {
11
+ throwOnError: false,
12
+ displayMode: true,
13
+ })
14
+ })
15
+ </script>
16
+
17
+ <svelte:head>
18
+ <link
19
+ rel="stylesheet"
20
+ href="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.css"
21
+ />
22
+ </svelte:head>
23
+
24
+ <div bind:this={container} class="math-block my-4 overflow-x-auto">
25
+ {@html html}
26
+ </div>
@@ -0,0 +1,30 @@
1
+ <script lang="ts">
2
+ import { onMount } from "svelte"
3
+
4
+ let { chart = "" }: { chart?: string } = $props()
5
+ let container: HTMLElement
6
+ let idBase = `mermaid-${Math.random().toString(36).slice(2, 9)}`
7
+ let renderCount = 0
8
+
9
+ onMount(async () => {
10
+ const mermaid = (await import("mermaid")).default
11
+
12
+ async function render() {
13
+ const isDark = document.documentElement.classList.contains("dark")
14
+ mermaid.initialize({ startOnLoad: false, theme: isDark ? "dark" : "default" })
15
+ const rid = `${idBase}-${renderCount++}`
16
+ const { svg } = await mermaid.render(rid, chart)
17
+ container.innerHTML = svg
18
+ }
19
+
20
+ await render()
21
+
22
+ const observer = new MutationObserver(() => render())
23
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] })
24
+ return () => observer.disconnect()
25
+ })
26
+ </script>
27
+
28
+ <div bind:this={container} class="mermaid-block my-4 flex justify-center">
29
+ <pre class="text-sm text-muted-foreground">{chart}</pre>
30
+ </div>
@@ -0,0 +1,49 @@
1
+ <script lang="ts">
2
+ let {
3
+ title = "",
4
+ description = "",
5
+ href = "",
6
+ icon = "",
7
+ image = "",
8
+ }: {
9
+ title?: string
10
+ description?: string
11
+ href?: string
12
+ icon?: string
13
+ image?: string
14
+ } = $props()
15
+ </script>
16
+
17
+ {#snippet cardContent()}
18
+ {#if image}
19
+ <div class="aspect-[2/1] w-full overflow-hidden bg-muted">
20
+ <img src={image} alt="" class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" />
21
+ </div>
22
+ {/if}
23
+ <div class="flex flex-col gap-2 p-5">
24
+ <span class="flex items-center gap-2 font-semibold text-card-foreground group-hover:text-primary">
25
+ {#if icon}
26
+ <iconify-icon icon={icon.includes(":") ? icon : `lucide:${icon}`} width="18" height="18" class="shrink-0"></iconify-icon>
27
+ {/if}
28
+ {title}
29
+ </span>
30
+ {#if description}
31
+ <span class="text-sm text-muted-foreground">{description}</span>
32
+ {/if}
33
+ </div>
34
+ {/snippet}
35
+
36
+ {#if href}
37
+ <a
38
+ {href}
39
+ class="group flex flex-col overflow-hidden rounded-xl border bg-card shadow-sm transition-colors hover:border-primary/30 hover:bg-accent"
40
+ >
41
+ {@render cardContent()}
42
+ </a>
43
+ {:else}
44
+ <div
45
+ class="group flex flex-col overflow-hidden rounded-xl border bg-card shadow-sm"
46
+ >
47
+ {@render cardContent()}
48
+ </div>
49
+ {/if}
@@ -0,0 +1,60 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+
4
+ let { children }: { children: Snippet } = $props()
5
+ </script>
6
+
7
+ <div class="stepper my-6">
8
+ {@render children()}
9
+ </div>
10
+
11
+ <style>
12
+ .stepper :global(ol) {
13
+ list-style: none;
14
+ padding-left: 0;
15
+ margin: 0;
16
+ counter-reset: step;
17
+ }
18
+
19
+ .stepper :global(li.step) {
20
+ position: relative;
21
+ padding-left: 2.5rem;
22
+ padding-bottom: 1.5rem;
23
+ counter-increment: step;
24
+ }
25
+
26
+ .stepper :global(li.step)::before {
27
+ content: counter(step);
28
+ position: absolute;
29
+ left: 0;
30
+ top: 0;
31
+ width: 1.75rem;
32
+ height: 1.75rem;
33
+ border-radius: 9999px;
34
+ background: var(--primary);
35
+ color: var(--primary-foreground);
36
+ font-size: 0.75rem;
37
+ font-weight: 600;
38
+ display: flex;
39
+ align-items: center;
40
+ justify-content: center;
41
+ }
42
+
43
+ .stepper :global(li.step)::after {
44
+ content: "";
45
+ position: absolute;
46
+ left: 0.8125rem;
47
+ top: 1.75rem;
48
+ width: 2px;
49
+ bottom: 0;
50
+ background: var(--border);
51
+ }
52
+
53
+ .stepper :global(li.step:last-child)::after {
54
+ display: none;
55
+ }
56
+
57
+ .stepper :global(li.step:last-child) {
58
+ padding-bottom: 0;
59
+ }
60
+ </style>
@@ -0,0 +1,87 @@
1
+ <script lang="ts">
2
+ import { fly } from "svelte/transition"
3
+
4
+ interface TabMeta {
5
+ label: string
6
+ icon?: string
7
+ }
8
+
9
+ let {
10
+ group = "",
11
+ tabs = [],
12
+ contents = [],
13
+ }: { group?: string; tabs?: TabMeta[]; contents?: string[] } = $props()
14
+
15
+ const storageKey = $derived(group ? `tabs:${group}` : "")
16
+
17
+ function getInitial() {
18
+ if (storageKey && typeof window !== "undefined" && window.localStorage) {
19
+ const saved = window.localStorage.getItem(storageKey)
20
+ if (saved && tabs.some((t: TabMeta) => t.label === saved)) return saved
21
+ }
22
+ return tabs[0]?.label ?? ""
23
+ }
24
+
25
+ let active = $state(getInitial())
26
+
27
+ function select(label: string) {
28
+ if (label === active) return
29
+ active = label
30
+ if (storageKey && typeof window !== "undefined" && window.localStorage) {
31
+ window.localStorage.setItem(storageKey, label)
32
+ window.dispatchEvent(new CustomEvent("tabs-sync", { detail: { group, label } }))
33
+ }
34
+ }
35
+
36
+ $effect(() => {
37
+ if (!storageKey || typeof window === "undefined") return
38
+
39
+ function onSync(e: Event) {
40
+ const detail = (e as CustomEvent<{ group: string; label: string }>).detail
41
+ if (detail?.group === group && detail?.label !== active) {
42
+ select(detail.label)
43
+ }
44
+ }
45
+
46
+ function onStorage(e: StorageEvent) {
47
+ if (e.key === storageKey && e.newValue && e.newValue !== active) {
48
+ select(e.newValue)
49
+ }
50
+ }
51
+
52
+ window.addEventListener("tabs-sync", onSync)
53
+ window.addEventListener("storage", onStorage)
54
+ return () => {
55
+ window.removeEventListener("tabs-sync", onSync)
56
+ window.removeEventListener("storage", onStorage)
57
+ }
58
+ })
59
+ </script>
60
+
61
+ <div class="my-4 overflow-hidden rounded-lg border border-border">
62
+ <div class="flex border-b border-border bg-muted/50">
63
+ <div class="flex overflow-x-auto">
64
+ {#each tabs as tab}
65
+ <button
66
+ onclick={() => select(tab.label)}
67
+ class="relative flex items-center gap-1.5 px-4 py-2 text-sm font-medium transition-colors whitespace-nowrap {active === tab.label ? 'text-foreground' : 'text-muted-foreground hover:text-foreground/80'}"
68
+ >
69
+ {#if tab.icon}
70
+ <iconify-icon icon={tab.icon.includes(":") ? tab.icon : `lucide:${tab.icon}`} width="14" height="14" class="shrink-0"></iconify-icon>
71
+ {/if}
72
+ {tab.label}
73
+ {#if active === tab.label}
74
+ <span class="absolute bottom-0 left-2 right-2 h-0.5 rounded-full bg-primary"></span>
75
+ {/if}
76
+ </button>
77
+ {/each}
78
+ </div>
79
+ </div>
80
+ {#each tabs as tab, i}
81
+ {#if active === tab.label}
82
+ <div class="p-4" in:fly={{ y: 6, duration: 200 }}>
83
+ {@html contents[i] ?? ""}
84
+ </div>
85
+ {/if}
86
+ {/each}
87
+ </div>
@@ -0,0 +1,114 @@
1
+ <script lang="ts">
2
+ import { fade } from "svelte/transition"
3
+
4
+ let { src = "", alt = "", caption = "" }: { src?: string; alt?: string; caption?: string } = $props()
5
+ let zoomed = $state(false)
6
+
7
+ function handleKeydown(e: KeyboardEvent) {
8
+ if (e.key === "Escape") zoomed = false
9
+ }
10
+
11
+ function scaleIn(node: Element) {
12
+ return {
13
+ duration: 250,
14
+ css: (t: number) => {
15
+ const ease = 1 - Math.pow(1 - t, 3)
16
+ return `opacity: ${ease}; transform: scale(${0.85 + 0.15 * ease})`
17
+ },
18
+ }
19
+ }
20
+
21
+ function scaleOut(node: Element) {
22
+ return {
23
+ duration: 200,
24
+ css: (t: number) => {
25
+ const ease = t * t
26
+ return `opacity: ${ease}; transform: scale(${0.85 + 0.15 * ease})`
27
+ },
28
+ }
29
+ }
30
+ </script>
31
+
32
+ <svelte:window onkeydown={handleKeydown} />
33
+
34
+ <figure class="zoom-figure">
35
+ <button type="button" class="zoom-trigger" onclick={() => (zoomed = true)}>
36
+ <img {src} {alt} class="rounded-lg" />
37
+ </button>
38
+ {#if caption}
39
+ <figcaption class="mt-2 text-center text-sm text-muted-foreground">{caption}</figcaption>
40
+ {/if}
41
+ </figure>
42
+
43
+ {#if zoomed}
44
+ <div class="zoom-overlay" role="dialog" aria-modal="true">
45
+ <button
46
+ type="button"
47
+ class="zoom-backdrop"
48
+ onclick={() => (zoomed = false)}
49
+ in:fade={{ duration: 250 }}
50
+ out:fade={{ duration: 200 }}
51
+ >
52
+ <img
53
+ {src}
54
+ {alt}
55
+ class="zoom-image"
56
+ in:scaleIn
57
+ out:scaleOut
58
+ />
59
+ </button>
60
+ </div>
61
+ {/if}
62
+
63
+ <style>
64
+ .zoom-figure {
65
+ margin: 0;
66
+ }
67
+
68
+ .zoom-trigger {
69
+ cursor: zoom-in;
70
+ background: none;
71
+ border: none;
72
+ padding: 0;
73
+ display: block;
74
+ }
75
+
76
+ .zoom-trigger img {
77
+ max-width: 100%;
78
+ height: auto;
79
+ transition: opacity 0.15s;
80
+ }
81
+
82
+ .zoom-trigger:hover img {
83
+ opacity: 0.85;
84
+ }
85
+
86
+ .zoom-overlay {
87
+ position: fixed;
88
+ inset: 0;
89
+ z-index: 50;
90
+ display: flex;
91
+ align-items: center;
92
+ justify-content: center;
93
+ }
94
+
95
+ .zoom-backdrop {
96
+ position: fixed;
97
+ inset: 0;
98
+ background: rgba(0, 0, 0, 0.8);
99
+ border: none;
100
+ cursor: zoom-out;
101
+ display: flex;
102
+ align-items: center;
103
+ justify-content: center;
104
+ padding: 2rem;
105
+ backdrop-filter: blur(4px);
106
+ }
107
+
108
+ .zoom-image {
109
+ max-width: 90vw;
110
+ max-height: 90vh;
111
+ object-fit: contain;
112
+ border-radius: 0.5rem;
113
+ }
114
+ </style>
@@ -0,0 +1,81 @@
1
+ import { addIcon } from "iconify-icon"
2
+
3
+ // Register custom tiramisu icon
4
+ addIcon("custom:tiramisu", {
5
+ body: '<rect x="3" y="16" width="18" height="3" rx="1" fill="currentColor"/><rect x="4" y="11" width="16" height="3" rx="1" fill="currentColor" opacity="0.7"/><rect x="5" y="6" width="14" height="3" rx="1" fill="currentColor" opacity="0.5"/><circle cx="8" cy="4" r="0.7" fill="currentColor" opacity="0.6"/><circle cx="12" cy="3.5" r="0.7" fill="currentColor" opacity="0.4"/><circle cx="16" cy="4" r="0.7" fill="currentColor" opacity="0.6"/>',
6
+ width: 24,
7
+ height: 24,
8
+ })
9
+
10
+ /** Map language/tool names to Iconify icon identifiers */
11
+ const icons: Record<string, string> = {
12
+ // JS/TS
13
+ typescript: "devicon-plain:typescript",
14
+ javascript: "devicon-plain:javascript",
15
+ tsx: "devicon-plain:typescript",
16
+ jsx: "devicon-plain:javascript",
17
+ // Web
18
+ html: "devicon-plain:html5",
19
+ css: "devicon-plain:css3",
20
+ svelte: "devicon-plain:svelte",
21
+ react: "devicon-plain:react",
22
+ vue: "devicon-plain:vuejs",
23
+ angular: "devicon-plain:angularjs",
24
+ // Shell
25
+ bash: "devicon-plain:bash",
26
+ shell: "devicon-plain:bash",
27
+ zsh: "devicon-plain:bash",
28
+ // Languages
29
+ python: "devicon-plain:python",
30
+ rust: "devicon-plain:rust",
31
+ go: "devicon-plain:go",
32
+ java: "devicon-plain:java",
33
+ csharp: "devicon-plain:csharp",
34
+ ruby: "devicon-plain:ruby",
35
+ php: "devicon-plain:php",
36
+ swift: "devicon-plain:swift",
37
+ kotlin: "devicon-plain:kotlin",
38
+ dart: "devicon-plain:dart",
39
+ c: "devicon-plain:c",
40
+ cpp: "devicon-plain:cplusplus",
41
+ // Data/Config
42
+ json: "devicon-plain:json",
43
+ yaml: "devicon-plain:yaml",
44
+ markdown: "devicon-plain:markdown",
45
+ graphql: "devicon-plain:graphql",
46
+ // Tools/Runtimes
47
+ docker: "devicon-plain:docker",
48
+ git: "devicon-plain:git",
49
+ nginx: "devicon-plain:nginx-original",
50
+ redis: "devicon-plain:redis",
51
+ postgresql: "devicon-plain:postgresql",
52
+ mongodb: "devicon-plain:mongodb",
53
+ // Package managers / runtimes
54
+ npm: "devicon-plain:npm",
55
+ yarn: "devicon-plain:yarn",
56
+ pnpm: "devicon-plain:pnpm",
57
+ bun: "devicon-plain:bun",
58
+ deno: "devicon-plain:denojs",
59
+ node: "devicon-plain:nodejs",
60
+ nodejs: "devicon-plain:nodejs",
61
+ // Custom
62
+ tiramisu: "custom:tiramisu",
63
+ }
64
+
65
+ // Aliases
66
+ icons.ts = icons.typescript
67
+ icons.js = icons.javascript
68
+ icons.sh = icons.bash
69
+ icons.py = icons.python
70
+ icons.rs = icons.rust
71
+ icons.md = icons.markdown
72
+ icons.yml = icons.yaml
73
+ icons["c++"] = icons.cpp
74
+ icons["c#"] = icons.csharp
75
+
76
+ /**
77
+ * Get an Iconify icon name for a language, or empty string if unknown.
78
+ */
79
+ export function getLangIcon(language: string): string {
80
+ return icons[language.toLowerCase()] ?? ""
81
+ }