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.
- package/LICENSE +21 -0
- package/README.md +61 -0
- package/app/components/CanvasMount.tsx +62 -0
- package/app/components/CodeWrapToggle.tsx +78 -0
- package/app/components/FindOnPage.tsx +224 -0
- package/app/components/MobileBottomBar.tsx +93 -0
- package/app/components/MobileProjectsPanel.tsx +113 -0
- package/app/components/PageFloatingMenu.tsx +224 -0
- package/app/components/ProjectSwitcher.tsx +124 -0
- package/app/components/Search.tsx +930 -0
- package/app/components/ShortcutsHelp.tsx +113 -0
- package/app/components/Sidebar.tsx +1049 -0
- package/app/components/TabBar.tsx +227 -0
- package/app/components/Toc.tsx +129 -0
- package/app/components/TopBar.tsx +74 -0
- package/app/components/theme-toggle.tsx +71 -0
- package/app/components/ui/button.tsx +56 -0
- package/app/components/ui/card.tsx +55 -0
- package/app/components/ui/dropdown-menu.tsx +156 -0
- package/app/components/ui/input.tsx +21 -0
- package/app/entry.client.tsx +12 -0
- package/app/entry.server.tsx +155 -0
- package/app/generated/site.ts +19 -0
- package/app/generated/slots.ts +10 -0
- package/app/generated/theme.generated.css +60 -0
- package/app/lib/config/config.server.ts +50 -0
- package/app/lib/config/defaults.ts +120 -0
- package/app/lib/config/load.ts +82 -0
- package/app/lib/config/schema.ts +131 -0
- package/app/lib/config/site.ts +43 -0
- package/app/lib/content.server.ts +105 -0
- package/app/lib/projects.ts +86 -0
- package/app/lib/sidebar.server.ts +113 -0
- package/app/lib/site.ts +27 -0
- package/app/lib/slots.tsx +33 -0
- package/app/lib/tabs.tsx +128 -0
- package/app/lib/useKeyboardShortcuts.ts +149 -0
- package/app/lib/utils.ts +17 -0
- package/app/root.tsx +171 -0
- package/app/routes/$.tsx +158 -0
- package/app/routes/_index.tsx +60 -0
- package/app/styles/app.css +461 -0
- package/app/styles/obsidian.css +83 -0
- package/app/styles/tailwind.css +227 -0
- package/cli.js +119 -0
- package/components.json +21 -0
- package/dist/config.mjs +87 -0
- package/dist/generate-content.mjs +1665 -0
- package/package.json +112 -0
- package/scripts/build-search-index.ts +129 -0
- package/scripts/canonical.ts +34 -0
- package/scripts/canvas-to-md.ts +73 -0
- package/scripts/compile.ts +242 -0
- package/scripts/emit-config.ts +163 -0
- package/scripts/generate-content.ts +197 -0
- package/scripts/obsidian/files.ts +222 -0
- package/scripts/obsidian/fs.ts +34 -0
- package/scripts/obsidian/generate.ts +36 -0
- package/scripts/obsidian/html.ts +17 -0
- package/scripts/obsidian/logger.ts +10 -0
- package/scripts/obsidian/markdown.ts +56 -0
- package/scripts/obsidian/obsidian.ts +229 -0
- package/scripts/obsidian/path.ts +60 -0
- package/scripts/obsidian/rehype.ts +60 -0
- package/scripts/obsidian/remark.ts +712 -0
- package/scripts/obsidian/types.ts +31 -0
- package/vite.config.ts +62 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* App-wide keyboard shortcuts: single keys (Gmail/GitHub-style — `l`, `w`) and
|
|
5
|
+
* two-key sequences (Gmail-style `g` then `c`). These live alongside the
|
|
6
|
+
* existing Cmd/Ctrl chords (Cmd+K search, Cmd+P file-open); those keep their own
|
|
7
|
+
* listeners and pass straight through this one untouched.
|
|
8
|
+
*
|
|
9
|
+
* Why single keys are safe: browsers reserve almost nothing for bare letters as
|
|
10
|
+
* long as focus isn't in a text field. The handler's first job is to bail when
|
|
11
|
+
* the user is typing (input/textarea/contenteditable) or when any modifier is
|
|
12
|
+
* held — that lets Cmd+K et al. fall through and stops shortcuts from firing
|
|
13
|
+
* mid-typing. Sequences never collide with the browser at all (it has no notion
|
|
14
|
+
* of "g then c").
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type ShortcutGroup = 'Дерево' | 'Вкладки' | 'Навигация'
|
|
18
|
+
|
|
19
|
+
/** A binding the caller wires to behavior, plus the metadata the `?` overlay shows. */
|
|
20
|
+
export type Shortcut = {
|
|
21
|
+
/** Single key (e.g. 'l') OR a two-key sequence (e.g. ['g', 'c']). Compared case-insensitively. */
|
|
22
|
+
keys: string | [string, string]
|
|
23
|
+
/** What it does — shown in the cheatsheet. */
|
|
24
|
+
label: string
|
|
25
|
+
/** Cheatsheet section. */
|
|
26
|
+
group: ShortcutGroup
|
|
27
|
+
/** Handler. Only invoked when focus is outside text fields and no modifier is held. */
|
|
28
|
+
run: () => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A display-only entry for shortcuts owned elsewhere (the existing Cmd+K / Cmd+P
|
|
33
|
+
* chords, the Shift+Enter row action) so the `?` overlay can list everything in
|
|
34
|
+
* one place without those handlers being re-registered here.
|
|
35
|
+
*/
|
|
36
|
+
export type ShortcutInfo = {
|
|
37
|
+
/** Human-readable key hint, e.g. '⌘K', 'Shift+Enter'. */
|
|
38
|
+
hint: string
|
|
39
|
+
label: string
|
|
40
|
+
group: ShortcutGroup
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The full cheatsheet, hand-maintained as the single source of truth for what the
|
|
45
|
+
* `?` overlay shows. Handlers live with their components (Sidebar, TabBar) via
|
|
46
|
+
* useKeyboardShortcuts; this list just describes every binding in one place,
|
|
47
|
+
* including the Cmd/Ctrl chords and the Shift+Enter row action owned elsewhere.
|
|
48
|
+
* Keep in sync when adding a binding.
|
|
49
|
+
*/
|
|
50
|
+
export const ALL_SHORTCUTS: ShortcutInfo[] = [
|
|
51
|
+
{ hint: '⌘/Ctrl K', label: 'Поиск по содержимому', group: 'Навигация' },
|
|
52
|
+
{ hint: '?', label: 'Показать список горячих клавиш', group: 'Навигация' },
|
|
53
|
+
{ hint: '⌘/Ctrl P', label: 'Поиск файла по имени', group: 'Дерево' },
|
|
54
|
+
{ hint: 'l', label: 'Найти текущую страницу в дереве', group: 'Дерево' },
|
|
55
|
+
{ hint: 'c', label: 'Свернуть все папки', group: 'Дерево' },
|
|
56
|
+
{ hint: 'w', label: 'Закрыть текущую вкладку', group: 'Вкладки' },
|
|
57
|
+
{ hint: 'Shift+Enter', label: 'Файл — открыть в новой вкладке; папку — развернуть рекурсивно', group: 'Дерево' },
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
/** True when focus is in an editable surface — single-key shortcuts must not fire there. */
|
|
61
|
+
function isTypingTarget(el: EventTarget | null): boolean {
|
|
62
|
+
if (!(el instanceof HTMLElement)) return false
|
|
63
|
+
const tag = el.tagName
|
|
64
|
+
return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** How long (ms) the first key of a sequence stays "armed" before the buffer clears. */
|
|
68
|
+
const SEQUENCE_WINDOW_MS = 1000
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Register the given shortcuts on `window`. Re-registers when `shortcuts`
|
|
72
|
+
* changes identity, so callers should memoize or accept the (cheap) churn.
|
|
73
|
+
*/
|
|
74
|
+
export function useKeyboardShortcuts(shortcuts: Shortcut[]): void {
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
// Split into single-key and sequence bindings, keyed lowercase.
|
|
77
|
+
const singles = new Map<string, Shortcut>()
|
|
78
|
+
const sequences = new Map<string, Map<string, Shortcut>>() // firstKey -> (secondKey -> shortcut)
|
|
79
|
+
for (const s of shortcuts) {
|
|
80
|
+
if (typeof s.keys === 'string') {
|
|
81
|
+
singles.set(s.keys.toLowerCase(), s)
|
|
82
|
+
} else {
|
|
83
|
+
const [a, b] = s.keys
|
|
84
|
+
const branch = sequences.get(a.toLowerCase()) ?? new Map()
|
|
85
|
+
branch.set(b.toLowerCase(), s)
|
|
86
|
+
sequences.set(a.toLowerCase(), branch)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let pending: string | null = null
|
|
91
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
92
|
+
const clearPending = () => {
|
|
93
|
+
pending = null
|
|
94
|
+
if (timer) {
|
|
95
|
+
clearTimeout(timer)
|
|
96
|
+
timer = null
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const onKey = (e: KeyboardEvent) => {
|
|
101
|
+
// Let Cmd/Ctrl/Alt chords (search, file-open, browser shortcuts) pass through,
|
|
102
|
+
// and never fire while the user is typing. Checked first, always.
|
|
103
|
+
if (e.metaKey || e.ctrlKey || e.altKey) {
|
|
104
|
+
clearPending()
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
if (isTypingTarget(e.target)) {
|
|
108
|
+
clearPending()
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const key = e.key.toLowerCase()
|
|
113
|
+
|
|
114
|
+
// Resolving the second key of an armed sequence takes priority.
|
|
115
|
+
if (pending) {
|
|
116
|
+
const branch = sequences.get(pending)
|
|
117
|
+
clearPending()
|
|
118
|
+
const match = branch?.get(key)
|
|
119
|
+
if (match) {
|
|
120
|
+
e.preventDefault()
|
|
121
|
+
match.run()
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
// Not a valid continuation — fall through so this key can still be a
|
|
125
|
+
// single-key shortcut on its own.
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Arm a new sequence if this key starts one.
|
|
129
|
+
if (sequences.has(key)) {
|
|
130
|
+
pending = key
|
|
131
|
+
timer = setTimeout(clearPending, SEQUENCE_WINDOW_MS)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Single-key shortcut.
|
|
136
|
+
const single = singles.get(key)
|
|
137
|
+
if (single) {
|
|
138
|
+
e.preventDefault()
|
|
139
|
+
single.run()
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
window.addEventListener('keydown', onKey)
|
|
144
|
+
return () => {
|
|
145
|
+
window.removeEventListener('keydown', onKey)
|
|
146
|
+
clearPending()
|
|
147
|
+
}
|
|
148
|
+
}, [shortcuts])
|
|
149
|
+
}
|
package/app/lib/utils.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { clsx, type ClassValue } from 'clsx'
|
|
2
|
+
import { twMerge } from 'tailwind-merge'
|
|
3
|
+
|
|
4
|
+
export function cn(...inputs: ClassValue[]) {
|
|
5
|
+
return twMerge(clsx(inputs))
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// MoSCoW priority tags, in descending priority. Stored alongside type tags
|
|
9
|
+
// (user-story, feature, …) in a doc's `tags` frontmatter array.
|
|
10
|
+
export const PRIORITY_TAGS = ['must-have', 'should-have', 'could-have', 'wont-have'] as const
|
|
11
|
+
export type PriorityTag = (typeof PRIORITY_TAGS)[number]
|
|
12
|
+
|
|
13
|
+
/** The MoSCoW priority tag from a doc's tags, or null if none is present. */
|
|
14
|
+
export function getPriority(tags: unknown): PriorityTag | null {
|
|
15
|
+
if (!Array.isArray(tags)) return null
|
|
16
|
+
return (PRIORITY_TAGS.find((p) => tags.includes(p)) as PriorityTag | undefined) ?? null
|
|
17
|
+
}
|
package/app/root.tsx
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Links,
|
|
3
|
+
Meta,
|
|
4
|
+
Outlet,
|
|
5
|
+
Scripts,
|
|
6
|
+
ScrollRestoration,
|
|
7
|
+
useLoaderData,
|
|
8
|
+
useLocation,
|
|
9
|
+
} from '@remix-run/react'
|
|
10
|
+
import { useEffect, useState } from 'react'
|
|
11
|
+
import type { LinksFunction, LoaderFunctionArgs } from '@remix-run/node'
|
|
12
|
+
|
|
13
|
+
import { buildSidebar, flattenSidebar } from '~/lib/sidebar.server'
|
|
14
|
+
import { getActiveProjectId } from '~/lib/projects'
|
|
15
|
+
import { site } from '~/lib/site'
|
|
16
|
+
import Sidebar from '~/components/Sidebar'
|
|
17
|
+
import { TopBar } from '~/lib/slots'
|
|
18
|
+
import TabBar from '~/components/TabBar'
|
|
19
|
+
import MobileBottomBar from '~/components/MobileBottomBar'
|
|
20
|
+
import MobileProjectsPanel from '~/components/MobileProjectsPanel'
|
|
21
|
+
import { ShortcutsHelp } from '~/components/ShortcutsHelp'
|
|
22
|
+
import { TabsProvider } from '~/lib/tabs'
|
|
23
|
+
import { cn } from '~/lib/utils'
|
|
24
|
+
import { themeInitScript } from '~/components/theme-toggle'
|
|
25
|
+
import { sidebarWidthInitScript } from '~/components/Sidebar'
|
|
26
|
+
|
|
27
|
+
import tailwindStyles from '~/styles/tailwind.css?url'
|
|
28
|
+
import appStyles from '~/styles/app.css?url'
|
|
29
|
+
import katexStyles from 'katex/dist/katex.min.css?url'
|
|
30
|
+
// Generated from docs.config.ts `theme` — loaded LAST so its :root/.dark token
|
|
31
|
+
// overrides win over the defaults declared in tailwind.css.
|
|
32
|
+
import themeStyles from '~/generated/theme.generated.css?url'
|
|
33
|
+
|
|
34
|
+
export const links: LinksFunction = () => [
|
|
35
|
+
{ rel: 'stylesheet', href: tailwindStyles },
|
|
36
|
+
{ rel: 'stylesheet', href: appStyles },
|
|
37
|
+
{ rel: 'stylesheet', href: themeStyles },
|
|
38
|
+
{ rel: 'stylesheet', href: katexStyles },
|
|
39
|
+
{ rel: 'icon', type: 'image/svg+xml', href: site.favicon },
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
// Root loader runs on every navigation; the active project is derived from the
|
|
43
|
+
// request URL and only that project's sidebar tree is built, so the nav persists
|
|
44
|
+
// across client-side navigations and swaps to match the project being viewed.
|
|
45
|
+
export async function loader({ request }: LoaderFunctionArgs) {
|
|
46
|
+
const projectId = getActiveProjectId(new URL(request.url).pathname)
|
|
47
|
+
// No active project (e.g. the home page) → no sidebar tree to build.
|
|
48
|
+
const sidebar = projectId ? flattenSidebar(await buildSidebar(projectId)) : null
|
|
49
|
+
return { sidebar, projectId }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default function App() {
|
|
53
|
+
const { sidebar, projectId } = useLoaderData<typeof loader>()
|
|
54
|
+
const location = useLocation()
|
|
55
|
+
const [menuOpen, setMenuOpen] = useState(false)
|
|
56
|
+
const [projectsOpen, setProjectsOpen] = useState(false)
|
|
57
|
+
|
|
58
|
+
// Close any open mobile overlay whenever navigation happens (link/row click).
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
setMenuOpen(false)
|
|
61
|
+
setProjectsOpen(false)
|
|
62
|
+
}, [location.pathname])
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<html lang={site.lang} className={site.defaultTheme === 'light' ? undefined : 'dark'}>
|
|
66
|
+
<head>
|
|
67
|
+
<meta charSet="utf-8" />
|
|
68
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
69
|
+
<Meta />
|
|
70
|
+
<Links />
|
|
71
|
+
{/* Set the theme class before paint to avoid a flash of the wrong theme. */}
|
|
72
|
+
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
|
|
73
|
+
{/* Apply the persisted sidebar width before paint to avoid a layout shift. */}
|
|
74
|
+
<script dangerouslySetInnerHTML={{ __html: sidebarWidthInitScript }} />
|
|
75
|
+
</head>
|
|
76
|
+
<body>
|
|
77
|
+
<TabsProvider projectId={projectId}>
|
|
78
|
+
{/* Desktop top bar with theme toggle: in-flow at the top (takes layout
|
|
79
|
+
space), floats and hides on scroll-down / shows on scroll-up. */}
|
|
80
|
+
<TopBar projectId={projectId} />
|
|
81
|
+
|
|
82
|
+
{/* Two-row grid: row 1 holds the tab bar (over the content columns only),
|
|
83
|
+
row 2 holds the doc content + TOC. The sidebar spans both rows so it
|
|
84
|
+
stays full-height. When no tabs are open the TabBar renders nothing, so
|
|
85
|
+
its 0-height row collapses and the layout matches the no-tabs case.
|
|
86
|
+
With no active project (e.g. `/`) there's no sidebar, so the leading
|
|
87
|
+
sidebar column is dropped and content starts at the first column. */}
|
|
88
|
+
<div
|
|
89
|
+
className={cn(
|
|
90
|
+
'grid min-h-screen grid-cols-1 grid-rows-[auto_minmax(0,1fr)]',
|
|
91
|
+
// No sidebar (e.g. `/`): a single full-width content column, no
|
|
92
|
+
// reserved TOC column — the page has no TOC, so leaving that column
|
|
93
|
+
// in place would push the centered content left of true center.
|
|
94
|
+
sidebar
|
|
95
|
+
? 'md:grid-cols-[var(--sidebar-width)_minmax(0,1fr)] xl:grid-cols-[var(--sidebar-width)_minmax(0,1fr)_var(--toc-width,250px)]'
|
|
96
|
+
: 'md:grid-cols-[minmax(0,1fr)]',
|
|
97
|
+
)}
|
|
98
|
+
>
|
|
99
|
+
{/* key on projectId: the headless-tree instance caches expanded/registered
|
|
100
|
+
items and won't re-sync to a new `data` map on its own, so switching
|
|
101
|
+
projects would leave the tree showing stale state. Remounting on
|
|
102
|
+
project change rebuilds it cleanly with the new data + initial expand. */}
|
|
103
|
+
{sidebar && (
|
|
104
|
+
<Sidebar
|
|
105
|
+
key={projectId}
|
|
106
|
+
data={sidebar}
|
|
107
|
+
currentPath={location.pathname}
|
|
108
|
+
open={menuOpen}
|
|
109
|
+
className="md:col-start-1 md:row-span-2 md:row-start-1"
|
|
110
|
+
/>
|
|
111
|
+
)}
|
|
112
|
+
{/* Tab strip: row 1, content column only (col-start-2) — not over the
|
|
113
|
+
sidebar (col 1) nor the TOC (col 3 at xl). The grid CELL is the
|
|
114
|
+
sticky element (top-11, just below the TopBar): a sticky grid item's
|
|
115
|
+
travel range is the whole grid container's box (tall), not its own
|
|
116
|
+
row — so it stays pinned while content scrolls. Making the inner
|
|
117
|
+
strip sticky instead would fail, since its parent cell is only as
|
|
118
|
+
tall as the strip. */}
|
|
119
|
+
<div
|
|
120
|
+
className={cn(
|
|
121
|
+
'sticky top-11 z-40 md:row-start-1',
|
|
122
|
+
sidebar ? 'md:col-start-2' : 'md:col-start-1',
|
|
123
|
+
)}
|
|
124
|
+
>
|
|
125
|
+
<TabBar />
|
|
126
|
+
</div>
|
|
127
|
+
{/* Content lands in row 2, right of the sidebar. The route ($.tsx) emits
|
|
128
|
+
<main> + <Toc> as two cells; pinning the wrapper here keeps them in the
|
|
129
|
+
content/TOC columns of row 2 regardless of grid auto-placement order. */}
|
|
130
|
+
<div className="contents md:[&>*]:row-start-2">
|
|
131
|
+
<Outlet />
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</TabsProvider>
|
|
135
|
+
|
|
136
|
+
{/* Fullscreen mobile project switcher (also houses the theme toggle).
|
|
137
|
+
Rendered before the bar so the floating bar stacks above it and its
|
|
138
|
+
Projects tab stays tappable to toggle the panel shut. */}
|
|
139
|
+
<MobileProjectsPanel
|
|
140
|
+
activeId={projectId}
|
|
141
|
+
open={projectsOpen}
|
|
142
|
+
onClose={() => setProjectsOpen(false)}
|
|
143
|
+
/>
|
|
144
|
+
|
|
145
|
+
{/* Mobile-only floating bottom navigation: Home · Проекты · Файлы
|
|
146
|
+
(sidebar) · Поиск. No top navbar on mobile. The Files tab is
|
|
147
|
+
disabled with no active project (e.g. `/`), where there's no tree. */}
|
|
148
|
+
<MobileBottomBar
|
|
149
|
+
dirsOpen={menuOpen}
|
|
150
|
+
projectsOpen={projectsOpen}
|
|
151
|
+
filesEnabled={!!projectId}
|
|
152
|
+
onToggleDirs={() => {
|
|
153
|
+
setMenuOpen((o) => !o)
|
|
154
|
+
setProjectsOpen(false)
|
|
155
|
+
}}
|
|
156
|
+
onToggleProjects={() => {
|
|
157
|
+
setProjectsOpen((o) => !o)
|
|
158
|
+
setMenuOpen(false)
|
|
159
|
+
}}
|
|
160
|
+
/>
|
|
161
|
+
|
|
162
|
+
{/* `?` cheatsheet for all keyboard shortcuts (single source: ALL_SHORTCUTS).
|
|
163
|
+
Owns its own open/close; renders nothing until triggered. */}
|
|
164
|
+
<ShortcutsHelp />
|
|
165
|
+
|
|
166
|
+
<ScrollRestoration />
|
|
167
|
+
<Scripts />
|
|
168
|
+
</body>
|
|
169
|
+
</html>
|
|
170
|
+
)
|
|
171
|
+
}
|
package/app/routes/$.tsx
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import { json, redirect } from '@remix-run/node'
|
|
3
|
+
import { useLoaderData, useLocation } from '@remix-run/react'
|
|
4
|
+
import type { LoaderFunctionArgs, MetaFunction } from '@remix-run/node'
|
|
5
|
+
|
|
6
|
+
import { getDoc, resolvePermalink, getPermalinkForId } from '~/lib/content.server'
|
|
7
|
+
import { getPriority } from '~/lib/utils'
|
|
8
|
+
import { t, pageTitle } from '~/lib/site'
|
|
9
|
+
import { Toc, DocPageOverride } from '~/lib/slots'
|
|
10
|
+
import PageFloatingMenu from '~/components/PageFloatingMenu'
|
|
11
|
+
import CanvasMount from '~/components/CanvasMount'
|
|
12
|
+
import { CodeWrapToggle } from '~/components/CodeWrapToggle'
|
|
13
|
+
|
|
14
|
+
/** A colored MoSCoW priority pill, rendered inline next to the page title. */
|
|
15
|
+
function PriorityBadge({ priority }: { priority: string }) {
|
|
16
|
+
return (
|
|
17
|
+
<span className="priority-badge" data-priority={priority}>
|
|
18
|
+
{priority}
|
|
19
|
+
</span>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Render a single frontmatter value as text: arrays comma-joined, everything else stringified. */
|
|
24
|
+
function formatValue(value: unknown): string {
|
|
25
|
+
if (Array.isArray(value)) return value.map((v) => String(v)).join(', ')
|
|
26
|
+
if (value === null) return ''
|
|
27
|
+
if (typeof value === 'object') return JSON.stringify(value)
|
|
28
|
+
return String(value)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A generic key→value table of every frontmatter field, collapsed by default. */
|
|
32
|
+
function FrontmatterTable({ frontmatter }: { frontmatter: Record<string, unknown> }) {
|
|
33
|
+
const entries = Object.entries(frontmatter)
|
|
34
|
+
if (entries.length === 0) return null
|
|
35
|
+
return (
|
|
36
|
+
<details className="frontmatter">
|
|
37
|
+
<summary className="frontmatter__summary">{t('properties')}</summary>
|
|
38
|
+
<dl className="frontmatter__list">
|
|
39
|
+
{entries.map(([key, value]) => (
|
|
40
|
+
<div className="frontmatter__row" key={key}>
|
|
41
|
+
<dt className="frontmatter__key">{key}</dt>
|
|
42
|
+
<dd className="frontmatter__value">{formatValue(value)}</dd>
|
|
43
|
+
</div>
|
|
44
|
+
))}
|
|
45
|
+
</dl>
|
|
46
|
+
</details>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
|
51
|
+
// The splat param holds the full doc path, e.g. "krista/глоссарий/коллекция".
|
|
52
|
+
const slug = (params['*'] ?? '').replace(/\/$/, '')
|
|
53
|
+
|
|
54
|
+
// Permalinks make a doc's URL independent of its file name. The permalink is
|
|
55
|
+
// the canonical URL: if `slug` is a permalink we serve the doc in place; if
|
|
56
|
+
// it is the file-path URL of a doc that has a permalink, we 301 to the
|
|
57
|
+
// permalink so there is a single canonical address that survives renames.
|
|
58
|
+
const permalinkTarget = await resolvePermalink(slug)
|
|
59
|
+
const docId = permalinkTarget ?? slug
|
|
60
|
+
if (!permalinkTarget) {
|
|
61
|
+
const canonical = await getPermalinkForId(slug)
|
|
62
|
+
if (canonical && canonical !== slug) {
|
|
63
|
+
return redirect(`/${canonical}/`, 301)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const doc = await getDoc(docId)
|
|
68
|
+
if (!doc || doc.frontmatter.draft === true) {
|
|
69
|
+
throw new Response('Not Found', { status: 404 })
|
|
70
|
+
}
|
|
71
|
+
const title =
|
|
72
|
+
(doc.frontmatter.title as string | undefined) ??
|
|
73
|
+
slug.split('/').pop()?.replace(/-/g, ' ') ??
|
|
74
|
+
slug
|
|
75
|
+
return json({ doc, title })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const meta: MetaFunction<typeof loader> = ({ data }) => {
|
|
79
|
+
return [{ title: pageTitle(data?.title) }]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Route default export: render the user's `DocPage` override when one is
|
|
84
|
+
* configured (it receives the same loader data via `useLoaderData`), else the
|
|
85
|
+
* engine's default doc body below.
|
|
86
|
+
*/
|
|
87
|
+
export default function DocPageRoute() {
|
|
88
|
+
if (DocPageOverride) return <DocPageOverride />
|
|
89
|
+
return <EngineDocPage />
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function EngineDocPage() {
|
|
93
|
+
const { doc, title } = useLoaderData<typeof loader>()
|
|
94
|
+
const showToc = doc.frontmatter.tableOfContents !== false
|
|
95
|
+
const isCanvas = doc.html.includes('canvas-container')
|
|
96
|
+
const priority = getPriority(doc.frontmatter.tags)
|
|
97
|
+
|
|
98
|
+
// Scroll to the #hash heading after the doc renders. Remix's ScrollRestoration
|
|
99
|
+
// scrolls to the hash at the navigation's DOM commit, but when navigating
|
|
100
|
+
// *between* docs the new heading only exists once this component renders the
|
|
101
|
+
// new HTML — so that initial attempt misses and the first click appears to do
|
|
102
|
+
// nothing (a second click, now same-doc, works). Re-running keyed on doc.id +
|
|
103
|
+
// hash covers the cross-doc case. scrollIntoView honours the heading's
|
|
104
|
+
// scroll-margin-top, so it lands below the sticky bars.
|
|
105
|
+
const { hash } = useLocation()
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (!hash) return
|
|
108
|
+
const id = decodeURIComponent(hash.slice(1))
|
|
109
|
+
// Wait a frame so the freshly-committed doc HTML is in the DOM.
|
|
110
|
+
requestAnimationFrame(() => {
|
|
111
|
+
document.getElementById(id)?.scrollIntoView({ block: 'start', behavior: 'smooth' })
|
|
112
|
+
})
|
|
113
|
+
}, [doc.id, hash])
|
|
114
|
+
|
|
115
|
+
if (isCanvas) {
|
|
116
|
+
// Canvas pages span the content + TOC columns (everything right of the sidebar).
|
|
117
|
+
return (
|
|
118
|
+
<>
|
|
119
|
+
<main className="min-w-0 xl:col-span-2">
|
|
120
|
+
<article className="content">
|
|
121
|
+
<h1 className="title-row px-10">
|
|
122
|
+
{title}
|
|
123
|
+
{priority && <PriorityBadge priority={priority} />}
|
|
124
|
+
</h1>
|
|
125
|
+
<div className="px-10">
|
|
126
|
+
<FrontmatterTable frontmatter={doc.frontmatter} />
|
|
127
|
+
</div>
|
|
128
|
+
<div className="body" dangerouslySetInnerHTML={{ __html: doc.html }} />
|
|
129
|
+
</article>
|
|
130
|
+
</main>
|
|
131
|
+
<CanvasMount deps={doc.id} />
|
|
132
|
+
<CodeWrapToggle deps={doc.id} />
|
|
133
|
+
</>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<>
|
|
139
|
+
{/* On mobile the bottom padding clears the global bar PLUS the page actions
|
|
140
|
+
deck that opens above it (one bar-height row sitting 1.5rem higher), so
|
|
141
|
+
the last lines of content never hide behind either, open or closed. */}
|
|
142
|
+
<main className="mx-auto w-full min-w-0 max-w-[calc(720px+5rem)] px-10 pb-16 pt-8 max-md:px-4 max-md:pb-[calc(2*var(--mobile-bar-height)+env(safe-area-inset-bottom)+3rem)]">
|
|
143
|
+
<article className="content">
|
|
144
|
+
<h1 className="title-row">
|
|
145
|
+
{title}
|
|
146
|
+
{priority && <PriorityBadge priority={priority} />}
|
|
147
|
+
</h1>
|
|
148
|
+
<FrontmatterTable frontmatter={doc.frontmatter} />
|
|
149
|
+
<div className="body" dangerouslySetInnerHTML={{ __html: doc.html }} />
|
|
150
|
+
</article>
|
|
151
|
+
</main>
|
|
152
|
+
{showToc && <Toc headings={doc.headings} />}
|
|
153
|
+
{showToc && <PageFloatingMenu headings={doc.headings} />}
|
|
154
|
+
<CanvasMount deps={doc.id} />
|
|
155
|
+
<CodeWrapToggle deps={doc.id} />
|
|
156
|
+
</>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Link } from '@remix-run/react'
|
|
2
|
+
import type { MetaFunction } from '@remix-run/node'
|
|
3
|
+
|
|
4
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card'
|
|
5
|
+
import { getProjects } from '~/lib/projects'
|
|
6
|
+
import { site } from '~/lib/site'
|
|
7
|
+
import { HomeOverride } from '~/lib/slots'
|
|
8
|
+
|
|
9
|
+
export const meta: MetaFunction = () => [{ title: site.title }]
|
|
10
|
+
|
|
11
|
+
const projects = getProjects()
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Route default export: render the user's `Home` override when configured, else
|
|
15
|
+
* the engine's default project-card grid below.
|
|
16
|
+
*/
|
|
17
|
+
export default function IndexRoute() {
|
|
18
|
+
if (HomeOverride) return <HomeOverride />
|
|
19
|
+
return <EngineIndex />
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function EngineIndex() {
|
|
23
|
+
return (
|
|
24
|
+
<main className="mx-auto w-full min-w-0 max-w-[calc(720px+5rem)] px-10 pb-16 pt-8 max-md:px-4 max-md:pb-20">
|
|
25
|
+
<article className="content">
|
|
26
|
+
<h1>{site.title}</h1>
|
|
27
|
+
{site.description && (
|
|
28
|
+
<p className="mb-8 text-lg text-muted-foreground">{site.description}</p>
|
|
29
|
+
)}
|
|
30
|
+
|
|
31
|
+
<div className="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-4">
|
|
32
|
+
{projects.map((p) => (
|
|
33
|
+
<Link key={p.id} to={p.landing} className="no-underline">
|
|
34
|
+
<Card className="h-full gap-3 py-5 transition-colors hover:border-ring">
|
|
35
|
+
<CardHeader>
|
|
36
|
+
<div className="flex items-center gap-2.5">
|
|
37
|
+
{/* Inline size: app.css's unlayered `img{height:auto}` would beat h-* utilities. */}
|
|
38
|
+
<img
|
|
39
|
+
src={p.logo}
|
|
40
|
+
alt=""
|
|
41
|
+
aria-hidden
|
|
42
|
+
width={28}
|
|
43
|
+
height={28}
|
|
44
|
+
style={{ height: 28, width: 28 }}
|
|
45
|
+
className="shrink-0 rounded-md"
|
|
46
|
+
/>
|
|
47
|
+
<CardTitle className="text-base">{p.name}</CardTitle>
|
|
48
|
+
</div>
|
|
49
|
+
</CardHeader>
|
|
50
|
+
<CardContent>
|
|
51
|
+
<CardDescription>{p.description}</CardDescription>
|
|
52
|
+
</CardContent>
|
|
53
|
+
</Card>
|
|
54
|
+
</Link>
|
|
55
|
+
))}
|
|
56
|
+
</div>
|
|
57
|
+
</article>
|
|
58
|
+
</main>
|
|
59
|
+
)
|
|
60
|
+
}
|