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,156 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useId, useRef, useState } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
|
|
4
|
+
import { cn } from '~/lib/utils'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Lightweight dropdown menu — no extra dependency, mirrors the hand-rolled
|
|
8
|
+
* pattern used across the app (ProjectSwitcher, Sidebar RowMenu). A trigger
|
|
9
|
+
* button toggles a menu rendered in a portal with fixed positioning computed
|
|
10
|
+
* from the trigger rect, so it's never clipped by an ancestor's `overflow`.
|
|
11
|
+
*
|
|
12
|
+
* Closes on outside click, Escape, scroll, or resize. The menu is anchored to a
|
|
13
|
+
* corner of the trigger via `align`:
|
|
14
|
+
* - 'start' : menu's left edge aligns with the trigger's left edge
|
|
15
|
+
* - 'end' : menu's right edge aligns with the trigger's right edge
|
|
16
|
+
*
|
|
17
|
+
* Compose menu contents with <DropdownMenuItem>. `onSelect` fires then the menu
|
|
18
|
+
* closes automatically.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
interface MenuPos {
|
|
22
|
+
top: number
|
|
23
|
+
/** The CSS `left` for the menu box; combined with `translateX` for `end` align. */
|
|
24
|
+
left: number
|
|
25
|
+
align: 'start' | 'end'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Lets a <DropdownMenuItem> close its own parent menu after selecting. */
|
|
29
|
+
const MenuCloseContext = createContext<() => void>(() => {})
|
|
30
|
+
|
|
31
|
+
export function DropdownMenu({
|
|
32
|
+
trigger,
|
|
33
|
+
children,
|
|
34
|
+
align = 'start',
|
|
35
|
+
menuClassName,
|
|
36
|
+
className,
|
|
37
|
+
label,
|
|
38
|
+
}: {
|
|
39
|
+
/** Render the trigger; receives the live open state for styling. */
|
|
40
|
+
trigger: (state: { open: boolean }) => React.ReactNode
|
|
41
|
+
children: React.ReactNode
|
|
42
|
+
align?: 'start' | 'end'
|
|
43
|
+
/** Extra classes for the menu box. */
|
|
44
|
+
menuClassName?: string
|
|
45
|
+
/** Classes for the trigger button; a function receives the live open state. */
|
|
46
|
+
className?: string | ((state: { open: boolean }) => string)
|
|
47
|
+
/** Accessible label for the trigger button. */
|
|
48
|
+
label?: string
|
|
49
|
+
}) {
|
|
50
|
+
const [open, setOpen] = useState(false)
|
|
51
|
+
const [pos, setPos] = useState<MenuPos | null>(null)
|
|
52
|
+
const btnRef = useRef<HTMLButtonElement>(null)
|
|
53
|
+
const menuId = useId()
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!open) return
|
|
57
|
+
const close = () => setOpen(false)
|
|
58
|
+
const onKey = (e: KeyboardEvent) => {
|
|
59
|
+
if (e.key === 'Escape') setOpen(false)
|
|
60
|
+
}
|
|
61
|
+
// Close on any outside interaction; also on scroll/resize, since the fixed
|
|
62
|
+
// menu would otherwise drift away from its (scrolled) trigger.
|
|
63
|
+
document.addEventListener('mousedown', close)
|
|
64
|
+
document.addEventListener('keydown', onKey)
|
|
65
|
+
window.addEventListener('scroll', close, true)
|
|
66
|
+
window.addEventListener('resize', close)
|
|
67
|
+
return () => {
|
|
68
|
+
document.removeEventListener('mousedown', close)
|
|
69
|
+
document.removeEventListener('keydown', onKey)
|
|
70
|
+
window.removeEventListener('scroll', close, true)
|
|
71
|
+
window.removeEventListener('resize', close)
|
|
72
|
+
}
|
|
73
|
+
}, [open])
|
|
74
|
+
|
|
75
|
+
const toggle = (e: React.MouseEvent) => {
|
|
76
|
+
// Don't let an ancestor's onClick (e.g. a row navigation) fire too.
|
|
77
|
+
e.stopPropagation()
|
|
78
|
+
e.preventDefault()
|
|
79
|
+
if (!open && btnRef.current) {
|
|
80
|
+
const r = btnRef.current.getBoundingClientRect()
|
|
81
|
+
setPos({ top: r.bottom + 4, left: align === 'end' ? r.right : r.left, align })
|
|
82
|
+
}
|
|
83
|
+
setOpen((o) => !o)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<>
|
|
88
|
+
<button
|
|
89
|
+
ref={btnRef}
|
|
90
|
+
type="button"
|
|
91
|
+
aria-haspopup="menu"
|
|
92
|
+
aria-expanded={open}
|
|
93
|
+
aria-controls={open ? menuId : undefined}
|
|
94
|
+
aria-label={label}
|
|
95
|
+
onClick={toggle}
|
|
96
|
+
className={typeof className === 'function' ? className({ open }) : className}
|
|
97
|
+
>
|
|
98
|
+
{trigger({ open })}
|
|
99
|
+
</button>
|
|
100
|
+
{open &&
|
|
101
|
+
pos &&
|
|
102
|
+
createPortal(
|
|
103
|
+
<div
|
|
104
|
+
id={menuId}
|
|
105
|
+
role="menu"
|
|
106
|
+
// Block the document mousedown-to-close for clicks inside the menu.
|
|
107
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
108
|
+
onClick={(e) => e.stopPropagation()}
|
|
109
|
+
style={{
|
|
110
|
+
position: 'fixed',
|
|
111
|
+
top: pos.top,
|
|
112
|
+
left: pos.left,
|
|
113
|
+
transform: pos.align === 'end' ? 'translateX(-100%)' : undefined,
|
|
114
|
+
}}
|
|
115
|
+
className={cn(
|
|
116
|
+
'z-[200] min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
|
|
117
|
+
menuClassName,
|
|
118
|
+
)}
|
|
119
|
+
>
|
|
120
|
+
<MenuCloseContext.Provider value={() => setOpen(false)}>
|
|
121
|
+
{children}
|
|
122
|
+
</MenuCloseContext.Provider>
|
|
123
|
+
</div>,
|
|
124
|
+
document.body,
|
|
125
|
+
)}
|
|
126
|
+
</>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function DropdownMenuItem({
|
|
131
|
+
onSelect,
|
|
132
|
+
children,
|
|
133
|
+
className,
|
|
134
|
+
}: {
|
|
135
|
+
onSelect: () => void
|
|
136
|
+
children: React.ReactNode
|
|
137
|
+
className?: string
|
|
138
|
+
}) {
|
|
139
|
+
const close = useContext(MenuCloseContext)
|
|
140
|
+
return (
|
|
141
|
+
<button
|
|
142
|
+
type="button"
|
|
143
|
+
role="menuitem"
|
|
144
|
+
onClick={() => {
|
|
145
|
+
close()
|
|
146
|
+
onSelect()
|
|
147
|
+
}}
|
|
148
|
+
className={cn(
|
|
149
|
+
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-sidebar-accent',
|
|
150
|
+
className,
|
|
151
|
+
)}
|
|
152
|
+
>
|
|
153
|
+
{children}
|
|
154
|
+
</button>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
|
|
3
|
+
import { cn } from '~/lib/utils'
|
|
4
|
+
|
|
5
|
+
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
|
6
|
+
return (
|
|
7
|
+
<input
|
|
8
|
+
type={type}
|
|
9
|
+
data-slot="input"
|
|
10
|
+
className={cn(
|
|
11
|
+
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
|
12
|
+
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
|
13
|
+
'aria-invalid:ring-destructive/20 aria-invalid:border-destructive',
|
|
14
|
+
className,
|
|
15
|
+
)}
|
|
16
|
+
{...props}
|
|
17
|
+
/>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export { Input }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { RemixBrowser } from "@remix-run/react";
|
|
2
|
+
import { startTransition, StrictMode } from "react";
|
|
3
|
+
import { hydrateRoot } from "react-dom/client";
|
|
4
|
+
|
|
5
|
+
startTransition(() => {
|
|
6
|
+
hydrateRoot(
|
|
7
|
+
document,
|
|
8
|
+
<StrictMode>
|
|
9
|
+
<RemixBrowser />
|
|
10
|
+
</StrictMode>
|
|
11
|
+
);
|
|
12
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { PassThrough } from "node:stream";
|
|
2
|
+
|
|
3
|
+
import type { AppLoadContext, EntryContext } from "@remix-run/node";
|
|
4
|
+
import { createReadableStreamFromReadable } from "@remix-run/node";
|
|
5
|
+
import { RemixServer } from "@remix-run/react";
|
|
6
|
+
import * as isbotModule from "isbot";
|
|
7
|
+
import { renderToPipeableStream } from "react-dom/server";
|
|
8
|
+
|
|
9
|
+
const ABORT_DELAY = 5_000;
|
|
10
|
+
|
|
11
|
+
export default function handleRequest(
|
|
12
|
+
request: Request,
|
|
13
|
+
responseStatusCode: number,
|
|
14
|
+
responseHeaders: Headers,
|
|
15
|
+
remixContext: EntryContext,
|
|
16
|
+
loadContext: AppLoadContext
|
|
17
|
+
) {
|
|
18
|
+
let prohibitOutOfOrderStreaming =
|
|
19
|
+
isBotRequest(request.headers.get("user-agent")) || remixContext.isSpaMode;
|
|
20
|
+
|
|
21
|
+
return prohibitOutOfOrderStreaming
|
|
22
|
+
? handleBotRequest(
|
|
23
|
+
request,
|
|
24
|
+
responseStatusCode,
|
|
25
|
+
responseHeaders,
|
|
26
|
+
remixContext
|
|
27
|
+
)
|
|
28
|
+
: handleBrowserRequest(
|
|
29
|
+
request,
|
|
30
|
+
responseStatusCode,
|
|
31
|
+
responseHeaders,
|
|
32
|
+
remixContext
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// We have some Remix apps in the wild already running with isbot@3 so we need
|
|
37
|
+
// to maintain backwards compatibility even though we want new apps to use
|
|
38
|
+
// isbot@4. That way, we can ship this as a minor Semver update to @remix-run/dev.
|
|
39
|
+
function isBotRequest(userAgent: string | null) {
|
|
40
|
+
if (!userAgent) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// isbot >= 3.8.0, >4
|
|
45
|
+
if ("isbot" in isbotModule && typeof isbotModule.isbot === "function") {
|
|
46
|
+
return isbotModule.isbot(userAgent);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// isbot < 3.8.0
|
|
50
|
+
if ("default" in isbotModule && typeof isbotModule.default === "function") {
|
|
51
|
+
return isbotModule.default(userAgent);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function handleBotRequest(
|
|
58
|
+
request: Request,
|
|
59
|
+
responseStatusCode: number,
|
|
60
|
+
responseHeaders: Headers,
|
|
61
|
+
remixContext: EntryContext
|
|
62
|
+
) {
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
let shellRendered = false;
|
|
65
|
+
const { pipe, abort } = renderToPipeableStream(
|
|
66
|
+
<RemixServer
|
|
67
|
+
context={remixContext}
|
|
68
|
+
url={request.url}
|
|
69
|
+
abortDelay={ABORT_DELAY}
|
|
70
|
+
/>,
|
|
71
|
+
{
|
|
72
|
+
onAllReady() {
|
|
73
|
+
shellRendered = true;
|
|
74
|
+
const body = new PassThrough();
|
|
75
|
+
const stream = createReadableStreamFromReadable(body);
|
|
76
|
+
|
|
77
|
+
responseHeaders.set("Content-Type", "text/html");
|
|
78
|
+
|
|
79
|
+
resolve(
|
|
80
|
+
new Response(stream, {
|
|
81
|
+
headers: responseHeaders,
|
|
82
|
+
status: responseStatusCode,
|
|
83
|
+
})
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
pipe(body);
|
|
87
|
+
},
|
|
88
|
+
onShellError(error: unknown) {
|
|
89
|
+
reject(error);
|
|
90
|
+
},
|
|
91
|
+
onError(error: unknown) {
|
|
92
|
+
responseStatusCode = 500;
|
|
93
|
+
// Log streaming rendering errors from inside the shell. Don't log
|
|
94
|
+
// errors encountered during initial shell rendering since they'll
|
|
95
|
+
// reject and get logged in handleDocumentRequest.
|
|
96
|
+
if (shellRendered) {
|
|
97
|
+
console.error(error);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
setTimeout(abort, ABORT_DELAY);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function handleBrowserRequest(
|
|
108
|
+
request: Request,
|
|
109
|
+
responseStatusCode: number,
|
|
110
|
+
responseHeaders: Headers,
|
|
111
|
+
remixContext: EntryContext
|
|
112
|
+
) {
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
let shellRendered = false;
|
|
115
|
+
const { pipe, abort } = renderToPipeableStream(
|
|
116
|
+
<RemixServer
|
|
117
|
+
context={remixContext}
|
|
118
|
+
url={request.url}
|
|
119
|
+
abortDelay={ABORT_DELAY}
|
|
120
|
+
/>,
|
|
121
|
+
{
|
|
122
|
+
onShellReady() {
|
|
123
|
+
shellRendered = true;
|
|
124
|
+
const body = new PassThrough();
|
|
125
|
+
const stream = createReadableStreamFromReadable(body);
|
|
126
|
+
|
|
127
|
+
responseHeaders.set("Content-Type", "text/html");
|
|
128
|
+
|
|
129
|
+
resolve(
|
|
130
|
+
new Response(stream, {
|
|
131
|
+
headers: responseHeaders,
|
|
132
|
+
status: responseStatusCode,
|
|
133
|
+
})
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
pipe(body);
|
|
137
|
+
},
|
|
138
|
+
onShellError(error: unknown) {
|
|
139
|
+
reject(error);
|
|
140
|
+
},
|
|
141
|
+
onError(error: unknown) {
|
|
142
|
+
responseStatusCode = 500;
|
|
143
|
+
// Log streaming rendering errors from inside the shell. Don't log
|
|
144
|
+
// errors encountered during initial shell rendering since they'll
|
|
145
|
+
// reject and get logged in handleDocumentRequest.
|
|
146
|
+
if (shellRendered) {
|
|
147
|
+
console.error(error);
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
setTimeout(abort, ABORT_DELAY);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// AUTO-GENERATED by scripts/generate-content.ts — DO NOT EDIT.
|
|
2
|
+
// Seed (empty site) committed so the engine typechecks before any generate. At
|
|
3
|
+
// RUNTIME the Vite `~/generated/*` alias redirects to the user's cwd, so this
|
|
4
|
+
// engine copy is only the type/seed fallback — the user's generated site.ts wins.
|
|
5
|
+
import type { GeneratedSite } from '~/lib/config/site'
|
|
6
|
+
|
|
7
|
+
export const SITE: GeneratedSite = {
|
|
8
|
+
site: {
|
|
9
|
+
title: 'Docs',
|
|
10
|
+
description: '',
|
|
11
|
+
lang: 'ru',
|
|
12
|
+
favicon: '/favicon.svg',
|
|
13
|
+
logo: { light: '/logo-light.svg', dark: '/logo-dark.svg' },
|
|
14
|
+
defaultTheme: 'dark',
|
|
15
|
+
},
|
|
16
|
+
projects: [],
|
|
17
|
+
general: { enabled: false, id: 'general', name: 'Без проекта', logo: '/projects/general.svg', description: '' },
|
|
18
|
+
ui: {},
|
|
19
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// AUTO-GENERATED by scripts/generate-content.ts — DO NOT EDIT.
|
|
2
|
+
// Seed: every slot null (= engine default). `generate` rewrites this from the
|
|
3
|
+
// `components` overrides in docs.config.ts. Committed so a fresh checkout builds
|
|
4
|
+
// before the first generate. See app/lib/slots.tsx for the slot contract.
|
|
5
|
+
import type { ComponentType } from 'react'
|
|
6
|
+
|
|
7
|
+
export const Home: ComponentType<any> | null = null
|
|
8
|
+
export const DocPage: ComponentType<any> | null = null
|
|
9
|
+
export const TopBar: ComponentType<any> | null = null
|
|
10
|
+
export const Toc: ComponentType<any> | null = null
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/* AUTO-GENERATED by scripts/generate-content.ts from docs.config.ts theme. DO NOT EDIT. */
|
|
2
|
+
:root {
|
|
3
|
+
--brand: oklch(0.42 0.158 286);
|
|
4
|
+
--background: oklch(1 0 0);
|
|
5
|
+
--foreground: oklch(0.145 0 0);
|
|
6
|
+
--card: oklch(1 0 0);
|
|
7
|
+
--card-foreground: oklch(0.145 0 0);
|
|
8
|
+
--popover: oklch(1 0 0);
|
|
9
|
+
--popover-foreground: oklch(0.145 0 0);
|
|
10
|
+
--primary: var(--brand);
|
|
11
|
+
--primary-foreground: oklch(0.985 0 0);
|
|
12
|
+
--secondary: oklch(0.97 0 0);
|
|
13
|
+
--secondary-foreground: oklch(0.205 0 0);
|
|
14
|
+
--muted: oklch(0.97 0 0);
|
|
15
|
+
--muted-foreground: oklch(0.556 0 0);
|
|
16
|
+
--accent: oklch(0.97 0 0);
|
|
17
|
+
--accent-foreground: oklch(0.205 0 0);
|
|
18
|
+
--destructive: oklch(0.577 0.245 27.325);
|
|
19
|
+
--border: oklch(0.922 0 0);
|
|
20
|
+
--input: oklch(0.922 0 0);
|
|
21
|
+
--ring: oklch(0.708 0 0);
|
|
22
|
+
--sidebar: oklch(0.985 0 0);
|
|
23
|
+
--sidebar-foreground: oklch(0.145 0 0);
|
|
24
|
+
--sidebar-primary: oklch(0.205 0 0);
|
|
25
|
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
26
|
+
--sidebar-accent: oklch(0.97 0 0);
|
|
27
|
+
--sidebar-accent-foreground: oklch(0.205 0 0);
|
|
28
|
+
--sidebar-border: oklch(0.922 0 0);
|
|
29
|
+
--sidebar-ring: oklch(0.708 0 0);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.dark {
|
|
33
|
+
--brand: oklch(0.68 0.14 286);
|
|
34
|
+
--background: oklch(0.145 0 0);
|
|
35
|
+
--foreground: oklch(0.985 0 0);
|
|
36
|
+
--card: oklch(0.205 0 0);
|
|
37
|
+
--card-foreground: oklch(0.985 0 0);
|
|
38
|
+
--popover: oklch(0.205 0 0);
|
|
39
|
+
--popover-foreground: oklch(0.985 0 0);
|
|
40
|
+
--primary: var(--brand);
|
|
41
|
+
--primary-foreground: oklch(0.205 0 0);
|
|
42
|
+
--secondary: oklch(0.269 0 0);
|
|
43
|
+
--secondary-foreground: oklch(0.985 0 0);
|
|
44
|
+
--muted: oklch(0.269 0 0);
|
|
45
|
+
--muted-foreground: oklch(0.708 0 0);
|
|
46
|
+
--accent: oklch(0.269 0 0);
|
|
47
|
+
--accent-foreground: oklch(0.985 0 0);
|
|
48
|
+
--destructive: oklch(0.704 0.191 22.216);
|
|
49
|
+
--border: oklch(1 0 0 / 10%);
|
|
50
|
+
--input: oklch(1 0 0 / 15%);
|
|
51
|
+
--ring: oklch(0.556 0 0);
|
|
52
|
+
--sidebar: oklch(0.205 0 0);
|
|
53
|
+
--sidebar-foreground: oklch(0.985 0 0);
|
|
54
|
+
--sidebar-primary: oklch(0.488 0.243 264.376);
|
|
55
|
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
56
|
+
--sidebar-accent: oklch(0.269 0 0);
|
|
57
|
+
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
58
|
+
--sidebar-border: oklch(1 0 0 / 10%);
|
|
59
|
+
--sidebar-ring: oklch(0.556 0 0);
|
|
60
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-side config reader (runtime).
|
|
3
|
+
*
|
|
4
|
+
* Reads the RESOLVED config that the build pipeline serialized to
|
|
5
|
+
* `app/generated/config.json` (under the user's cwd — same contract as the doc
|
|
6
|
+
* manifest in `content.server.ts`). It never imports the user's TS config, so it
|
|
7
|
+
* is safe to bundle into the Remix server.
|
|
8
|
+
*
|
|
9
|
+
* Read is synchronous + process-cached: `getConfig()` is called in server render
|
|
10
|
+
* paths (loaders, `projects.ts` helpers) where async would be awkward, and the
|
|
11
|
+
* config is tiny and immutable for the process lifetime.
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
|
|
16
|
+
import { docsConfigSchema, type DocsConfig } from './schema'
|
|
17
|
+
import { DEFAULT_THEME, defaultUiFor } from './defaults'
|
|
18
|
+
|
|
19
|
+
const CONFIG_PATH = path.join(process.cwd(), 'app', 'generated', 'config.json')
|
|
20
|
+
|
|
21
|
+
let cache: DocsConfig | null = null
|
|
22
|
+
|
|
23
|
+
/** A fully-defaulted empty config, used when no config.json has been generated. */
|
|
24
|
+
function emptyConfig(): DocsConfig {
|
|
25
|
+
const parsed = docsConfigSchema.parse({})
|
|
26
|
+
return {
|
|
27
|
+
...parsed,
|
|
28
|
+
theme: { colors: { light: { ...DEFAULT_THEME.light }, dark: { ...DEFAULT_THEME.dark } } },
|
|
29
|
+
ui: { ...defaultUiFor(parsed.site.lang) },
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The resolved docs config for this site (process-cached). */
|
|
34
|
+
export function getConfig(): DocsConfig {
|
|
35
|
+
if (cache) return cache
|
|
36
|
+
try {
|
|
37
|
+
const raw = fs.readFileSync(CONFIG_PATH, 'utf8')
|
|
38
|
+
// Already resolved + validated at generate time; re-parse defensively so a
|
|
39
|
+
// hand-edited file still can't violate the schema at runtime.
|
|
40
|
+
cache = docsConfigSchema.parse(JSON.parse(raw))
|
|
41
|
+
} catch {
|
|
42
|
+
cache = emptyConfig()
|
|
43
|
+
}
|
|
44
|
+
return cache
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Resolved UI strings for the current site (lang-defaulted, user-overridden). */
|
|
48
|
+
export function getUi(): Record<string, string> {
|
|
49
|
+
return getConfig().ui
|
|
50
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shipped defaults: theme tokens + per-locale UI strings.
|
|
3
|
+
*
|
|
4
|
+
* These reproduce this project's ORIGINAL hardcoded values, so a config that
|
|
5
|
+
* omits `theme`/`ui` (or omits individual keys) renders byte-for-byte as before.
|
|
6
|
+
* `loadConfig` merges authored values over these.
|
|
7
|
+
*
|
|
8
|
+
* UI strings are keyed by `site.lang`. Only the chrome strings users typically
|
|
9
|
+
* want to rebrand are externalized here; the keyboard-shortcut cheatsheet labels
|
|
10
|
+
* (`useKeyboardShortcuts.ts`) remain in code as an advanced concern.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Default OKLCH theme tokens — mirror of `app/styles/tailwind.css` :root/.dark. */
|
|
14
|
+
export const DEFAULT_THEME = {
|
|
15
|
+
light: {
|
|
16
|
+
'--brand': 'oklch(0.42 0.158 286)',
|
|
17
|
+
'--background': 'oklch(1 0 0)',
|
|
18
|
+
'--foreground': 'oklch(0.145 0 0)',
|
|
19
|
+
'--card': 'oklch(1 0 0)',
|
|
20
|
+
'--card-foreground': 'oklch(0.145 0 0)',
|
|
21
|
+
'--popover': 'oklch(1 0 0)',
|
|
22
|
+
'--popover-foreground': 'oklch(0.145 0 0)',
|
|
23
|
+
'--primary': 'var(--brand)',
|
|
24
|
+
'--primary-foreground': 'oklch(0.985 0 0)',
|
|
25
|
+
'--secondary': 'oklch(0.97 0 0)',
|
|
26
|
+
'--secondary-foreground': 'oklch(0.205 0 0)',
|
|
27
|
+
'--muted': 'oklch(0.97 0 0)',
|
|
28
|
+
'--muted-foreground': 'oklch(0.556 0 0)',
|
|
29
|
+
'--accent': 'oklch(0.97 0 0)',
|
|
30
|
+
'--accent-foreground': 'oklch(0.205 0 0)',
|
|
31
|
+
'--destructive': 'oklch(0.577 0.245 27.325)',
|
|
32
|
+
'--border': 'oklch(0.922 0 0)',
|
|
33
|
+
'--input': 'oklch(0.922 0 0)',
|
|
34
|
+
'--ring': 'oklch(0.708 0 0)',
|
|
35
|
+
'--sidebar': 'oklch(0.985 0 0)',
|
|
36
|
+
'--sidebar-foreground': 'oklch(0.145 0 0)',
|
|
37
|
+
'--sidebar-primary': 'oklch(0.205 0 0)',
|
|
38
|
+
'--sidebar-primary-foreground': 'oklch(0.985 0 0)',
|
|
39
|
+
'--sidebar-accent': 'oklch(0.97 0 0)',
|
|
40
|
+
'--sidebar-accent-foreground': 'oklch(0.205 0 0)',
|
|
41
|
+
'--sidebar-border': 'oklch(0.922 0 0)',
|
|
42
|
+
'--sidebar-ring': 'oklch(0.708 0 0)',
|
|
43
|
+
} as Record<string, string>,
|
|
44
|
+
dark: {
|
|
45
|
+
'--brand': 'oklch(0.68 0.14 286)',
|
|
46
|
+
'--background': 'oklch(0.145 0 0)',
|
|
47
|
+
'--foreground': 'oklch(0.985 0 0)',
|
|
48
|
+
'--card': 'oklch(0.205 0 0)',
|
|
49
|
+
'--card-foreground': 'oklch(0.985 0 0)',
|
|
50
|
+
'--popover': 'oklch(0.205 0 0)',
|
|
51
|
+
'--popover-foreground': 'oklch(0.985 0 0)',
|
|
52
|
+
'--primary': 'var(--brand)',
|
|
53
|
+
'--primary-foreground': 'oklch(0.205 0 0)',
|
|
54
|
+
'--secondary': 'oklch(0.269 0 0)',
|
|
55
|
+
'--secondary-foreground': 'oklch(0.985 0 0)',
|
|
56
|
+
'--muted': 'oklch(0.269 0 0)',
|
|
57
|
+
'--muted-foreground': 'oklch(0.708 0 0)',
|
|
58
|
+
'--accent': 'oklch(0.269 0 0)',
|
|
59
|
+
'--accent-foreground': 'oklch(0.985 0 0)',
|
|
60
|
+
'--destructive': 'oklch(0.704 0.191 22.216)',
|
|
61
|
+
'--border': 'oklch(1 0 0 / 10%)',
|
|
62
|
+
'--input': 'oklch(1 0 0 / 15%)',
|
|
63
|
+
'--ring': 'oklch(0.556 0 0)',
|
|
64
|
+
'--sidebar': 'oklch(0.205 0 0)',
|
|
65
|
+
'--sidebar-foreground': 'oklch(0.985 0 0)',
|
|
66
|
+
'--sidebar-primary': 'oklch(0.488 0.243 264.376)',
|
|
67
|
+
'--sidebar-primary-foreground': 'oklch(0.985 0 0)',
|
|
68
|
+
'--sidebar-accent': 'oklch(0.269 0 0)',
|
|
69
|
+
'--sidebar-accent-foreground': 'oklch(0.985 0 0)',
|
|
70
|
+
'--sidebar-border': 'oklch(1 0 0 / 10%)',
|
|
71
|
+
'--sidebar-ring': 'oklch(0.556 0 0)',
|
|
72
|
+
} as Record<string, string>,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The UI string catalogue. Keys are stable identifiers used in components via
|
|
77
|
+
* `getUi(config)`; values are the originals (ru). `en` is a best-effort fallback
|
|
78
|
+
* for non-ru sites so an unset `ui` doesn't render Russian on an English site.
|
|
79
|
+
*/
|
|
80
|
+
export const UI_STRINGS: Record<string, Record<string, string>> = {
|
|
81
|
+
ru: {
|
|
82
|
+
projects: 'Проекты',
|
|
83
|
+
selectProject: 'Выберите проект',
|
|
84
|
+
noProject: 'Без проекта',
|
|
85
|
+
onThisPage: 'На этой странице',
|
|
86
|
+
properties: 'Свойства',
|
|
87
|
+
shortcuts: 'Горячие клавиши',
|
|
88
|
+
searchProjectFilter: 'Проект',
|
|
89
|
+
close: 'Закрыть',
|
|
90
|
+
closePanel: 'Закрыть панель',
|
|
91
|
+
closeSearch: 'Закрыть поиск',
|
|
92
|
+
closeTab: 'Закрыть вкладку',
|
|
93
|
+
home: 'Главная',
|
|
94
|
+
files: 'Файлы',
|
|
95
|
+
search: 'Поиск',
|
|
96
|
+
toggleTheme: 'Переключить тему',
|
|
97
|
+
},
|
|
98
|
+
en: {
|
|
99
|
+
projects: 'Projects',
|
|
100
|
+
selectProject: 'Select a project',
|
|
101
|
+
noProject: 'No project',
|
|
102
|
+
onThisPage: 'On this page',
|
|
103
|
+
properties: 'Properties',
|
|
104
|
+
shortcuts: 'Keyboard shortcuts',
|
|
105
|
+
searchProjectFilter: 'Project',
|
|
106
|
+
close: 'Close',
|
|
107
|
+
closePanel: 'Close panel',
|
|
108
|
+
closeSearch: 'Close search',
|
|
109
|
+
closeTab: 'Close tab',
|
|
110
|
+
home: 'Home',
|
|
111
|
+
files: 'Files',
|
|
112
|
+
search: 'Search',
|
|
113
|
+
toggleTheme: 'Toggle theme',
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** UI strings for a language, falling back to `en` then `ru`. */
|
|
118
|
+
export function defaultUiFor(lang: string): Record<string, string> {
|
|
119
|
+
return UI_STRINGS[lang] ?? UI_STRINGS.en ?? UI_STRINGS.ru
|
|
120
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-side config loader (Node only — imports the user's TS config).
|
|
3
|
+
*
|
|
4
|
+
* Runs from the build pipeline (`scripts/*`), which executes under
|
|
5
|
+
* `node --experimental-strip-types`, so it can `import()` the user's
|
|
6
|
+
* `docs.config.ts` directly. It validates against `docsConfigSchema`, merges the
|
|
7
|
+
* shipped theme/ui defaults, fills per-project derived defaults (logo, landing),
|
|
8
|
+
* and returns a fully-resolved `DocsConfig`.
|
|
9
|
+
*
|
|
10
|
+
* The running APP must NOT call this (Vite can't bundle the user's cwd TS file);
|
|
11
|
+
* the generator serializes the resolved config to `app/generated/config.json`,
|
|
12
|
+
* and the app reads that JSON at runtime via `config.server.ts`. This module is
|
|
13
|
+
* the single place the TS config is ever imported.
|
|
14
|
+
*/
|
|
15
|
+
import { pathToFileURL } from 'node:url'
|
|
16
|
+
import path from 'node:path'
|
|
17
|
+
import fs from 'node:fs'
|
|
18
|
+
|
|
19
|
+
import { docsConfigSchema, type DocsConfig } from './schema.ts'
|
|
20
|
+
import { DEFAULT_THEME, defaultUiFor } from './defaults.ts'
|
|
21
|
+
|
|
22
|
+
/** Candidate config filenames, in resolution order, relative to cwd. */
|
|
23
|
+
const CONFIG_NAMES = ['docs.config.ts', 'docs.config.js', 'docs.config.mjs']
|
|
24
|
+
|
|
25
|
+
/** Locate the user's config file in `cwd`, or null when none exists. */
|
|
26
|
+
function findConfigFile(cwd: string): string | null {
|
|
27
|
+
for (const name of CONFIG_NAMES) {
|
|
28
|
+
const p = path.join(cwd, name)
|
|
29
|
+
if (fs.existsSync(p)) return p
|
|
30
|
+
}
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Load + validate + fully resolve the docs config from `cwd` (defaults to
|
|
36
|
+
* `process.cwd()`). With no config file, returns an all-defaults config so the
|
|
37
|
+
* engine still runs (empty site, no projects) rather than throwing.
|
|
38
|
+
*/
|
|
39
|
+
export async function loadConfig(cwd: string = process.cwd()): Promise<DocsConfig> {
|
|
40
|
+
const file = findConfigFile(cwd)
|
|
41
|
+
let authored: unknown = {}
|
|
42
|
+
if (file) {
|
|
43
|
+
// Cache-bust with the file mtime so repeated loads in one watch session
|
|
44
|
+
// pick up edits (import() caches by URL otherwise).
|
|
45
|
+
const mtime = fs.statSync(file).mtimeMs
|
|
46
|
+
const mod = await import(`${pathToFileURL(file).href}?t=${mtime}`)
|
|
47
|
+
authored = mod.default ?? mod.config ?? mod
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const parsed = docsConfigSchema.safeParse(authored)
|
|
51
|
+
if (!parsed.success) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`Invalid docs.config:\n\n${JSON.stringify(parsed.error.format(), null, 2)}`,
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
return resolveDerived(parsed.data)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Fill values that depend on other fields and merge shipped defaults:
|
|
61
|
+
* - per-project `logo` → `/projects/<id>.svg`, `landing` → first-doc URL (left
|
|
62
|
+
* null here; the app derives it from the manifest when unset),
|
|
63
|
+
* - theme colors merged OVER `DEFAULT_THEME`,
|
|
64
|
+
* - `ui` merged OVER the per-`lang` default strings.
|
|
65
|
+
*/
|
|
66
|
+
function resolveDerived(config: DocsConfig): DocsConfig {
|
|
67
|
+
const projects = config.projects.map((p) => ({
|
|
68
|
+
...p,
|
|
69
|
+
logo: p.logo ?? `/projects/${p.id}.svg`,
|
|
70
|
+
}))
|
|
71
|
+
|
|
72
|
+
const theme = {
|
|
73
|
+
colors: {
|
|
74
|
+
light: { ...DEFAULT_THEME.light, ...config.theme.colors.light },
|
|
75
|
+
dark: { ...DEFAULT_THEME.dark, ...config.theme.colors.dark },
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const ui = { ...defaultUiFor(config.site.lang), ...config.ui }
|
|
80
|
+
|
|
81
|
+
return { ...config, projects, theme, ui }
|
|
82
|
+
}
|