cantip 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 (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/app/components/CanvasMount.tsx +62 -0
  4. package/app/components/CodeWrapToggle.tsx +78 -0
  5. package/app/components/FindOnPage.tsx +224 -0
  6. package/app/components/MobileBottomBar.tsx +93 -0
  7. package/app/components/MobileProjectsPanel.tsx +113 -0
  8. package/app/components/PageFloatingMenu.tsx +224 -0
  9. package/app/components/ProjectSwitcher.tsx +124 -0
  10. package/app/components/Search.tsx +930 -0
  11. package/app/components/ShortcutsHelp.tsx +113 -0
  12. package/app/components/Sidebar.tsx +1049 -0
  13. package/app/components/TabBar.tsx +227 -0
  14. package/app/components/Toc.tsx +129 -0
  15. package/app/components/TopBar.tsx +74 -0
  16. package/app/components/theme-toggle.tsx +71 -0
  17. package/app/components/ui/button.tsx +56 -0
  18. package/app/components/ui/card.tsx +55 -0
  19. package/app/components/ui/dropdown-menu.tsx +156 -0
  20. package/app/components/ui/input.tsx +21 -0
  21. package/app/entry.client.tsx +12 -0
  22. package/app/entry.server.tsx +155 -0
  23. package/app/generated/site.ts +19 -0
  24. package/app/generated/slots.ts +10 -0
  25. package/app/generated/theme.generated.css +60 -0
  26. package/app/lib/config/config.server.ts +50 -0
  27. package/app/lib/config/defaults.ts +120 -0
  28. package/app/lib/config/load.ts +82 -0
  29. package/app/lib/config/schema.ts +131 -0
  30. package/app/lib/config/site.ts +43 -0
  31. package/app/lib/content.server.ts +105 -0
  32. package/app/lib/projects.ts +86 -0
  33. package/app/lib/sidebar.server.ts +113 -0
  34. package/app/lib/site.ts +27 -0
  35. package/app/lib/slots.tsx +33 -0
  36. package/app/lib/tabs.tsx +128 -0
  37. package/app/lib/useKeyboardShortcuts.ts +149 -0
  38. package/app/lib/utils.ts +17 -0
  39. package/app/root.tsx +171 -0
  40. package/app/routes/$.tsx +158 -0
  41. package/app/routes/_index.tsx +60 -0
  42. package/app/styles/app.css +461 -0
  43. package/app/styles/obsidian.css +83 -0
  44. package/app/styles/tailwind.css +227 -0
  45. package/cli.js +119 -0
  46. package/components.json +21 -0
  47. package/dist/config.mjs +87 -0
  48. package/dist/generate-content.mjs +1665 -0
  49. package/package.json +112 -0
  50. package/scripts/build-search-index.ts +129 -0
  51. package/scripts/canonical.ts +34 -0
  52. package/scripts/canvas-to-md.ts +73 -0
  53. package/scripts/compile.ts +242 -0
  54. package/scripts/emit-config.ts +163 -0
  55. package/scripts/generate-content.ts +197 -0
  56. package/scripts/obsidian/files.ts +222 -0
  57. package/scripts/obsidian/fs.ts +34 -0
  58. package/scripts/obsidian/generate.ts +36 -0
  59. package/scripts/obsidian/html.ts +17 -0
  60. package/scripts/obsidian/logger.ts +10 -0
  61. package/scripts/obsidian/markdown.ts +56 -0
  62. package/scripts/obsidian/obsidian.ts +229 -0
  63. package/scripts/obsidian/path.ts +60 -0
  64. package/scripts/obsidian/rehype.ts +60 -0
  65. package/scripts/obsidian/remark.ts +712 -0
  66. package/scripts/obsidian/types.ts +31 -0
  67. package/vite.config.ts +62 -0
@@ -0,0 +1,224 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { ArrowUp, ChevronLeft, ChevronRight, List, Search as SearchIcon, X } from 'lucide-react'
3
+
4
+ import type { Heading } from '~/lib/content.server'
5
+ import { TocLinks, tocHeadings } from '~/components/Toc'
6
+ import FindOnPage from '~/components/FindOnPage'
7
+ import { cn } from '~/lib/utils'
8
+
9
+ /** Shared tab class — IDENTICAL to MobileBottomBar's `tab`, so this row reads as
10
+ * a second deck of the same bar (stacked size-5 icon + 0.625rem label). */
11
+ const tab =
12
+ 'flex flex-1 flex-col items-center justify-center gap-0.5 rounded-lg py-1.5 text-[0.625rem] font-medium text-muted-foreground transition-colors'
13
+
14
+ /**
15
+ * Page-specific action menu (mobile only), styled as a SECOND DECK of the global
16
+ * MobileBottomBar: same pill chrome, same tab sizing (size-5 icons, 0.625rem
17
+ * labels), sitting directly above the global bar so the two read as one stacked
18
+ * control. The global bar itself is left untouched — this is a separate
19
+ * page-scoped strip.
20
+ *
21
+ * - Collapsed: a slim bar in the same chrome with one centred trigger tab
22
+ * ("Действия" + chevron).
23
+ * - Tapped: it swaps to the full action row — "Наверх" (scroll to top) and
24
+ * "Содержание" (open the TOC sheet) — as bottom-bar-style tabs. A trailing
25
+ * close tab (chevron-down) collapses it; tapping an action also collapses.
26
+ *
27
+ * Quiet by default: HIDDEN on the first screen, fading in once scrolled ~1
28
+ * viewport (at the top there's nothing to scroll up to and the intro stays
29
+ * clean), and dimmed until reached for.
30
+ *
31
+ * Layering: same inset-x-3 width as the global bar, stacked just above it, at
32
+ * z-80 — below the bar (z-100) AND below the mobile overlays (Sidebar/files and
33
+ * Projects panel, both z-90) so those cover it just like they cover each other.
34
+ * The TOC sheet it opens uses the modal tier (z-[200]).
35
+ */
36
+ export default function PageFloatingMenu({ headings }: { headings: Heading[] }) {
37
+ const shown = tocHeadings(headings)
38
+ const [visible, setVisible] = useState(true)
39
+ const [open, setOpen] = useState(false)
40
+ const [tocOpen, setTocOpen] = useState(false)
41
+ const [findOpen, setFindOpen] = useState(false)
42
+ const navRef = useRef<HTMLElement>(null)
43
+
44
+ // While the deck is open, any pointer/focus landing outside it collapses it —
45
+ // standard dropdown dismissal. pointerdown fires before click so a tap-away
46
+ // closes immediately; focusin covers keyboard/AT moving focus elsewhere.
47
+ useEffect(() => {
48
+ if (!open) return
49
+ const onAway = (e: Event) => {
50
+ if (!navRef.current?.contains(e.target as Node)) setOpen(false)
51
+ }
52
+ document.addEventListener('pointerdown', onAway)
53
+ document.addEventListener('focusin', onAway)
54
+ return () => {
55
+ document.removeEventListener('pointerdown', onAway)
56
+ document.removeEventListener('focusin', onAway)
57
+ }
58
+ }, [open])
59
+
60
+ // Visibility for the collapsed trigger: show it at the very top, at the very
61
+ // bottom, or while scrolling UP. Hide it while scrolling down, and — once you
62
+ // scroll up and stop — auto-hide after a short idle so it doesn't linger over
63
+ // the content. (The expanded row ignores all this; see the `open` guards.)
64
+ useEffect(() => {
65
+ if (open) return // While interacting, stay put — don't react to scroll.
66
+ let lastY = window.scrollY
67
+ let idle: ReturnType<typeof setTimeout> | undefined
68
+
69
+ const evaluate = () => {
70
+ clearTimeout(idle)
71
+ const y = window.scrollY
72
+ const max = document.documentElement.scrollHeight - window.innerHeight
73
+ const atTop = y <= 4
74
+ const atBottom = y >= max - 4
75
+ const scrollingUp = y < lastY
76
+ lastY = y
77
+
78
+ if (atTop || atBottom) {
79
+ setVisible(true) // Persistent edges — stay shown, no idle timer.
80
+ } else if (scrollingUp) {
81
+ setVisible(true)
82
+ // Scrolled up then stopped → hide after the scrolling settles.
83
+ idle = setTimeout(() => setVisible(false), 2000)
84
+ } else {
85
+ setVisible(false) // Scrolling down.
86
+ }
87
+ }
88
+
89
+ evaluate()
90
+ window.addEventListener('scroll', evaluate, { passive: true })
91
+ return () => {
92
+ clearTimeout(idle)
93
+ window.removeEventListener('scroll', evaluate)
94
+ }
95
+ }, [open])
96
+
97
+ const scrollTop = () => {
98
+ setOpen(false)
99
+ window.scrollTo({ top: 0, behavior: 'smooth' })
100
+ }
101
+
102
+ const openToc = () => {
103
+ setOpen(false)
104
+ setTocOpen(true)
105
+ }
106
+
107
+ const openFind = () => {
108
+ setOpen(false)
109
+ setFindOpen(true)
110
+ }
111
+
112
+ return (
113
+ <>
114
+ {open ? (
115
+ /* Expanded: full-width deck matching the global bar, stacked above it. */
116
+ <nav
117
+ ref={navRef}
118
+ className={cn(
119
+ 'fixed inset-x-3 bottom-[calc(var(--mobile-bar-height)+env(safe-area-inset-bottom)+1.5rem)] z-80 md:hidden',
120
+ 'flex items-stretch gap-1 rounded-2xl border bg-sidebar/95 px-1.5 py-1 shadow-lg backdrop-blur',
121
+ )}
122
+ aria-label="Действия страницы"
123
+ >
124
+ <button type="button" onClick={scrollTop} className={tab} aria-label="Наверх">
125
+ <ArrowUp className="size-5" />
126
+ <span>Наверх</span>
127
+ </button>
128
+ <button type="button" onClick={openFind} className={tab} aria-label="Найти на странице">
129
+ <SearchIcon className="size-5" />
130
+ <span>Найти</span>
131
+ </button>
132
+ <button type="button" onClick={openToc} className={tab} aria-label="Содержание">
133
+ <List className="size-5" />
134
+ <span>Содержание</span>
135
+ </button>
136
+ <button
137
+ type="button"
138
+ onClick={() => setOpen(false)}
139
+ className={tab}
140
+ aria-label="Скрыть"
141
+ aria-expanded
142
+ >
143
+ <ChevronRight className="size-5" />
144
+ <span>Скрыть</span>
145
+ </button>
146
+ </nav>
147
+ ) : (
148
+ /* Collapsed: a small centred pill, shown per the scroll rules above. */
149
+ <div
150
+ className={cn(
151
+ 'fixed inset-x-3 bottom-[calc(var(--mobile-bar-height)+env(safe-area-inset-bottom)+1.5rem)] z-80 flex justify-end md:hidden',
152
+ 'transition-opacity duration-200',
153
+ visible ? 'opacity-100' : 'pointer-events-none opacity-0',
154
+ )}
155
+ >
156
+ <button
157
+ type="button"
158
+ onClick={() => setOpen(true)}
159
+ className="flex items-center gap-1 rounded-xl border bg-sidebar/95 px-3 py-2 text-[0.6875rem] font-medium text-muted-foreground shadow-md backdrop-blur transition-colors hover:text-foreground"
160
+ aria-label="Действия страницы"
161
+ aria-expanded={false}
162
+ >
163
+ <ChevronLeft className="size-3.5" />
164
+ На странице
165
+ </button>
166
+ </div>
167
+ )}
168
+
169
+ {tocOpen && <TocSheet shown={shown} onClose={() => setTocOpen(false)} />}
170
+ {findOpen && <FindOnPage onClose={() => setFindOpen(false)} />}
171
+ </>
172
+ )
173
+ }
174
+
175
+ /**
176
+ * Bottom-sheet TOC modal. Slides up from the bottom, backdrop dismiss + Escape +
177
+ * body-scroll-lock, mirroring the Search modal conventions. Tapping a heading
178
+ * link closes the sheet (the link's hash navigation scrolls to the heading).
179
+ */
180
+ function TocSheet({ shown, onClose }: { shown: Heading[]; onClose: () => void }) {
181
+ useEffect(() => {
182
+ const onKey = (e: KeyboardEvent) => {
183
+ if (e.key === 'Escape') onClose()
184
+ }
185
+ document.addEventListener('keydown', onKey)
186
+ const prev = document.body.style.overflow
187
+ document.body.style.overflow = 'hidden'
188
+ return () => {
189
+ document.removeEventListener('keydown', onKey)
190
+ document.body.style.overflow = prev
191
+ }
192
+ }, [onClose])
193
+
194
+ return (
195
+ <div
196
+ className="fixed inset-0 z-[200] flex flex-col justify-end bg-background/40 backdrop-blur-sm md:hidden"
197
+ onClick={onClose}
198
+ role="dialog"
199
+ aria-modal="true"
200
+ aria-label="Содержание"
201
+ >
202
+ <div
203
+ className="flex h-[75vh] flex-col overflow-hidden rounded-t-2xl border-t bg-popover pb-[env(safe-area-inset-bottom)]"
204
+ onClick={(e) => e.stopPropagation()}
205
+ >
206
+ <div className="flex shrink-0 items-center justify-between border-b px-4 py-3">
207
+ <span className="text-sm font-semibold text-foreground">Содержание</span>
208
+ <button
209
+ type="button"
210
+ onClick={onClose}
211
+ className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
212
+ aria-label="Закрыть"
213
+ >
214
+ <X className="size-5" />
215
+ </button>
216
+ </div>
217
+ {/* onClick on the list closes after a heading is tapped (links bubble up). */}
218
+ <div className="min-h-0 flex-1 overflow-y-auto px-4 pt-3 pb-6 text-sm" onClick={onClose}>
219
+ <TocLinks shown={shown} />
220
+ </div>
221
+ </div>
222
+ </div>
223
+ )
224
+ }
@@ -0,0 +1,124 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { useNavigate } from '@remix-run/react'
3
+ import { Check, ChevronsUpDown } from 'lucide-react'
4
+
5
+ import { getProjects, getProject, type Project } from '~/lib/projects'
6
+ import { t } from '~/lib/site'
7
+ import { cn } from '~/lib/utils'
8
+
9
+ /** Logo + height tuned to dodge the unlayered `img{height:auto}` cascade (inline style wins). */
10
+ function ProjectLogo({ project, className }: { project: Project; className?: string }) {
11
+ return (
12
+ <img
13
+ src={project.logo}
14
+ alt=""
15
+ aria-hidden
16
+ width={20}
17
+ height={20}
18
+ style={{ height: 20, width: 20 }}
19
+ className={cn('shrink-0 rounded', className)}
20
+ />
21
+ )
22
+ }
23
+
24
+ interface Props {
25
+ /** Id of the active project, or null when none is selected (e.g. `/`). */
26
+ activeId: string | null
27
+ className?: string
28
+ }
29
+
30
+ /**
31
+ * Project selector shown in the top bar, just right of the logo. Displays the
32
+ * current project (logo + name); clicking opens a dropdown of all projects, each
33
+ * with its logo. Choosing one navigates to that project's landing doc — which
34
+ * re-derives the active project and swaps the sidebar to its menu.
35
+ */
36
+ export default function ProjectSwitcher({ activeId, className }: Props) {
37
+ const navigate = useNavigate()
38
+ const projects = getProjects()
39
+ // null when no project is selected (e.g. on `/`) — the trigger shows a
40
+ // neutral "choose a project" label instead of defaulting to the first.
41
+ const active = activeId ? (getProject(activeId) ?? null) : null
42
+
43
+ const [open, setOpen] = useState(false)
44
+ const rootRef = useRef<HTMLDivElement>(null)
45
+
46
+ // Close on outside click or Escape.
47
+ useEffect(() => {
48
+ if (!open) return
49
+ const onDown = (e: MouseEvent) => {
50
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false)
51
+ }
52
+ const onKey = (e: KeyboardEvent) => {
53
+ if (e.key === 'Escape') setOpen(false)
54
+ }
55
+ document.addEventListener('mousedown', onDown)
56
+ document.addEventListener('keydown', onKey)
57
+ return () => {
58
+ document.removeEventListener('mousedown', onDown)
59
+ document.removeEventListener('keydown', onKey)
60
+ }
61
+ }, [open])
62
+
63
+ const select = (p: Project) => {
64
+ setOpen(false)
65
+ if (p.id !== active?.id) navigate(p.landing)
66
+ }
67
+
68
+ return (
69
+ <div ref={rootRef} className={cn('relative', className)}>
70
+ <button
71
+ type="button"
72
+ aria-haspopup="listbox"
73
+ aria-expanded={open}
74
+ onClick={() => setOpen((o) => !o)}
75
+ className={cn(
76
+ 'flex items-center gap-2 rounded-md border bg-background px-2 py-1.5 text-sm',
77
+ 'transition-colors hover:bg-sidebar-accent',
78
+ )}
79
+ >
80
+ {active ? (
81
+ <>
82
+ <ProjectLogo project={active} />
83
+ <span className="max-w-[10rem] truncate font-medium text-foreground">{active.name}</span>
84
+ </>
85
+ ) : (
86
+ <span className="max-w-[10rem] truncate font-medium text-muted-foreground">
87
+ {t('selectProject')}
88
+ </span>
89
+ )}
90
+ <ChevronsUpDown className="size-3.5 shrink-0 text-muted-foreground" />
91
+ </button>
92
+
93
+ {open && (
94
+ <ul
95
+ role="listbox"
96
+ className={cn(
97
+ 'absolute left-0 top-[calc(100%+4px)] z-50 min-w-[14rem] overflow-hidden rounded-md border bg-popover p-1 shadow-md',
98
+ )}
99
+ >
100
+ {projects.map((p) => {
101
+ const isActive = p.id === active?.id
102
+ return (
103
+ <li key={p.id} role="option" aria-selected={isActive}>
104
+ <button
105
+ type="button"
106
+ onClick={() => select(p)}
107
+ className={cn(
108
+ 'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm',
109
+ 'transition-colors hover:bg-sidebar-accent',
110
+ isActive && 'bg-sidebar-accent',
111
+ )}
112
+ >
113
+ <ProjectLogo project={p} />
114
+ <span className="min-w-0 flex-1 truncate text-foreground">{p.name}</span>
115
+ {isActive && <Check className="size-4 shrink-0 text-foreground" />}
116
+ </button>
117
+ </li>
118
+ )
119
+ })}
120
+ </ul>
121
+ )}
122
+ </div>
123
+ )
124
+ }